From 03c1b893b8c0f8a3984eb1ba5d7f4ba31aaae340 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 17 Aug 2026 11:11:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E6=8A=80=E6=9C=AF=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=8F=90=E7=A4=BA=E5=A2=9E=E5=8A=A015=E7=A7=92?= =?UTF-8?q?=E5=8E=BB=E9=87=8D=E7=BC=93=E5=AD=98=EF=BC=8C=E9=81=BF=E5=85=8D?= =?UTF-8?q?=E5=B9=B6=E5=8F=91=E5=A4=B1=E8=B4=A5=E5=88=B7=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/__tests__/client.spec.ts | 87 ++++++++++++++++++++++++++++++++ src/api/client.ts | 53 ++++++++++++++++--- 2 files changed, 134 insertions(+), 6 deletions(-) diff --git a/src/api/__tests__/client.spec.ts b/src/api/__tests__/client.spec.ts index 02bc6ec1..6d858379 100644 --- a/src/api/__tests__/client.spec.ts +++ b/src/api/__tests__/client.spec.ts @@ -180,6 +180,93 @@ describe('MoviePilot API client', () => { expect(error.response?.data).toEqual(envelope) }) + it('相同技术错误在去重窗口内只提示一次,避免并发失败刷屏', async () => { + const { api } = createApiClients({ + adapter: rejectWith({ message: 'Server exploded' }, 500), + notifier, + }) + + await api.get('/a').catch(reason => reason) + await api.get('/b').catch(reason => reason) + + expect(notifier.error).toHaveBeenCalledTimes(1) + expect(notifier.error).toHaveBeenCalledWith('Server exploded') + }) + + it('去重窗口过期后相同技术错误可以再次提示', async () => { + vi.useFakeTimers() + try { + const { api } = createApiClients({ + adapter: rejectWith({ message: 'Server exploded' }, 500), + notifier, + }) + + await api.get('/a').catch(reason => reason) + expect(notifier.error).toHaveBeenCalledTimes(1) + + // 窗口(15 秒)过后,新一轮相同错误应能再次提示。 + vi.advanceTimersByTime(15000) + await api.get('/b').catch(reason => reason) + expect(notifier.error).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('成功响应后清空去重缓存,让后续技术错误可以再次提示', async () => { + vi.useFakeTimers() + try { + const { api } = createApiClients({ + adapter: rejectWith({ message: 'Server exploded' }, 500), + notifier, + }) + + await api.get('/a').catch(reason => reason) + expect(notifier.error).toHaveBeenCalledTimes(1) + + // 服务恢复后缓存应被清空,即使仍在窗口内,相同错误也可再次提示。 + api.defaults.adapter = resolveWith>({ + success: true, + message: '', + data: { ok: true }, + }) + await api.get('/ping') + api.defaults.adapter = rejectWith({ message: 'Server exploded' }, 500) + await api.get('/c').catch(reason => reason) + + expect(notifier.error).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('不同消息的技术错误互不影响,分别提示', async () => { + const adapter: AxiosAdapter = async config => { + const message = config.url === '/a' ? 'First failure' : 'Second failure' + const response = createResponse(config, { message }, 500) + throw new AxiosError('Request failed', AxiosError.ERR_BAD_RESPONSE, config, undefined, response) + } + const { api } = createApiClients({ adapter, notifier }) + + await api.get('/a').catch(reason => reason) + await api.get('/b').catch(reason => reason) + + expect(notifier.error).toHaveBeenCalledTimes(2) + expect(notifier.error).toHaveBeenCalledWith('First failure') + expect(notifier.error).toHaveBeenCalledWith('Second failure') + }) + + it('业务失败不参与技术错误去重,始终逐条提示', async () => { + const envelope: ApiResponse = { success: false, message: 'Cannot save', data: null } + const { api } = createApiClients({ adapter: resolveWith(envelope), notifier }) + + await api.post('/setting', {}).catch(reason => reason) + await api.post('/setting', {}).catch(reason => reason) + + expect(notifier.error).toHaveBeenCalledTimes(2) + expect(notifier.error).toHaveBeenCalledWith('Cannot save') + }) + it('取消请求保持原始 CanceledError,且不提示或触发离线探测', async () => { const reportConnectionFailure = vi.fn() const adapter: AxiosAdapter = async () => { diff --git a/src/api/client.ts b/src/api/client.ts index 22e55050..37b086b9 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -98,6 +98,9 @@ const defaultFallbackMessages: Record = { timeout: 'Request timeout', } +/** 技术类失败提示的去重窗口:后端异常时并发请求会得到相同错误,窗口内只提示一次避免刷屏。 */ +const TECHNICAL_ERROR_DEDUP_MS = 15000 + interface ApiRequestErrorOptions { businessFailure?: boolean cause?: unknown @@ -187,11 +190,15 @@ export function createApiClients(options: CreateApiClientsOptions = {}): { const api = axios.create(axiosConfig) const pluginApi = axios.create(axiosConfig) + // 技术类失败提示的去重缓存:同一消息在窗口内只提示一次,避免后端异常时大量并发请求刷屏。 + // 两个客户端共用同一份缓存,保证重复提示被整体收敛。 + const technicalErrorDedup = new Map() + // 优化器等底层拦截器必须先安装,确保它们在业务数据解包前仍能看到完整 AxiosResponse。 setupInstance?.(api) setupInstance?.(pluginApi) - installResponseInterceptors(api, 'data', hooks, notifier, resolveFallbackMessage) - installResponseInterceptors(pluginApi, 'envelope', hooks, notifier, resolveFallbackMessage) + installResponseInterceptors(api, 'data', hooks, notifier, resolveFallbackMessage, technicalErrorDedup) + installResponseInterceptors(pluginApi, 'envelope', hooks, notifier, resolveFallbackMessage, technicalErrorDedup) return { api: api as DataApiClient, @@ -206,11 +213,15 @@ function installResponseInterceptors( hooks?: ApiClientHooks, notifier?: ApiFeedbackNotifier, resolveFallbackMessage?: ApiFallbackMessageResolver, + technicalErrorDedup?: Map, ) { instance.interceptors.response.use( response => { hooks?.markServerOnline?.() + // 服务恢复在线后清空技术错误去重缓存,让新一轮故障可以再次提示,避免永久静默。 + technicalErrorDedup?.clear() + if (isBinarySuccess(response)) return response.data const payload: unknown = response.data @@ -223,7 +234,7 @@ function installResponseInterceptors( request: response.request, response, }) - notifyFailure(response.config.feedback, notifier, error.message) + notifyFailure(response.config.feedback, notifier, error.message, technicalErrorDedup) return Promise.reject(error) } @@ -280,7 +291,7 @@ function installResponseInterceptors( // 连接类失败(无响应、超时、网关不可用)统一交给离线状态系统按阈值提示, // 不在请求层逐个弹出,避免后端重启时刷屏。 - if (!failureReason) notifyFailure(requestConfig?.feedback, notifier, error.message) + if (!failureReason) notifyFailure(requestConfig?.feedback, notifier, error.message, technicalErrorDedup) return Promise.reject(error) }, ) @@ -359,8 +370,38 @@ function resolveFallback(key: ApiFallbackMessageKey, resolver?: ApiFallbackMessa } /** 默认模式只提示失败,silent 模式完全关闭请求层反馈。 */ -function notifyFailure(mode: ApiFeedbackMode | undefined, notifier: ApiFeedbackNotifier | undefined, message: string) { - if (mode !== 'silent' && message) notifier?.error(message) +function notifyFailure( + mode: ApiFeedbackMode | undefined, + notifier: ApiFeedbackNotifier | undefined, + message: string, + technicalErrorDedup?: Map, +) { + if (mode !== 'silent' && message && !isTechnicalErrorCached(message, technicalErrorDedup)) { + notifier?.error(message) + } +} + +/** + * 技术错误去重:相同消息在窗口内只提示一次。 + * + * 后端异常或重启时大量并发请求会携带同一技术错误(协议错误、HTTP 5xx 等), + * 逐条弹出会占满屏幕;此处以消息为键缓存最近提示时间,窗口内重复消息不再提示。 + */ +function isTechnicalErrorCached(message: string, dedup?: Map): boolean { + if (!dedup) return false + const now = Date.now() + const lastShownAt = dedup.get(message) + dedup.set(message, now) + if (lastShownAt !== undefined && now - lastShownAt < TECHNICAL_ERROR_DEDUP_MS) { + return true + } + // 顺带清理过期缓存项,避免长时间运行后缓存无限增长。 + if (dedup.size > 64) { + for (const [key, at] of dedup) { + if (now - at >= TECHNICAL_ERROR_DEDUP_MS) dedup.delete(key) + } + } + return false } /** all 模式用于显式要求请求层展示后端成功消息。 */