diff --git a/docs/changelog/2026-06-02-proactive-jwt-refresh.md b/docs/changelog/2026-06-02-proactive-jwt-refresh.md new file mode 100644 index 00000000..dde69a67 --- /dev/null +++ b/docs/changelog/2026-06-02-proactive-jwt-refresh.md @@ -0,0 +1,71 @@ +# Changelog: 主动 JWT 续期 —— 消除文件管理器双刷新 + +**日期**: 2026-06-02 +**类型**: 修复 (Bug Fix) +**影响范围**: frontend/src/stores/auth.ts · frontend/src/api/index.ts + +--- + +## 变更摘要 + +实现客户端 JWT access token 提前续期机制,彻底消除文件管理器"进目录双刷新"问题。 + +--- + +## 动机 + +每次 access token 过期后,`api/index.ts` 的 `fetchAuthed()` 会: +1. 先发出一次业务请求 → 服务端返回 401 +2. 触发 `/auth/refresh` 获取新 token +3. 用新 token 重发同一业务请求 + +导致每次进入目录产生 2 次 `/api/sync/browse` 请求,页面可见"双刷新"(`_browseSeq` 虽可防止旧数据覆盖,但无法消除 2 次请求本身)。 + +--- + +## 方案 + +### `frontend/src/stores/auth.ts` + +- 新增 `decodeJwtExp(jwt)` 工具函数:不验签地从 JWT Payload 解码 `exp` 字段(秒) +- 新增 `tokenExp` computed:返回当前 token 的过期 unix 秒,无 token 时为 0 +- 新增 `isTokenExpiringSoon(thresholdSec = 300)` 方法:当距过期 < `thresholdSec` 秒时返回 true + +### `frontend/src/api/index.ts` + +在 `fetchAuthed()` 发出实际请求之前加入一个提前续期分支: + +```typescript +if (!_retry && !AUTH_SKIP_REFRESH.has(path) && auth.token && auth.isTokenExpiringSoon(300)) { + await tryRefreshSession() +} +``` + +若 token 将在 5 分钟内过期,先刷新再发请求,完全跳过"401 → 重试"路径。 +原有的 401 兜底重试逻辑保留不变(处理极端并发或时钟偏差场景)。 + +--- + +## 涉及文件 + +| 文件 | 变更类型 | +|------|---------| +| `frontend/src/stores/auth.ts` | 新增 `decodeJwtExp` / `tokenExp` / `isTokenExpiringSoon` | +| `frontend/src/api/index.ts` | 在 `fetchAuthed` 中新增提前续期分支 | + +--- + +## 是否需要迁移 / 重启 + +- **前端**: 需重新 `vite build` 后部署到服务器 +- **后端**: 无变更,无需重启 +- **数据库**: 无变更 + +--- + +## 验证方式 + +1. 登录后等待 access token 接近过期(或手动修改 token exp 为近期时间) +2. 进入文件管理器浏览目录 +3. 打开 DevTools Network 过滤 `/sync/browse` +4. 预期:每次进目录只有 **1 条** `/sync/browse` 请求,不再出现 401 后重试的第 2 条 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 352591ad..ba0126a4 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -79,6 +79,12 @@ export async function fetchAuthed( const { params, _retry, ...init } = opts const url = buildApiUrl(path, params) const auth = useAuthStore() + + // 主动续期:距过期 < 5min 且非 auth 路径,先刷新再发请求(消除 401 重试双请求) + if (!_retry && !AUTH_SKIP_REFRESH.has(path) && auth.token && auth.isTokenExpiringSoon(300)) { + await tryRefreshSession() + } + const res = await fetch(url, { ...init, headers: buildAuthHeaders(init), diff --git a/frontend/src/pages/FilesPage.vue b/frontend/src/pages/FilesPage.vue index 4b05fc70..b2e8dead 100644 --- a/frontend/src/pages/FilesPage.vue +++ b/frontend/src/pages/FilesPage.vue @@ -596,6 +596,7 @@ const route = useRoute() const router = useRouter() // ── State ── +const _mounted = ref(false) // 防止 route watcher 在 onMounted 前触发双重 browse const selectedServer = ref(null) const currentPath = ref(DEFAULT_FILES_ROOT) const files = ref([]) @@ -1487,11 +1488,13 @@ onMounted(async () => { : defaultPathForServer(initialId) await refreshFilesCapability() browse() + _mounted.value = true }) watch( () => route.query.server_id, (id) => { + if (!_mounted.value) return // 初始化未完成时跳过,避免与 onMounted 双重 browse if (id && Number(id) !== selectedServer.value) { selectedServer.value = Number(id) const qPath = route.query.path diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index c6d8c623..a16b9567 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -7,6 +7,18 @@ import { ApiError, TotpRequiredError, api, http } from '@/api' export { TotpRequiredError } +/** 从 JWT 不验签地解码 exp 字段(秒)。失败返回 0。 */ +function decodeJwtExp(jwt: string): number { + try { + const part = jwt.split('.')[1] + const json = atob(part.replace(/-/g, '+').replace(/_/g, '/')) + const obj = JSON.parse(json) as { exp?: number } + return typeof obj.exp === 'number' ? obj.exp : 0 + } catch { + return 0 + } +} + export const useAuthStore = defineStore('auth', () => { const token = ref(null) const admin = ref(null) @@ -14,6 +26,15 @@ export const useAuthStore = defineStore('auth', () => { const isLoggedIn = computed(() => !!token.value) + /** token 过期时间(unix 秒),无 token 时为 0 */ + const tokenExp = computed(() => (token.value ? decodeJwtExp(token.value) : 0)) + + /** token 是否即将过期(默认 5 分钟内) */ + function isTokenExpiringSoon(thresholdSec = 300): boolean { + const exp = tokenExp.value + return exp > 0 && exp - Math.floor(Date.now() / 1000) < thresholdSec + } + function setAccessToken(accessToken: string) { token.value = accessToken } @@ -89,6 +110,8 @@ export const useAuthStore = defineStore('auth', () => { admin, isLoggedIn, bootstrapped, + tokenExp, + isTokenExpiringSoon, setAccessToken, tryRestoreSession, login,