fix(auth): 静默处理会话失效错误

This commit is contained in:
jxxghp
2026-08-25 13:05:25 +08:00
parent 94eac98207
commit 618c56461a
7 changed files with 54 additions and 53 deletions
+15
View File
@@ -387,6 +387,21 @@ describe('MoviePilot API client', () => {
expect(notifier.error).not.toHaveBeenCalled()
})
it('403 被 onForbidden 接管时不逐条弹请求层 Toast', async () => {
const onForbidden = vi.fn(() => true)
const { api } = createApiClients({
adapter: rejectWith({ detail: 'token校验不通过' }, 403),
hooks: { onForbidden },
notifier,
})
const error = requireApiRequestError(await api.get('/resource').catch(reason => reason))
expect(error.status).toBe(403)
expect(onForbidden).toHaveBeenCalledWith(error)
expect(notifier.error).not.toHaveBeenCalled()
})
it('401 未被 onUnauthorized 接管时保留逐条错误提示', async () => {
const onUnauthorized = vi.fn(() => false)
const { api } = createApiClients({
+19 -19
View File
@@ -1,5 +1,5 @@
import { AxiosError, AxiosHeaders, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
i18nT: vi.fn((key: string) => `translated:${key}`),
@@ -70,10 +70,6 @@ describe('API application wiring', () => {
mocks.toastError.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('向 window 暴露插件最终 payload 客户端,而内部默认导出数据客户端', async () => {
const module = await import('@/api')
@@ -98,8 +94,7 @@ describe('API application wiring', () => {
expect(mocks.i18nT).toHaveBeenCalledWith('common.invalidApiResponse')
})
it('已登录时并发 401 只统一登出并提示一次本地化文案', async () => {
vi.useFakeTimers()
it('已登录时并发 401 只统一登出并静默返回登录页', async () => {
mocks.authState.token = 'expired-token'
const module = await installFailingAdapter(401, { detail: 'Not authenticated' })
@@ -107,25 +102,30 @@ describe('API application wiring', () => {
expect(mocks.logout).toHaveBeenCalledOnce()
expect(mocks.routerPush).toHaveBeenCalledWith('/login')
expect(mocks.toastError).toHaveBeenCalledOnce()
expect(mocks.toastError).toHaveBeenCalledWith('translated:common.sessionExpired')
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('登出后短暂窗口内的在途 401 持静默,窗口过后恢复逐条提示', async () => {
vi.useFakeTimers()
it('登出后的在途 401 持静默,不在登录页暴露认证错误', async () => {
mocks.authState.token = 'expired-token'
const module = await installFailingAdapter(401, { detail: 'Not authenticated' })
await module.default.get('/dashboard').catch(() => {})
expect(mocks.toastError).toHaveBeenCalledOnce()
// 窗口内的连带 401 不再弹出英文提示。
await module.default.get('/subscribe').catch(() => {})
expect(mocks.toastError).toHaveBeenCalledOnce()
vi.setSystemTime(Date.now() + 6000)
await module.default.get('/resource').catch(() => {})
expect(mocks.toastError).toHaveBeenCalledTimes(2)
expect(mocks.toastError).toHaveBeenLastCalledWith('Not authenticated')
expect(mocks.logout).toHaveBeenCalledOnce()
expect(mocks.routerPush).toHaveBeenCalledOnce()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('token 校验失败的 403 完成签退后不弹技术错误', async () => {
mocks.authState.token = 'invalid-token'
const module = await installFailingAdapter(403, { detail: 'token校验不通过' })
await Promise.allSettled([module.default.get('/dashboard'), module.default.get('/subscribe')])
expect(mocks.logout).toHaveBeenCalledOnce()
expect(mocks.routerPush).toHaveBeenCalledWith('/login')
expect(mocks.toastError).not.toHaveBeenCalled()
})
})
+7 -7
View File
@@ -80,8 +80,8 @@ export interface ApiFeedbackNotifier {
/** 请求生命周期钩子用于隔离认证和离线状态等应用级副作用。 */
export interface ApiClientHooks {
markServerOnline?(): void
onForbidden?(error: ApiRequestError): void
/** 返回 true 表示认证失效已由应用层统一接管,请求层不再弹出逐条错误提示。 */
/** 返回 true 表示认证失败已由应用层接管,请求层不再弹出逐条错误提示。 */
onForbidden?(error: ApiRequestError): boolean | void
onUnauthorized?(error: ApiRequestError): boolean | void
reportConnectionFailure?(reason: 'network-error' | 'timeout' | 'server-unreachable'): void
}
@@ -288,11 +288,11 @@ function installResponseInterceptors(
if (!requestConfig?.skipConnectionTracking && failureReason) {
hooks?.reportConnectionFailure?.(failureReason)
}
if (response?.status === 403) hooks?.onForbidden?.(error)
// 认证失效(如后端重启导致 token 作废)由应用层统一登出跳转,
// 避免并发请求逐条弹出 "Not authenticated" 等英文提示刷屏。
if (response?.status === 401 && hooks?.onUnauthorized?.(error) === true) {
// 认证失败由应用层统一签退或交给登录流程处理,避免在登录页暴露技术错误。
const authenticationHandled =
(response?.status === 401 && hooks?.onUnauthorized?.(error) === true) ||
(response?.status === 403 && hooks?.onForbidden?.(error) === true)
if (authenticationHandled) {
return Promise.reject(error)
}
+13 -24
View File
@@ -26,42 +26,31 @@ export interface ConnectionAwareRequestConfig extends AxiosRequestConfig {
const globalOfflineStatus = useGlobalOfflineStatus()
const toast = useToast()
// 会话失效后短暂窗口内的 401 都是同一次 token 作废的连带失败,统一静默避免刷屏。
const SESSION_EXPIRED_SUPPRESSION_MS = 5000
let sessionExpiredAt = 0
const fallbackMessageKeys: Record<ApiFallbackMessageKey, string> = {
'invalid-envelope': 'common.invalidApiResponse',
'network-error': 'common.networkConnectionFailed',
'request-failed': 'common.apiRequestFailed',
timeout: 'common.requestTimeout',
}
/** 认证失效只负责代码签退;原始异常继续交给发起请求的业务界面处理。 */
function handleAuthenticationFailure(): true {
const authStore = useAuthStore()
if (authStore.token) {
authStore.logout()
void router.push('/login')
}
return true
}
const { api, pluginApi } = createApiClients({
baseURL: import.meta.env.VITE_API_BASE_URL,
setupInstance: initializeClient,
hooks: {
markServerOnline: globalOfflineStatus.markServerOnline,
reportConnectionFailure: globalOfflineStatus.reportNetworkError,
onForbidden: () => {
const authStore = useAuthStore()
// 未登录的 403 可能是登录或 MFA 流程的一部分,不应触发全局登出跳转。
if (!authStore.token) return
authStore.logout()
void router.push('/login')
},
onUnauthorized: () => {
const authStore = useAuthStore()
if (!authStore.token) {
// 无 token 的 401(如登录页验证失败)交给调用方自行展示;
// 但刚登出后的在途请求属于同一会话失效,继续静默。
return Date.now() - sessionExpiredAt < SESSION_EXPIRED_SUPPRESSION_MS
}
// 后端重启会使旧 token 作废:只提示一次并统一登出,不逐条弹英文错误。
sessionExpiredAt = Date.now()
authStore.logout()
toast.error(i18n.global.t('common.sessionExpired'))
void router.push('/login')
return true
},
onForbidden: handleAuthenticationFailure,
onUnauthorized: handleAuthenticationFailure,
},
notifier: {
error: message => toast.error(message),
-1
View File
@@ -66,7 +66,6 @@ export default {
requestTimeout: 'Request timed out',
invalidApiResponse: 'The server returned an invalid response',
apiRequestFailed: 'Request failed',
sessionExpired: 'Session expired, please sign in again',
troubleshooting: 'Troubleshooting',
checking: 'Checking',
retry: 'Retry',
-1
View File
@@ -64,7 +64,6 @@ export default {
requestTimeout: '请求超时',
invalidApiResponse: '服务器返回了无效响应',
apiRequestFailed: '请求失败',
sessionExpired: '登录状态已失效,请重新登录',
troubleshooting: '疑难解答',
checking: '检查中',
retry: '重试',
-1
View File
@@ -64,7 +64,6 @@ export default {
requestTimeout: '請求超時',
invalidApiResponse: '服務器返回了無效響應',
apiRequestFailed: '請求失敗',
sessionExpired: '登入狀態已失效,請重新登入',
troubleshooting: '疑難排解',
checking: '檢查中',
retry: '重試',