fix: eliminate double browse requests in file manager
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 条
|
||||||
@@ -79,6 +79,12 @@ export async function fetchAuthed(
|
|||||||
const { params, _retry, ...init } = opts
|
const { params, _retry, ...init } = opts
|
||||||
const url = buildApiUrl(path, params)
|
const url = buildApiUrl(path, params)
|
||||||
const auth = useAuthStore()
|
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, {
|
const res = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
headers: buildAuthHeaders(init),
|
headers: buildAuthHeaders(init),
|
||||||
|
|||||||
@@ -596,6 +596,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// ── State ──
|
// ── State ──
|
||||||
|
const _mounted = ref(false) // 防止 route watcher 在 onMounted 前触发双重 browse
|
||||||
const selectedServer = ref<number | null>(null)
|
const selectedServer = ref<number | null>(null)
|
||||||
const currentPath = ref(DEFAULT_FILES_ROOT)
|
const currentPath = ref(DEFAULT_FILES_ROOT)
|
||||||
const files = ref<FileEntry[]>([])
|
const files = ref<FileEntry[]>([])
|
||||||
@@ -1487,11 +1488,13 @@ onMounted(async () => {
|
|||||||
: defaultPathForServer(initialId)
|
: defaultPathForServer(initialId)
|
||||||
await refreshFilesCapability()
|
await refreshFilesCapability()
|
||||||
browse()
|
browse()
|
||||||
|
_mounted.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.query.server_id,
|
() => route.query.server_id,
|
||||||
(id) => {
|
(id) => {
|
||||||
|
if (!_mounted.value) return // 初始化未完成时跳过,避免与 onMounted 双重 browse
|
||||||
if (id && Number(id) !== selectedServer.value) {
|
if (id && Number(id) !== selectedServer.value) {
|
||||||
selectedServer.value = Number(id)
|
selectedServer.value = Number(id)
|
||||||
const qPath = route.query.path
|
const qPath = route.query.path
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ import { ApiError, TotpRequiredError, api, http } from '@/api'
|
|||||||
|
|
||||||
export { TotpRequiredError }
|
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', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string | null>(null)
|
const token = ref<string | null>(null)
|
||||||
const admin = ref<AdminProfile | null>(null)
|
const admin = ref<AdminProfile | null>(null)
|
||||||
@@ -14,6 +26,15 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
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) {
|
function setAccessToken(accessToken: string) {
|
||||||
token.value = accessToken
|
token.value = accessToken
|
||||||
}
|
}
|
||||||
@@ -89,6 +110,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
admin,
|
admin,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
bootstrapped,
|
bootstrapped,
|
||||||
|
tokenExp,
|
||||||
|
isTokenExpiringSoon,
|
||||||
setAccessToken,
|
setAccessToken,
|
||||||
tryRestoreSession,
|
tryRestoreSession,
|
||||||
login,
|
login,
|
||||||
|
|||||||
Reference in New Issue
Block a user