From 286f57f63a7f61721857cad50a9e7283678d14d8 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sun, 7 Jun 2026 01:09:50 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20BangDream=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ot-bangdream-tsugu-global-refactor-plan.md | 2 + .../tsugu/data-clients/api-cache-client.ts | 34 +++---- .../tsugu/data-clients/asset-cache-client.ts | 50 +++++----- .../tsugu/data-clients/cache-client-policy.ts | 96 +++++++++++++++++++ .../tsugu/data-clients/file-cache-client.ts | 22 ++--- .../tsugu/cache-client-policy.spec.ts | 61 ++++++++++++ .../bangDream/tsugu/file-cache-client.spec.ts | 23 +++++ 7 files changed, 231 insertions(+), 57 deletions(-) create mode 100644 src/qqbot/plugins/bangDream/tsugu/data-clients/cache-client-policy.ts create mode 100644 test/qqbot/plugins/bangDream/tsugu/cache-client-policy.spec.ts diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 7e7bd02..b4bc8df 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -285,6 +285,7 @@ export interface TsuguHook { - 已新增 `data-clients/data-provider.ts`,定义 `BangDreamDataProvider`、JSON/Asset/Tracker 请求参数和 provider URL 解析。 - 已新增 `data-clients/provider-decorators.ts`,提供 `withCache`、`withRetry`、`withTiming`,缓存默认值、重试和耗时日志不再要求业务函数内手写循环。 +- 已新增 `data-clients/cache-client-policy.ts`,收口旧 cache client 的重试次数规范化、3 秒等待、HTTP 状态读取、404 不重试和缺失 URL 缓存过期时间;`api-cache-client.ts`、`asset-cache-client.ts`、`file-cache-client.ts` 开始消费统一策略,避免 JSON/API 与素材下载继续各自手写 retry/fallback。 - 已新增 `data-clients/bestdori-provider.ts`、`data-clients/hhwx-tracker-provider.ts`、`data-clients/static-patch-provider.ts`,主数据、素材、Tracker 和本地静态修正分别走 provider。 - `models/main-data-store.ts` 已改为通过 Bestdori provider 加载主数据,通过 static patch provider 读取 `cards-cn-fix.json`、`skills-cn-fix.json`、`area-item-fix.json` 和 `nickname-song.xlsx`。 - 已新增 `models/main-data-repository.ts`、`song-repository.ts`、`card-repository.ts`、`event-repository.ts`、`gacha-repository.ts`、`player-repository.ts`。 @@ -295,6 +296,7 @@ export interface TsuguHook { - 已接入 `BANGDREAM_TSUGU_REQUEST_TIMEOUT_MS` 和 `BANGDREAM_TSUGU_MAIN_DATA_READY_TIMEOUT_MS`,HTTP 请求和主数据首次 ready 等待都有硬超时,BangDream 命令执行前会等待关键主数据集合可用。 - 已新增 `scripts/bangdream-render-smoke.ps1`,BangDream 图片 smoke 通过父进程限时、子进程 PID 清理、生成后显式退出,避免本地调试命令卡住进程。 - 已新增 `test/qqbot/plugins/bangDream/tsugu/data-provider.spec.ts`,覆盖 provider URL 解析、Bestdori/HHWX mock 数据源、retry/cache wrapper。 +- 已新增 `cache-client-policy.spec.ts`,覆盖缓存客户端 retry/status/not-found 策略;`file-cache-client.spec.ts` 补 SVG 缺失资源 404 缓存,避免 SVG 素材缺失仍反复请求。 - 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。 ### Phase 4:搜索 specification 和 matcher diff --git a/src/qqbot/plugins/bangDream/tsugu/data-clients/api-cache-client.ts b/src/qqbot/plugins/bangDream/tsugu/data-clients/api-cache-client.ts index 6958628..86e3392 100644 --- a/src/qqbot/plugins/bangDream/tsugu/data-clients/api-cache-client.ts +++ b/src/qqbot/plugins/bangDream/tsugu/data-clients/api-cache-client.ts @@ -4,6 +4,11 @@ import { getFileNameFromUrl, } from '@/qqbot/plugins/bangDream/tsugu/data-clients/cache-path'; import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger'; +import { + getCacheClientErrorMessage, + isCacheClientNotFound, + runWithCacheClientRetry, +} from '@/qqbot/plugins/bangDream/tsugu/data-clients/cache-client-policy'; /** * 在数据下载与缓存层中调用APIAnd缓存Response。 @@ -26,33 +31,24 @@ async function callAPIAndCacheResponse( } const cacheDir = getCacheDirectory(url); const fileName = getFileNameFromUrl(url); - for (let attempt = 0; attempt < retryCount; attempt++) { - try { - const data = await getJsonAndSave(url, cacheDir, fileName, cacheTime); - return data; - } catch (e) { - if (e && e.response && e.response.status === 404) { - // 当URL返回404错误后,不再重试,直接抛出错误。 + return await runWithCacheClientRetry({ + action: () => getJsonAndSave(url, cacheDir, fileName, cacheTime), + onFailure: (attempt, _retryCount, error) => { + if (isCacheClientNotFound(error)) { logger( `API`, `URL "${url}" returned 404 Not Found. No more retries will be made.`, ); - throw e; + return; } logger( `API`, - `Failed to get JSON from "${url}" on attempt ${attempt + 1}. Error: ${e.message}`, + `Failed to get JSON from "${url}" on attempt ${attempt}. Error: ${getCacheClientErrorMessage(error)}`, ); - if (attempt === retryCount - 1) { - throw e; // Rethrow the error if all retries fail - } - //等待3秒后重试 - await new Promise((resolve) => setTimeout(resolve, 3000)); - } - } - throw new Error( - `Failed to get JSON from "${url}" after ${retryCount} attempts`, - ); + }, + retryCount, + shouldRetry: (error) => !isCacheClientNotFound(error), + }); } export { callAPIAndCacheResponse }; diff --git a/src/qqbot/plugins/bangDream/tsugu/data-clients/asset-cache-client.ts b/src/qqbot/plugins/bangDream/tsugu/data-clients/asset-cache-client.ts index b524dcf..080b925 100644 --- a/src/qqbot/plugins/bangDream/tsugu/data-clients/asset-cache-client.ts +++ b/src/qqbot/plugins/bangDream/tsugu/data-clients/asset-cache-client.ts @@ -8,10 +8,14 @@ import { Buffer } from 'buffer'; import { assetErrorImageBuffer } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger'; import * as fs from 'fs'; +import { + BANGDREAM_MISSING_URL_CACHE_EXPIRY_MS, + getCacheClientErrorMessage, + getCacheClientResponseStatus, + runWithCacheClientRetry, +} from '@/qqbot/plugins/bangDream/tsugu/data-clients/cache-client-policy'; -// 错误 URL 列表和错误缓存过期时间 const errUrl: { [key: string]: number } = {}; -const ERROR_CACHE_EXPIRY = 12 * 60 * 60 * 1000; // 1 天 const memoryCache: { [url: string]: Buffer } = {}; /** @@ -35,7 +39,10 @@ async function downloadFile( throw new Error("downloadFile: url.includes('undefined')"); } - if (errUrl[url] && currentTime - errUrl[url] < ERROR_CACHE_EXPIRY) { + if ( + errUrl[url] && + currentTime - errUrl[url] < BANGDREAM_MISSING_URL_CACHE_EXPIRY_MS + ) { throw new Error('downloadFile: errUrl includes url and not expired'); } @@ -43,15 +50,10 @@ async function downloadFile( const cacheDir = getCacheDirectory(url); const fileName = getFileNameFromUrl(url); - for (let attempt = 0; attempt < retryCount; attempt++) { - let assetNotExists = false; - if (attempt > 0) { - logger( - `downloader`, - `Retrying download for "${url}" (attempt ${attempt + 1}/${retryCount})`, - ); - } - try { + let assetNotExists = false; + return await runWithCacheClientRetry({ + action: async () => { + assetNotExists = false; const data = await download(url, cacheDir, fileName, cacheTime); const htmlSig = Buffer.from(''); const slice = Buffer.from(data.subarray(0, htmlSig.length)); @@ -63,24 +65,22 @@ async function downloadFile( ); } return data; - } catch (e) { - if (attempt === retryCount - 1) { - throw e; - } - if (assetNotExists) { - throw e; - } - //等待3秒后重试 - await new Promise((resolve) => setTimeout(resolve, 3000)); - } - } + }, + onRetry: (nextAttempt, normalizedRetryCount) => + logger( + `downloader`, + `Retrying download for "${url}" (attempt ${nextAttempt}/${normalizedRetryCount})`, + ), + retryCount, + shouldRetry: () => !assetNotExists, + }); } catch (e) { logger( `downloader`, - `Failed to download file from "${url}". Error: ${e.message}`, + `Failed to download file from "${url}". Error: ${getCacheClientErrorMessage(e)}`, ); - if (e.message.includes('404')) { + if (getCacheClientResponseStatus(e) === 404) { errUrl[url] = Date.now(); } diff --git a/src/qqbot/plugins/bangDream/tsugu/data-clients/cache-client-policy.ts b/src/qqbot/plugins/bangDream/tsugu/data-clients/cache-client-policy.ts new file mode 100644 index 0000000..f06e277 --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/data-clients/cache-client-policy.ts @@ -0,0 +1,96 @@ +export const BANGDREAM_CACHE_RETRY_DELAY_MS = 3000; +export const BANGDREAM_MISSING_URL_CACHE_EXPIRY_MS = 12 * 60 * 60 * 1000; + +export interface CacheClientRetryOptions { + action: (attempt: number) => Promise; + delayMs?: number; + onFailure?: (attempt: number, retryCount: number, error: unknown) => void; + onRetry?: (nextAttempt: number, retryCount: number, error: unknown) => void; + retryCount?: number; + shouldRetry?: (error: unknown, attempt: number) => boolean; +} + +/** + * 获取缓存客户端错误文本。 + * + * @param error - 待解析错误。 + */ +export function getCacheClientErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +/** + * 获取缓存客户端 HTTP 错误状态码。 + * + * @param error - 待解析错误。 + */ +export function getCacheClientResponseStatus( + error: unknown, +): number | undefined { + if (typeof error !== 'object' || error == null || !('response' in error)) { + return undefined; + } + const response = (error as { response?: { status?: number } }).response; + return response?.status; +} + +/** + * 判断缓存客户端错误是否为 HTTP 404。 + * + * @param error - 待判断错误。 + */ +export function isCacheClientNotFound(error: unknown): boolean { + return getCacheClientResponseStatus(error) === 404; +} + +/** + * 规范化缓存客户端重试次数。 + * + * @param retryCount - 原始重试次数。 + */ +export function normalizeCacheClientRetryCount(retryCount = 1): number { + return Math.max(1, retryCount); +} + +/** + * 等待缓存客户端下一次重试。 + * + * @param delayMs - 等待毫秒数。 + */ +export async function waitCacheClientRetryDelay( + delayMs: number, +): Promise { + if (delayMs <= 0) return; + await new Promise((resolve) => setTimeout(resolve, delayMs)); +} + +/** + * 按缓存客户端策略执行可重试任务。 + * + * @param options - 重试策略。 + */ +export async function runWithCacheClientRetry( + options: CacheClientRetryOptions, +): Promise { + const retryCount = normalizeCacheClientRetryCount(options.retryCount); + const delayMs = options.delayMs ?? BANGDREAM_CACHE_RETRY_DELAY_MS; + let lastError: unknown; + + for (let attempt = 1; attempt <= retryCount; attempt += 1) { + try { + return await options.action(attempt); + } catch (error) { + lastError = error; + options.onFailure?.(attempt, retryCount, error); + const canRetry = options.shouldRetry?.(error, attempt) ?? true; + if (attempt >= retryCount || !canRetry) { + throw error; + } + options.onRetry?.(attempt + 1, retryCount, error); + await waitCacheClientRetryDelay(delayMs); + } + } + + throw lastError; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts b/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts index d98ce0d..716c6e2 100644 --- a/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts +++ b/src/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client.ts @@ -5,9 +5,13 @@ import { BANGDREAM_TSUGU_ENV_KEYS, normalizeBangDreamPositiveInteger, } from '@/qqbot/plugins/bangDream/tsugu/runtime/runtime-options'; +import { + BANGDREAM_MISSING_URL_CACHE_EXPIRY_MS, + getCacheClientErrorMessage, + getCacheClientResponseStatus, +} from '@/qqbot/plugins/bangDream/tsugu/data-clients/cache-client-policy'; const errorUrlCache: { [url: string]: number } = {}; -const ERROR_URL_CACHE_EXPIRY_MS = 12 * 60 * 60 * 1000; const DEFAULT_REQUEST_TIMEOUT_MS = 8000; function getRequestTimeoutMs(): number { @@ -84,14 +88,14 @@ export async function download( } return fileBuffer; } catch (e) { - if (getErrorResponseStatus(e) === 404) { + if (getCacheClientResponseStatus(e) === 404) { errorUrlCache[url] = Date.now(); } - if (url.includes('.png')) { + if (url.includes('.png') || url.includes('.svg')) { throw e; } else { throw new Error( - `Failed to download file from "${url}". Error: ${e.message}`, + `Failed to download file from "${url}". Error: ${getCacheClientErrorMessage(e)}`, ); } } @@ -102,21 +106,13 @@ function isErrorUrlCacheActive(url: string): boolean { if (cachedAt == null) { return false; } - if (Date.now() - cachedAt >= ERROR_URL_CACHE_EXPIRY_MS) { + if (Date.now() - cachedAt >= BANGDREAM_MISSING_URL_CACHE_EXPIRY_MS) { delete errorUrlCache[url]; return false; } return true; } -function getErrorResponseStatus(error: unknown): number | undefined { - if (typeof error !== 'object' || error == null || !('response' in error)) { - return undefined; - } - const response = (error as { response?: { status?: number } }).response; - return response?.status; -} - /** * 在数据下载与缓存层中确保DirectoryExists。 * diff --git a/test/qqbot/plugins/bangDream/tsugu/cache-client-policy.spec.ts b/test/qqbot/plugins/bangDream/tsugu/cache-client-policy.spec.ts new file mode 100644 index 0000000..dd17575 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/cache-client-policy.spec.ts @@ -0,0 +1,61 @@ +import { + getCacheClientErrorMessage, + getCacheClientResponseStatus, + isCacheClientNotFound, + normalizeCacheClientRetryCount, + runWithCacheClientRetry, +} from '@/qqbot/plugins/bangDream/tsugu/data-clients/cache-client-policy'; + +describe('BangDream cache client policy', () => { + it('normalizes retry count and reads http status safely', () => { + const notFoundError = Object.assign(new Error('not found'), { + response: { status: 404 }, + }); + + expect(normalizeCacheClientRetryCount(0)).toBe(1); + expect(normalizeCacheClientRetryCount(3)).toBe(3); + expect(getCacheClientResponseStatus(notFoundError)).toBe(404); + expect(isCacheClientNotFound(notFoundError)).toBe(true); + expect(getCacheClientErrorMessage(notFoundError)).toBe('not found'); + expect(getCacheClientResponseStatus('plain')).toBeUndefined(); + }); + + it('retries transient failures with a bounded retry count', async () => { + const action = jest + .fn() + .mockRejectedValueOnce(new Error('temporary')) + .mockResolvedValue('ok'); + const onRetry = jest.fn(); + + await expect( + runWithCacheClientRetry({ + action, + delayMs: 0, + onRetry, + retryCount: 2, + }), + ).resolves.toBe('ok'); + + expect(action).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenCalledWith( + 2, + 2, + expect.objectContaining({ message: 'temporary' }), + ); + }); + + it('does not retry when the caller marks the error as non-retryable', async () => { + const action = jest.fn().mockRejectedValue(new Error('missing')); + + await expect( + runWithCacheClientRetry({ + action, + delayMs: 0, + retryCount: 3, + shouldRetry: () => false, + }), + ).rejects.toThrow('missing'); + + expect(action).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/qqbot/plugins/bangDream/tsugu/file-cache-client.spec.ts b/test/qqbot/plugins/bangDream/tsugu/file-cache-client.spec.ts index aab4c44..d19baeb 100644 --- a/test/qqbot/plugins/bangDream/tsugu/file-cache-client.spec.ts +++ b/test/qqbot/plugins/bangDream/tsugu/file-cache-client.spec.ts @@ -50,4 +50,27 @@ describe('BangDream file cache client', () => { ); expect(get).toHaveBeenCalledTimes(1); }); + + it('keeps missing svg urls in the error cache', async () => { + const notFoundError = Object.assign(new Error('not found'), { + response: { status: 404 }, + }); + const get = jest.fn().mockRejectedValueOnce(notFoundError); + + jest.doMock('axios', () => ({ + __esModule: true, + default: { get }, + })); + + const { download } = + await import('@/qqbot/plugins/bangDream/tsugu/data-clients/file-cache-client'); + + await expect(download('https://example.com/missing.svg')).rejects.toThrow( + 'not found', + ); + await expect(download('https://example.com/missing.svg')).rejects.toThrow( + 'errorUrlCache includes url', + ); + expect(get).toHaveBeenCalledTimes(1); + }); });