# AuthContext 用裸 fetch 绕过 apiClient 401 拦截器

**日期**：2026-04-27
**触发**：用户报告"登录后过一段时间提示没有权限，且不跳登录"。MCP 复现 + 修复（PR #158）。

## 现象

- 用户登录正常，使用一段时间后（access token 30min TTL 过期前后）
- 侧边栏菜单缩到 4 项（Dashboard / Feedback / Knowledge Base / Performance）
- 顶部还显示"IT Administrator"用户名
- **不跳登录页，无任何会话过期提示**
- 客户端 console：`[useFilteredNavigation] isAdmin: false`，但 `auth-storage` 里 roles=['Administrator']

## 根因（不直观）

前端有**两套 `useAuth`**：
- `@/lib/auth` 是 zustand persist，从 login 响应直接拿，长期存在 localStorage
- `@/contexts/AuthContext` 是 React Context，每次 mount 调 `/users/me` 重新拿权限

历史遗留双源结构本身已经是债，但更严重的是：**`AuthContext.loadUserData` 用裸 `fetch` 调 `/users/me`，绕过 `apiClient` 的 axios 拦截器**。

apiClient 的 401 拦截器是项目里**唯一**做 token 自动 refresh + retry + refresh 失败跳登录的地方。AuthContext 不走它意味着：
- 401 时不会自动 refresh + retry
- 401 时不会跳登录（`if (response.ok)` 直接 return，silently 把 user/permissions/isAdmin 留在初始 null/Set()/false）
- 但其它走 apiClient 的请求会触发 refresh → token 被悄悄换好，所以 `apiClient` 之后的调用都正常 → **造成"已登录但无权限"假象**

zustand `auth-storage` 持久化了用户名和角色，所以右上角显示正常；但 AuthContext 的 `permissions` 是空 Set → 客户端权限 gate 全部 false → 菜单缩水。

## 修复

```typescript
// 改前
const response = await fetch(`${API_URL}/users/me`, {
  headers: { 'Authorization': `Bearer ${token}` },
});
if (response.ok) { ... }   // 401 时静默忽略

// 改后
import apiClient from '@/lib/api-client';
const userData = await apiClient.get('/users/me');  // 自动 401→refresh→retry / 失败跳登录
```

## 复现技巧（MCP 加速调试 token 失效路径）

不必等 30 分钟自然过期。用 Playwright MCP `browser_evaluate` 直接污染 localStorage：

```js
// 模拟 access 过期（refresh 仍有效）
localStorage.setItem('token', 'eyJ.expired.fake');
// 或 + 模拟 refresh 也失效
localStorage.setItem('refresh_token', 'fake-refresh-token-invalid');
```

然后 `browser_navigate` 到任意受保护页面，配合 `browser_network_requests` 看 401 → refresh 链路是否符合预期、`browser_console_messages` 看客户端权限 gate 状态。一次完整复现 < 30 秒。

## 通用规则

**项目里所有受 token 保护的 API 调用必须走 `apiClient`，不可用裸 `fetch` / 单独 `axios.create`。** 唯一例外是 `apiClient.ts` 内部的 `performRefresh` 自身（避免无限循环）。

新增 hook / context / 服务里调 `/api/v1/*` 时，第一行就 import apiClient。

## 也是次因（顺手修了）

`auth.service.ts` 的 login / devEmailLogin 响应里 `permissions: []` 是 TODO，前端依赖 `/users/me` 二次拉补；一旦 #1 触发，AuthContext 二次拉失败 → 雪崩。本次同步把 TODO 落地：query include role.permissions，新增 file-scoped `aggregatePermissionCodes`（不复用 users.service 的 findOneWithPermissions，避免 auth↔users 循环依赖）。

## 后续待办

- 双 useAuth 合并（架构整理，独立 PR）
- 后端 JwtStrategy 从 Header 读 X-Organization-Id 传给 validateUser，避免未来移除 v2.1 region=null 兜底时丢 org-scoped 权限（独立 PR）
