diff --git a/src/api/__tests__/client.spec.ts b/src/api/__tests__/client.spec.ts index 257b98d4..7a473e60 100644 --- a/src/api/__tests__/client.spec.ts +++ b/src/api/__tests__/client.spec.ts @@ -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({ diff --git a/src/api/__tests__/index.spec.ts b/src/api/__tests__/index.spec.ts index a201e058..47f55fd3 100644 --- a/src/api/__tests__/index.spec.ts +++ b/src/api/__tests__/index.spec.ts @@ -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() }) }) diff --git a/src/api/client.ts b/src/api/client.ts index 3b94313c..22176960 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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) } diff --git a/src/api/index.ts b/src/api/index.ts index beb0ca93..a322d37d 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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 = { '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), diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index a016e789..bbdf95db 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -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', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 9320c58e..f0f084c4 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -64,7 +64,6 @@ export default { requestTimeout: '请求超时', invalidApiResponse: '服务器返回了无效响应', apiRequestFailed: '请求失败', - sessionExpired: '登录状态已失效,请重新登录', troubleshooting: '疑难解答', checking: '检查中', retry: '重试', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 63723f9b..9cadbee5 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -64,7 +64,6 @@ export default { requestTimeout: '請求超時', invalidApiResponse: '服務器返回了無效響應', apiRequestFailed: '請求失敗', - sessionExpired: '登入狀態已失效,請重新登入', troubleshooting: '疑難排解', checking: '檢查中', retry: '重試',