fix(api): 修复无效 envelope 响应刷屏并加固 SW API 缓存

- 去重缓存仅在 envelope 校验通过后清空,避免坏响应命中时逐条弹窗
- SW API 缓存只写入 JSON 响应,排除 opaque 响应与连接探测心跳
This commit is contained in:
jxxghp
2026-08-18 18:50:49 +08:00
parent 89da6be8bb
commit c98542b9bb
5 changed files with 121 additions and 6 deletions
+34
View File
@@ -253,6 +253,40 @@ describe('MoviePilot API client', () => {
}
})
it('连续无效 envelope 响应只提示一次,避免坏缓存命中时刷屏', async () => {
// 无效 envelope 是 HTTP 200 但响应体不是标准三键结构,模拟 SW 缓存回退出的坏响应。
const { api } = createApiClients({
adapter: resolveWith('<html>legacy app shell</html>'),
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, '<html></html>', 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'
+6 -3
View File
@@ -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
+6 -2
View File
@@ -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,
+56 -1
View File
@@ -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('<html><body>offline shell</body></html>', {
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('<html></html>', { 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()
})
})
+19
View File
@@ -19,3 +19,22 @@ export const corsSafeCachePlugin: WorkboxPlugin = {
return selectCorsSafeCachedResponse(request, cachedResponse)
},
}
/**
* 判定响应是否允许写入 API 运行时缓存。
*
* API 缓存的消费方要求 JSON envelopeSPA 回退页等 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
},
}