Files
leaudit-platform-frontend/docs/Token自动管理_测试说明.md
T
2025-12-05 00:09:32 +08:00

389 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
- 重复添加 tokenmergeAuthHeaders + axios 拦截器)
- 手动传递 token 容易遗漏
- 代码冗余,维护困难
### 修改后(统一)
```
登录 → localStorage (access_token)
Component → 不传递 token
API 函数 → 不接受 token 参数
→ axios 拦截器 (自动从 localStorage)
最终请求 → Authorization: Bearer xxx
```
**优点**
- ✅ 单一 token 来源(localStorage
- ✅ 自动添加 tokenaxios 拦截器)
- ✅ 无需手动传递
- ✅ 代码简洁,易维护
## 潜在问题和解决方案
### 问题 1SSR 环境无法访问 localStorage
**场景**Remix loader/action 在服务端运行,无法访问 localStorage
**解决方案**
- Loader/Action 中使用 Cookie Session`getUserSession`
- 客户端组件使用 localStorage + axios 拦截器
- 不需要在 loader 中传递 token 给客户端
### 问题 2Token 过期
**场景**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 中的错误处理模式