mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-28 19:47:49 +08:00
fix(api): 修复无效 envelope 响应刷屏并加固 SW API 缓存
- 去重缓存仅在 envelope 校验通过后清空,避免坏响应命中时逐条弹窗 - SW API 缓存只写入 JSON 响应,排除 opaque 响应与连接探测心跳
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user