13 KiB
Token 自动管理 - 测试说明
修改摘要
目标
统一 Token 管理机制,完全依赖自动获取,移除所有手动传递 token 的代码。
架构说明
1. 客户端请求(axios 拦截器自动处理)
在浏览器环境中,所有使用 axiosInstance 的请求都会被 axios 拦截器自动添加 token:
axios 拦截器 (app/api/axios-client.ts:66-86):
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):
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):
// 设置请求头
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 参数
关键修改:
// ❌ 修改前
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 拦截器)
关键修改:
// ❌ 修改前
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
步骤:
- 打开浏览器开发者工具 → Application → Local Storage
- 访问登录页面并登录(单点登录或管理员登录)
- 检查 localStorage 中是否有
access_token和user_info
预期结果:
access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
user_info: {"user_id":1,"username":"test",...}
2. 评查文件列表测试(PostgREST 请求)
目的:验证 axios 拦截器自动添加 token
步骤:
- 打开浏览器开发者工具 → Network
- 访问
/rules-files页面 - 查看
get_review_files_with_details和count_review_files请求 - 检查 Request Headers 中的
Authorization字段
预期结果:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
验证代码:
app/routes/rules-files.tsx:177-fetchData中调用getReviewFiles- 不传递 token 参数,完全依赖 axios 拦截器
3. 完成评查按钮测试(PostgREST 请求)
目的:验证 PUT 请求也能自动添加 token
步骤:
- 打开浏览器开发者工具 → Network
- 在评查文件列表中点击"完成评查"按钮
- 查看
documents?id=eq...&user_id=eq...的 PATCH/PUT 请求 - 检查 Request Headers 中的
Authorization字段
预期结果:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
验证代码:
app/routes/rules-files.tsx:338-handleReviewFileClick中调用updateDocumentAuditStatus- 不传递 token 参数,完全依赖 axios 拦截器
4. 附件追加测试(fetch 请求)
目的:验证使用 fetch 的请求也能正确添加 token
步骤:
- 打开浏览器开发者工具 → Network
- 在评查文件列表中点击"追加附件"按钮,上传附件
- 查看
contracts/{id}/append_attachments的 POST 请求 - 检查 Request Headers 中的
Authorization字段
预期结果:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
验证代码:
app/routes/rules-files.tsx:515-handleAttachmentUpload中调用appendContractAttachments- 不传递 token 参数,函数内部从 localStorage 获取
5. 认证失败测试
目的:验证 token 过期或缺失时的行为
步骤:
- 打开浏览器开发者工具 → Application → Local Storage
- 删除
access_token - 刷新
/rules-files页面 - 查看 Network 中的请求是否返回 401
预期结果:
- 请求返回 401 Unauthorized
- 页面显示"获取文档列表失败: 用户身份验证失败"
6. 其他页面测试
目的:验证其他页面也能正常工作
页面列表:
/documents- 文档列表(已修改)/cross-checking- 交叉评查/contract-template- 合同模板搜索/chat-with-llm- AI 对话
步骤:
- 访问每个页面
- 检查 Network 中的 API 请求是否都带有
Authorizationheader
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> │
└─────────────────────────────────────────────────────────┘
总结
通过这次重构,我们实现了:
-
统一 Token 来源:
- 客户端:从 localStorage 获取(axios 拦截器自动)
- 服务端:从 Cookie Session 获取(显式传递)
-
自动 Token 注入:
- 客户端:axios 拦截器自动为所有请求添加 Authorization header
- 服务端:API 函数接受 optional token 参数
-
简化代码:
- 客户端组件无需手动传递 token
- 服务端 loader 统一从
getUserSession获取
-
易于维护:Token 管理逻辑清晰分离
关键要点:
- ⚠️ Loader/Action 中必须显式传递 token(服务端无 localStorage)
- ✅ 客户端组件无需传递 token(axios 拦截器自动处理)
- ✅ API 函数保留 optional token 参数(向后兼容)
下一步建议:
- 考虑将所有使用 fetch 的 API 改为使用 axios,统一请求方式
- 统一 loader 中的错误处理模式