From c98542b9bb8680e96b8425af8e7db4ca1688872d Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 18 Aug 2026 18:50:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E4=BF=AE=E5=A4=8D=E6=97=A0?= =?UTF-8?q?=E6=95=88=20envelope=20=E5=93=8D=E5=BA=94=E5=88=B7=E5=B1=8F?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=E5=9B=BA=20SW=20API=20=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 去重缓存仅在 envelope 校验通过后清空,避免坏响应命中时逐条弹窗 - SW API 缓存只写入 JSON 响应,排除 opaque 响应与连接探测心跳 --- src/api/__tests__/client.spec.ts | 34 +++++++++++ src/api/client.ts | 9 ++- src/service-worker.ts | 8 ++- .../__tests__/serviceWorkerCache.spec.ts | 57 ++++++++++++++++++- src/utils/serviceWorkerCache.ts | 19 +++++++ 5 files changed, 121 insertions(+), 6 deletions(-) diff --git a/src/api/__tests__/client.spec.ts b/src/api/__tests__/client.spec.ts index 72569751..257b98d4 100644 --- a/src/api/__tests__/client.spec.ts +++ b/src/api/__tests__/client.spec.ts @@ -253,6 +253,40 @@ describe('MoviePilot API client', () => { } }) + it('连续无效 envelope 响应只提示一次,避免坏缓存命中时刷屏', async () => { + // 无效 envelope 是 HTTP 200 但响应体不是标准三键结构,模拟 SW 缓存回退出的坏响应。 + const { api } = createApiClients({ + adapter: resolveWith('legacy app shell'), + notifier, + }) + + await api.get('/a').catch(reason => reason) + await api.get('/b').catch(reason => reason) + await api.get('/c').catch(reason => reason) + + expect(notifier.error).toHaveBeenCalledTimes(1) + expect(notifier.error).toHaveBeenCalledWith('Invalid API response envelope') + }) + + it('无效 envelope 响应不清空去重缓存,窗口内其他技术错误保持收敛', async () => { + const adapter: AxiosAdapter = async config => { + if (config.url === '/bad') return createResponse(config, '', 200) + const response = createResponse(config, { message: 'Server exploded' }, 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('/bad').catch(reason => reason) + await api.get('/b').catch(reason => reason) + + // 500 技术错误在窗口内只提示一次;无效 envelope 属于不同消息单独提示一次, + // 且它不得清空去重缓存,否则第 3 个相同 500 错误会被再次弹出。 + expect(notifier.error).toHaveBeenCalledTimes(2) + expect(notifier.error).toHaveBeenCalledWith('Server exploded') + expect(notifier.error).toHaveBeenCalledWith('Invalid API response envelope') + }) + it('不同消息的技术错误互不影响,分别提示', async () => { const adapter: AxiosAdapter = async config => { const message = config.url === '/a' ? 'First failure' : 'Second failure' diff --git a/src/api/client.ts b/src/api/client.ts index 1e34c636..3b94313c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -222,9 +222,6 @@ function installResponseInterceptors( response => { hooks?.markServerOnline?.() - // 服务恢复在线后清空技术错误去重缓存,让新一轮故障可以再次提示,避免永久静默。 - technicalErrorDedup?.clear() - if (isBinarySuccess(response)) return response.data const payload: unknown = response.data @@ -238,10 +235,16 @@ function installResponseInterceptors( request: response.request, response, }) + // 无效 envelope 本身就是技术错误,必须留在去重窗口内收敛; + // 此处绝不能清空去重缓存,否则每个坏响应都会重置窗口并逐条弹 Toast 刷屏。 notifyFailure(response.config.feedback, notifier, error.message, technicalErrorDedup) return Promise.reject(error) } + // 只有通过 envelope 校验的真实响应才算“服务恢复在线”的证据, + // 此时清空技术错误去重缓存,让新一轮故障可以再次提示,避免永久静默。 + technicalErrorDedup?.clear() + if (!payload.success) { notifyFailure(response.config.feedback, notifier, payload.message) if (responseMode === 'envelope') return payload diff --git a/src/service-worker.ts b/src/service-worker.ts index 42d9ce2c..737993e0 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -4,7 +4,7 @@ import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategi import { ExpirationPlugin } from 'workbox-expiration' import { CacheableResponsePlugin } from 'workbox-cacheable-response' import * as navigationPreload from 'workbox-navigation-preload' -import { corsSafeCachePlugin } from '@/utils/serviceWorkerCache' +import { corsSafeCachePlugin, jsonOnlyCachePlugin } from '@/utils/serviceWorkerCache' // Service Worker 类型声明 declare let self: ServiceWorkerGlobalScope & { @@ -155,6 +155,7 @@ registerRoute( !url.pathname.includes('/api/v1/system/message') && // SSE实时消息流 !url.pathname.includes('/api/v1/system/progress/') && // SSE实时进度流 !url.pathname.includes('/api/v1/system/logging') && // SSE实时日志流 + !url.pathname.includes('/api/v1/system/ping') && // 连接探测心跳,缓存回退会掩盖真实离线 !url.pathname.includes('/api/v1/message/') && // 用户消息接口 !url.pathname.includes('/api/v1/system/global') && // 系统配置接口 !url.pathname.includes('/api/v1/mfa/') && // 多因素认证接口 @@ -166,8 +167,11 @@ registerRoute( cacheName: `api-cache-${CACHE_VERSION}`, networkTimeoutSeconds: 5, plugins: [ + // 只缓存 JSON 响应,并排除 opaque(状态 0):坏体或 HTML 落入 API 缓存后, + // NetworkFirst 超时回退会返回不可解析的 200,导致前端反复弹“无效响应”。 + jsonOnlyCachePlugin, new CacheableResponsePlugin({ - statuses: [0, 200], + statuses: [200], }), new ExpirationPlugin({ maxEntries: 500, diff --git a/src/utils/__tests__/serviceWorkerCache.spec.ts b/src/utils/__tests__/serviceWorkerCache.spec.ts index 07f755ea..ec2451e3 100644 --- a/src/utils/__tests__/serviceWorkerCache.spec.ts +++ b/src/utils/__tests__/serviceWorkerCache.spec.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { corsSafeCachePlugin, selectCorsSafeCachedResponse } from '../serviceWorkerCache' +import { + corsSafeCachePlugin, + jsonOnlyCachePlugin, + selectCorsSafeCachedResponse, + shouldCacheJsonResponse, +} from '../serviceWorkerCache' function createResponse(type: ResponseType) { const response = new Response('image') @@ -51,3 +56,53 @@ describe('Service Worker cache CORS boundary', () => { ).resolves.toBeUndefined() }) }) + +describe('Service Worker API cache JSON boundary', () => { + function createJsonResponse(contentType: string) { + return new Response('{"success":true,"message":"","data":null}', { + headers: { 'content-type': contentType }, + }) + } + + it('accepts a standard JSON API response', () => { + expect(shouldCacheJsonResponse(createJsonResponse('application/json; charset=utf-8'))).toBe(true) + }) + + it('rejects an HTML response that would break the envelope contract', () => { + const html = new Response('offline shell', { + headers: { 'content-type': 'text/html; charset=utf-8' }, + }) + + expect(shouldCacheJsonResponse(html)).toBe(false) + }) + + it('rejects a binary response such as an image or blob', () => { + const image = new Response(new Blob(['\x89PNG']), { headers: { 'content-type': 'image/png' } }) + + expect(shouldCacheJsonResponse(image)).toBe(false) + }) + + it('exposes the boundary through the Workbox cache update lifecycle', async () => { + const response = createJsonResponse('application/json') + + await expect( + jsonOnlyCachePlugin.cacheWillUpdate?.({ + event: new Event('fetch') as ExtendableEvent, + request: new Request('https://moviepilot/api/v1/resource'), + response, + }), + ).resolves.toBe(response) + }) + + it('skips caching a non-JSON response through the Workbox lifecycle', async () => { + const response = new Response('', { headers: { 'content-type': 'text/html' } }) + + await expect( + jsonOnlyCachePlugin.cacheWillUpdate?.({ + event: new Event('fetch') as ExtendableEvent, + request: new Request('https://moviepilot/api/v1/resource'), + response, + }), + ).resolves.toBeNull() + }) +}) diff --git a/src/utils/serviceWorkerCache.ts b/src/utils/serviceWorkerCache.ts index 54667f9d..afc0c591 100644 --- a/src/utils/serviceWorkerCache.ts +++ b/src/utils/serviceWorkerCache.ts @@ -19,3 +19,22 @@ export const corsSafeCachePlugin: WorkboxPlugin = { return selectCorsSafeCachedResponse(request, cachedResponse) }, } + +/** + * 判定响应是否允许写入 API 运行时缓存。 + * + * API 缓存的消费方要求 JSON envelope;SPA 回退页等 HTML 或损坏体一旦被写入, + * NetworkFirst 超时回退时会以 200 返回坏体,前端 envelope 校验失败后逐条弹 + * “服务器返回了无效响应”(仅清浏览器缓存可解)。写入前校验 Content-Type + * 可以从源头阻断非 JSON 响应进入缓存。 + */ +export function shouldCacheJsonResponse(response: Response): boolean { + return (response.headers.get('content-type') ?? '').includes('json') +} + +/** API 缓存专用:只写入声明为 JSON 的响应,HTML、图片等一律不落缓存。 */ +export const jsonOnlyCachePlugin: WorkboxPlugin = { + async cacheWillUpdate({ response }) { + return shouldCacheJsonResponse(response) ? response : null + }, +}