# 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 │ └─────────────────────────────────────────────────────────┘ ``` ## 总结 通过这次重构,我们实现了: 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 中的错误处理模式