all in
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
# Token 自动管理 - 测试说明
|
||||
|
||||
## 修改摘要
|
||||
|
||||
### 目标
|
||||
统一 Token 管理机制,**完全依赖自动获取**,移除所有手动传递 token 的代码。
|
||||
|
||||
### 架构说明
|
||||
|
||||
#### 1. **客户端请求**(axios 拦截器自动处理)
|
||||
在**浏览器环境**中,所有使用 `axiosInstance` 的请求都会被 axios 拦截器自动添加 token:
|
||||
|
||||
**axios 拦截器** (`app/api/axios-client.ts:66-86`):
|
||||
```typescript
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
// 检查是否在白名单中
|
||||
if (isInAuthWhitelist(config.url)) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// ⚠️ 重要:只在浏览器环境中自动添加 token
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
```
|
||||
|
||||
**受益的请求类型**:
|
||||
- 客户端组件中的 `useEffect`、`useCallback`、事件处理函数中的所有 API 请求
|
||||
- 所有 PostgREST 请求(`postgrestGet`, `postgrestPost`, `postgrestPut`, `postgrestDelete`)
|
||||
- 所有使用 `apiRequest`、`downloadFile` 的请求
|
||||
|
||||
#### 1.5. **服务端请求**(需要显式传递 token)
|
||||
在 **Remix Loader/Action(服务端)** 中,`typeof window === 'undefined'`,axios 拦截器**不会**自动添加 token。
|
||||
|
||||
**解决方案**:从 Cookie Session 获取 `frontendJWT` 并显式传递给 API 函数
|
||||
|
||||
**示例** (`app/routes/rules-files.tsx:62-74`):
|
||||
```typescript
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
// ✅ 从 Cookie Session 获取 frontendJWT
|
||||
const { getUserSession } = await import("~/api/login/auth.server");
|
||||
const { userInfo, frontendJWT } = await getUserSession(request);
|
||||
|
||||
// ✅ 显式传递 token 给 API 函数
|
||||
const typesResponse = await getDocumentTypes({pageSize:500}, frontendJWT);
|
||||
|
||||
return Response.json({ ... });
|
||||
}
|
||||
```
|
||||
|
||||
**需要显式传递 token 的场景**:
|
||||
- 所有 Remix Loader 函数
|
||||
- 所有 Remix Action 函数
|
||||
- 任何在 Node.js 服务端运行的代码
|
||||
|
||||
#### 2. **使用 fetch 的请求**(手动处理)
|
||||
不使用 axios 的请求需要手动从 localStorage 获取 token:
|
||||
|
||||
**示例** (`app/api/files/files-upload.ts:appendContractAttachments`):
|
||||
```typescript
|
||||
// 设置请求头
|
||||
const headers: HeadersInit = {
|
||||
'Accept': 'application/json'
|
||||
};
|
||||
|
||||
// 从 localStorage 获取 token
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData
|
||||
});
|
||||
```
|
||||
|
||||
### 修改的文件
|
||||
|
||||
#### 1. `app/routes/rules-files.tsx`
|
||||
**Loader 修改**:
|
||||
- ✅ 从 `getUserSession` 获取 `frontendJWT`(line 65)
|
||||
- ✅ 传递给 `getDocumentTypes`(line 74)
|
||||
|
||||
**客户端组件修改**:
|
||||
- ❌ 移除从 `useLoaderData` 中读取 `frontendJWT`
|
||||
- ❌ 移除所有客户端 API 调用中的 token 参数
|
||||
- ✅ 完全依赖 axios 拦截器自动添加 token
|
||||
|
||||
#### 1.5. `app/routes/documents.list.tsx`
|
||||
**Loader 修改**:
|
||||
- ✅ 从 `getUserSession` 获取 `frontendJWT`(line 44)
|
||||
- ✅ 传递给 `getDocumentTypes`(line 52)
|
||||
|
||||
**客户端组件修改**:
|
||||
- ❌ 移除所有客户端 API 调用中的 token 参数
|
||||
- ✅ 完全依赖 axios 拦截器自动添加 token
|
||||
|
||||
#### 2. `app/api/evaluation_points/rules-files.ts`
|
||||
**移除的内容**:
|
||||
- ❌ `DocumentSearchParams.token` 字段
|
||||
- ❌ `getReviewFiles` 函数的 `token` 参数处理
|
||||
- ❌ `updateDocumentAuditStatus` 函数的 `token` 参数
|
||||
- ❌ 所有 `postgrestPost`/`postgrestPut` 调用中的 token 参数
|
||||
|
||||
**关键修改**:
|
||||
```typescript
|
||||
// ❌ 修改前
|
||||
export async function getReviewFiles(searchParams: DocumentSearchParams = {}, ...) {
|
||||
const { token } = searchParams;
|
||||
const filesResponse = await postgrestPost(..., listParams, token);
|
||||
}
|
||||
|
||||
export async function updateDocumentAuditStatus(id, auditStatus, userId, token?) {
|
||||
const response = await postgrestPut(..., token);
|
||||
}
|
||||
|
||||
// ✅ 修改后
|
||||
export async function getReviewFiles(searchParams: DocumentSearchParams = {}, ...) {
|
||||
// token 参数已移除
|
||||
const filesResponse = await postgrestPost(..., listParams); // axios 拦截器自动添加
|
||||
}
|
||||
|
||||
export async function updateDocumentAuditStatus(id, auditStatus, userId) {
|
||||
const response = await postgrestPut(...); // axios 拦截器自动添加
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. `app/api/files/files-upload.ts`
|
||||
**修改的内容**:
|
||||
- ❌ 移除 `appendContractAttachments` 的 `jwtToken` 参数
|
||||
- ✅ 添加从 localStorage 获取 token 的逻辑(因为使用 fetch,不经过 axios 拦截器)
|
||||
|
||||
**关键修改**:
|
||||
```typescript
|
||||
// ❌ 修改前
|
||||
export async function appendContractAttachments(..., jwtToken?: string) {
|
||||
if (jwtToken) {
|
||||
headers['Authorization'] = `Bearer ${jwtToken}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 修改后
|
||||
export async function appendContractAttachments(...) {
|
||||
// 从 localStorage 获取 token
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试步骤
|
||||
|
||||
### 1. 登录测试
|
||||
**目的**:验证登录后 token 正确保存到 localStorage
|
||||
|
||||
**步骤**:
|
||||
1. 打开浏览器开发者工具 → Application → Local Storage
|
||||
2. 访问登录页面并登录(单点登录或管理员登录)
|
||||
3. 检查 localStorage 中是否有 `access_token` 和 `user_info`
|
||||
|
||||
**预期结果**:
|
||||
```
|
||||
access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
user_info: {"user_id":1,"username":"test",...}
|
||||
```
|
||||
|
||||
### 2. 评查文件列表测试(PostgREST 请求)
|
||||
**目的**:验证 axios 拦截器自动添加 token
|
||||
|
||||
**步骤**:
|
||||
1. 打开浏览器开发者工具 → Network
|
||||
2. 访问 `/rules-files` 页面
|
||||
3. 查看 `get_review_files_with_details` 和 `count_review_files` 请求
|
||||
4. 检查 Request Headers 中的 `Authorization` 字段
|
||||
|
||||
**预期结果**:
|
||||
```
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
**验证代码**:
|
||||
- `app/routes/rules-files.tsx:177` - `fetchData` 中调用 `getReviewFiles`
|
||||
- 不传递 token 参数,完全依赖 axios 拦截器
|
||||
|
||||
### 3. 完成评查按钮测试(PostgREST 请求)
|
||||
**目的**:验证 PUT 请求也能自动添加 token
|
||||
|
||||
**步骤**:
|
||||
1. 打开浏览器开发者工具 → Network
|
||||
2. 在评查文件列表中点击"完成评查"按钮
|
||||
3. 查看 `documents?id=eq...&user_id=eq...` 的 PATCH/PUT 请求
|
||||
4. 检查 Request Headers 中的 `Authorization` 字段
|
||||
|
||||
**预期结果**:
|
||||
```
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
**验证代码**:
|
||||
- `app/routes/rules-files.tsx:338` - `handleReviewFileClick` 中调用 `updateDocumentAuditStatus`
|
||||
- 不传递 token 参数,完全依赖 axios 拦截器
|
||||
|
||||
### 4. 附件追加测试(fetch 请求)
|
||||
**目的**:验证使用 fetch 的请求也能正确添加 token
|
||||
|
||||
**步骤**:
|
||||
1. 打开浏览器开发者工具 → Network
|
||||
2. 在评查文件列表中点击"追加附件"按钮,上传附件
|
||||
3. 查看 `contracts/{id}/append_attachments` 的 POST 请求
|
||||
4. 检查 Request Headers 中的 `Authorization` 字段
|
||||
|
||||
**预期结果**:
|
||||
```
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
**验证代码**:
|
||||
- `app/routes/rules-files.tsx:515` - `handleAttachmentUpload` 中调用 `appendContractAttachments`
|
||||
- 不传递 token 参数,函数内部从 localStorage 获取
|
||||
|
||||
### 5. 认证失败测试
|
||||
**目的**:验证 token 过期或缺失时的行为
|
||||
|
||||
**步骤**:
|
||||
1. 打开浏览器开发者工具 → Application → Local Storage
|
||||
2. 删除 `access_token`
|
||||
3. 刷新 `/rules-files` 页面
|
||||
4. 查看 Network 中的请求是否返回 401
|
||||
|
||||
**预期结果**:
|
||||
- 请求返回 401 Unauthorized
|
||||
- 页面显示"获取文档列表失败: 用户身份验证失败"
|
||||
|
||||
### 6. 其他页面测试
|
||||
**目的**:验证其他页面也能正常工作
|
||||
|
||||
**页面列表**:
|
||||
- `/documents` - 文档列表(已修改)
|
||||
- `/cross-checking` - 交叉评查
|
||||
- `/contract-template` - 合同模板搜索
|
||||
- `/chat-with-llm` - AI 对话
|
||||
|
||||
**步骤**:
|
||||
1. 访问每个页面
|
||||
2. 检查 Network 中的 API 请求是否都带有 `Authorization` header
|
||||
|
||||
## Token 管理架构对比
|
||||
|
||||
### 修改前(混乱)
|
||||
```
|
||||
登录 → Cookie Session (frontendJWT)
|
||||
↓
|
||||
Loader → 传递 frontendJWT 给客户端
|
||||
↓
|
||||
Component → 手动传递 token 给 API 函数
|
||||
↓
|
||||
API 函数 → 接受 token 参数
|
||||
↓
|
||||
→ mergeAuthHeaders (从 token 参数或 localStorage)
|
||||
↓
|
||||
→ axios 拦截器 (再次从 localStorage)
|
||||
↓
|
||||
最终请求 → Authorization: Bearer xxx (重复添加)
|
||||
```
|
||||
|
||||
**问题**:
|
||||
- Token 来源混乱(Cookie Session vs localStorage)
|
||||
- 重复添加 token(mergeAuthHeaders + axios 拦截器)
|
||||
- 手动传递 token 容易遗漏
|
||||
- 代码冗余,维护困难
|
||||
|
||||
### 修改后(统一)
|
||||
```
|
||||
登录 → localStorage (access_token)
|
||||
↓
|
||||
Component → 不传递 token
|
||||
↓
|
||||
API 函数 → 不接受 token 参数
|
||||
↓
|
||||
→ axios 拦截器 (自动从 localStorage)
|
||||
↓
|
||||
最终请求 → Authorization: Bearer xxx
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- ✅ 单一 token 来源(localStorage)
|
||||
- ✅ 自动添加 token(axios 拦截器)
|
||||
- ✅ 无需手动传递
|
||||
- ✅ 代码简洁,易维护
|
||||
|
||||
## 潜在问题和解决方案
|
||||
|
||||
### 问题 1:SSR 环境无法访问 localStorage
|
||||
**场景**:Remix loader/action 在服务端运行,无法访问 localStorage
|
||||
|
||||
**解决方案**:
|
||||
- Loader/Action 中使用 Cookie Session(`getUserSession`)
|
||||
- 客户端组件使用 localStorage + axios 拦截器
|
||||
- 不需要在 loader 中传递 token 给客户端
|
||||
|
||||
### 问题 2:Token 过期
|
||||
**场景**:Token 过期后请求返回 401
|
||||
|
||||
**当前行为**:
|
||||
- axios 响应拦截器检测到 401
|
||||
- 清除 localStorage
|
||||
- 跳转到登录页
|
||||
|
||||
**代码位置**:`app/api/axios-client.ts:91-121`
|
||||
|
||||
### 问题 3:使用 fetch 的请求
|
||||
**场景**:不使用 axios 的请求无法被拦截器处理
|
||||
|
||||
**解决方案**:
|
||||
- 在函数内部手动从 localStorage 获取 token
|
||||
- 示例:`appendContractAttachments` (已修改)
|
||||
|
||||
## Token 管理流程图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Token 管理架构 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ 客户端组件 │ │ 服务端 Loader │
|
||||
│ (Browser) │ │ (Node.js) │
|
||||
└──────────┬──────────┘ └──────────┬──────────┘
|
||||
│ │
|
||||
│ API 调用 │ API 调用
|
||||
│ (不传递 token) │ (显式传递 frontendJWT)
|
||||
↓ ↓
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ axios 拦截器 │ │ API 函数 │
|
||||
│ - 检查 window │ │ - 接受 token 参数 │
|
||||
│ - 从 localStorage │ │ - 传递给 │
|
||||
│ 获取 token │ │ postgrestGet/Post │
|
||||
│ - 自动添加到 header │ └──────────┬───────────┘
|
||||
└──────────┬───────────┘ │
|
||||
│ │
|
||||
│ │ token 参数
|
||||
↓ ↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ axiosInstance (HTTP 请求) │
|
||||
│ - Authorization: Bearer <token> │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
通过这次重构,我们实现了:
|
||||
|
||||
1. **统一 Token 来源**:
|
||||
- **客户端**:从 localStorage 获取(axios 拦截器自动)
|
||||
- **服务端**:从 Cookie Session 获取(显式传递)
|
||||
|
||||
2. **自动 Token 注入**:
|
||||
- **客户端**:axios 拦截器自动为所有请求添加 Authorization header
|
||||
- **服务端**:API 函数接受 optional token 参数
|
||||
|
||||
3. **简化代码**:
|
||||
- 客户端组件无需手动传递 token
|
||||
- 服务端 loader 统一从 `getUserSession` 获取
|
||||
|
||||
4. **易于维护**:Token 管理逻辑清晰分离
|
||||
|
||||
**关键要点**:
|
||||
- ⚠️ **Loader/Action 中必须显式传递 token**(服务端无 localStorage)
|
||||
- ✅ **客户端组件无需传递 token**(axios 拦截器自动处理)
|
||||
- ✅ **API 函数保留 optional token 参数**(向后兼容)
|
||||
|
||||
**下一步建议**:
|
||||
- 考虑将所有使用 fetch 的 API 改为使用 axios,统一请求方式
|
||||
- 统一 loader 中的错误处理模式
|
||||
Reference in New Issue
Block a user