diff --git a/backend/src/common/auth/jwt.service.ts b/backend/src/common/auth/jwt.service.ts index 05900c55..67fa1a7b 100644 --- a/backend/src/common/auth/jwt.service.ts +++ b/backend/src/common/auth/jwt.service.ts @@ -51,9 +51,9 @@ export interface DecodedToken extends JWTPayload { } // Token 配置 -const ACCESS_TOKEN_EXPIRES_IN = '2h'; // Access Token 2小时过期 +const ACCESS_TOKEN_EXPIRES_IN = '24h'; // Access Token 24小时过期(长操作如审稿/文献检索需要充足时间) const REFRESH_TOKEN_EXPIRES_IN = '7d'; // Refresh Token 7天过期 -const ACCESS_TOKEN_EXPIRES_SECONDS = 2 * 60 * 60; // 7200秒 +const ACCESS_TOKEN_EXPIRES_SECONDS = 24 * 60 * 60; // 86400秒 /** * JWT Service 类 diff --git a/frontend-v2/src/common/api/axios.ts b/frontend-v2/src/common/api/axios.ts index acd20972..b75d68df 100644 --- a/frontend-v2/src/common/api/axios.ts +++ b/frontend-v2/src/common/api/axios.ts @@ -11,8 +11,8 @@ import { getAccessToken, getRefreshToken, refreshAccessToken, - clearTokens, } from '../../framework/auth/api'; +import { showSessionExpiredAndRedirect } from '../../framework/auth/sessionGuard'; const apiClient = axios.create({ timeout: 60000, @@ -68,11 +68,7 @@ apiClient.interceptors.response.use( if (!is401 || !hasRefreshToken || alreadyRetried || isKicked) { if (is401) { - clearTokens(); - if (isKicked) { - alert('您的账号已在其他设备登录,当前会话已失效,请重新登录'); - } - window.location.href = '/login'; + showSessionExpiredAndRedirect(isKicked ? 'kicked' : undefined); } return Promise.reject(error); } @@ -101,8 +97,7 @@ apiClient.interceptors.response.use( return apiClient(originalRequest); } catch (refreshError) { processPendingQueue(refreshError, null); - clearTokens(); - window.location.href = '/login'; + showSessionExpiredAndRedirect(); return Promise.reject(refreshError); } finally { isRefreshing = false; diff --git a/frontend-v2/src/framework/auth/index.ts b/frontend-v2/src/framework/auth/index.ts index 68dfa53a..845a59ad 100644 --- a/frontend-v2/src/framework/auth/index.ts +++ b/frontend-v2/src/framework/auth/index.ts @@ -5,6 +5,7 @@ export { AuthProvider, useAuth, AuthContext } from './AuthContext'; export * from './types'; export * from './api'; +export { handleFetchUnauthorized, showSessionExpiredAndRedirect } from './sessionGuard'; diff --git a/frontend-v2/src/framework/auth/sessionGuard.ts b/frontend-v2/src/framework/auth/sessionGuard.ts new file mode 100644 index 00000000..b0e39e97 --- /dev/null +++ b/frontend-v2/src/framework/auth/sessionGuard.ts @@ -0,0 +1,115 @@ +/** + * Session Guard - 全局会话过期处理 + * + * 提供统一的 401 处理逻辑,供 fetch 和 axios 客户端共用: + * 1. 尝试用 Refresh Token 自动续期 + * 2. 续期失败时弹出友好提示并跳转登录页 + */ + +import { getRefreshToken, refreshAccessToken, clearTokens } from './api'; + +let isRefreshing = false; +let pendingQueue: { + resolve: (token: string) => void; + reject: (err: unknown) => void; +}[] = []; + +function processPendingQueue(error: unknown, token: string | null) { + pendingQueue.forEach(({ resolve, reject }) => { + if (token) resolve(token); + else reject(error); + }); + pendingQueue = []; +} + +/** + * 显示友好的会话过期提示并跳转登录页 + */ +function showSessionExpiredAndRedirect(reason?: string) { + clearTokens(); + + const message = reason === 'kicked' + ? '您的账号已在其他设备登录,当前会话已失效' + : '登录已过期,请重新登录'; + + const overlay = document.createElement('div'); + overlay.id = 'session-expired-overlay'; + overlay.innerHTML = ` +
+
+
🔒
+

${message}

+

点击下方按钮返回登录页面

+ +
+
+ `; + + if (!document.getElementById('session-expired-overlay')) { + document.body.appendChild(overlay); + document.getElementById('session-expired-btn')?.addEventListener('click', () => { + window.location.href = '/login'; + }); + } +} + +/** + * 处理 401 响应(fetch 客户端专用) + * + * @returns 新的 accessToken(可用于重试),或 null(已跳转登录) + */ +export async function handleFetchUnauthorized( + responseMessage?: string, +): Promise { + const isKicked = responseMessage?.includes('其他设备'); + if (isKicked) { + showSessionExpiredAndRedirect('kicked'); + return null; + } + + const hasRefresh = !!getRefreshToken(); + if (!hasRefresh) { + showSessionExpiredAndRedirect(); + return null; + } + + if (isRefreshing) { + return new Promise((resolve, reject) => { + pendingQueue.push({ resolve, reject }); + }).catch(() => { + showSessionExpiredAndRedirect(); + return null; + }); + } + + isRefreshing = true; + + try { + const tokens = await refreshAccessToken(); + const newToken = tokens.accessToken; + processPendingQueue(null, newToken); + return newToken; + } catch (refreshError) { + processPendingQueue(refreshError, null); + showSessionExpiredAndRedirect(); + return null; + } finally { + isRefreshing = false; + } +} + +/** + * 供 axios apiClient 调用的友好跳转(替代 alert + window.location.href) + */ +export { showSessionExpiredAndRedirect }; diff --git a/frontend-v2/src/modules/asl/api/index.ts b/frontend-v2/src/modules/asl/api/index.ts index 9869769e..608f692f 100644 --- a/frontend-v2/src/modules/asl/api/index.ts +++ b/frontend-v2/src/modules/asl/api/index.ts @@ -15,6 +15,7 @@ import type { ProjectStatistics } from '../types'; import { getAccessToken } from '../../../framework/auth/api'; +import { handleFetchUnauthorized } from '../../../framework/auth/sessionGuard'; // API基础URL const API_BASE_URL = '/api/v1/asl'; @@ -33,10 +34,11 @@ function getAuthHeaders(): HeadersInit { return headers; } -// 通用请求函数 +// 通用请求函数(含 401 自动刷新 + 友好提示) async function request( url: string, - options?: RequestInit + options?: RequestInit, + _retried = false, ): Promise { const response = await fetch(`${API_BASE_URL}${url}`, { ...options, @@ -46,14 +48,26 @@ async function request( }, }); + if (response.status === 401 && !_retried) { + let serverMsg = ''; + try { + const body = await response.clone().json(); + serverMsg = body?.message || ''; + } catch { /* ignore */ } + + const newToken = await handleFetchUnauthorized(serverMsg); + if (newToken) { + return request(url, options, true); + } + throw new Error('登录已过期,请重新登录'); + } + if (!response.ok) { - // 尝试解析错误响应 let errorMessage = `HTTP ${response.status}: ${response.statusText}`; try { const errorData = await response.json(); errorMessage = errorData.error || errorData.message || errorMessage; } catch (e) { - // 如果响应体不是JSON,使用状态文本 const text = await response.text().catch(() => ''); if (text) { errorMessage = text;