refactor: 收口 BangDream 缓存客户端策略

This commit is contained in:
sunlei 2026-06-07 01:09:50 +08:00
parent bbd30ba2ea
commit 286f57f63a
7 changed files with 231 additions and 57 deletions

View File

@ -285,6 +285,7 @@ export interface TsuguHook {
- 已新增 `data-clients/data-provider.ts`,定义 `BangDreamDataProvider`、JSON/Asset/Tracker 请求参数和 provider URL 解析。 - 已新增 `data-clients/data-provider.ts`,定义 `BangDreamDataProvider`、JSON/Asset/Tracker 请求参数和 provider URL 解析。
- 已新增 `data-clients/provider-decorators.ts`,提供 `withCache`、`withRetry`、`withTiming`,缓存默认值、重试和耗时日志不再要求业务函数内手写循环。 - 已新增 `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。 - 已新增 `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-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`。 - 已新增 `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 命令执行前会等待关键主数据集合可用。 - 已接入 `BANGDREAM_TSUGU_REQUEST_TIMEOUT_MS``BANGDREAM_TSUGU_MAIN_DATA_READY_TIMEOUT_MS`HTTP 请求和主数据首次 ready 等待都有硬超时BangDream 命令执行前会等待关键主数据集合可用。
- 已新增 `scripts/bangdream-render-smoke.ps1`BangDream 图片 smoke 通过父进程限时、子进程 PID 清理、生成后显式退出,避免本地调试命令卡住进程。 - 已新增 `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。 - 已新增 `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 第一段迁移后仍能输出非空图片。 - 本地图片烟测已生成查歌 `136`、查活动 `50` 简易背景和真实活动背景图片,证明 provider/repository 第一段迁移后仍能输出非空图片。
### Phase 4搜索 specification 和 matcher ### Phase 4搜索 specification 和 matcher

View File

@ -4,6 +4,11 @@ import {
getFileNameFromUrl, getFileNameFromUrl,
} from '@/qqbot/plugins/bangDream/tsugu/data-clients/cache-path'; } from '@/qqbot/plugins/bangDream/tsugu/data-clients/cache-path';
import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger'; 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 * APIAnd缓存Response
@ -26,33 +31,24 @@ async function callAPIAndCacheResponse(
} }
const cacheDir = getCacheDirectory(url); const cacheDir = getCacheDirectory(url);
const fileName = getFileNameFromUrl(url); const fileName = getFileNameFromUrl(url);
for (let attempt = 0; attempt < retryCount; attempt++) { return await runWithCacheClientRetry({
try { action: () => getJsonAndSave(url, cacheDir, fileName, cacheTime),
const data = await getJsonAndSave(url, cacheDir, fileName, cacheTime); onFailure: (attempt, _retryCount, error) => {
return data; if (isCacheClientNotFound(error)) {
} catch (e) {
if (e && e.response && e.response.status === 404) {
// 当URL返回404错误后不再重试直接抛出错误。
logger( logger(
`API`, `API`,
`URL "${url}" returned 404 Not Found. No more retries will be made.`, `URL "${url}" returned 404 Not Found. No more retries will be made.`,
); );
throw e; return;
} }
logger( logger(
`API`, `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 }; export { callAPIAndCacheResponse };

View File

@ -8,10 +8,14 @@ import { Buffer } from 'buffer';
import { assetErrorImageBuffer } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils'; import { assetErrorImageBuffer } from '@/qqbot/plugins/bangDream/tsugu/canvas/image-utils';
import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger'; import { logger } from '@/qqbot/plugins/bangDream/tsugu/runtime/logger';
import * as fs from 'fs'; 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 errUrl: { [key: string]: number } = {};
const ERROR_CACHE_EXPIRY = 12 * 60 * 60 * 1000; // 1 天
const memoryCache: { [url: string]: Buffer } = {}; const memoryCache: { [url: string]: Buffer } = {};
/** /**
@ -35,7 +39,10 @@ async function downloadFile(
throw new Error("downloadFile: url.includes('undefined')"); 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'); throw new Error('downloadFile: errUrl includes url and not expired');
} }
@ -43,15 +50,10 @@ async function downloadFile(
const cacheDir = getCacheDirectory(url); const cacheDir = getCacheDirectory(url);
const fileName = getFileNameFromUrl(url); const fileName = getFileNameFromUrl(url);
for (let attempt = 0; attempt < retryCount; attempt++) {
let assetNotExists = false; let assetNotExists = false;
if (attempt > 0) { return await runWithCacheClientRetry({
logger( action: async () => {
`downloader`, assetNotExists = false;
`Retrying download for "${url}" (attempt ${attempt + 1}/${retryCount})`,
);
}
try {
const data = await download(url, cacheDir, fileName, cacheTime); const data = await download(url, cacheDir, fileName, cacheTime);
const htmlSig = Buffer.from('<!DOCTYPE html>'); const htmlSig = Buffer.from('<!DOCTYPE html>');
const slice = Buffer.from(data.subarray(0, htmlSig.length)); const slice = Buffer.from(data.subarray(0, htmlSig.length));
@ -63,24 +65,22 @@ async function downloadFile(
); );
} }
return data; return data;
} catch (e) { },
if (attempt === retryCount - 1) { onRetry: (nextAttempt, normalizedRetryCount) =>
throw e; logger(
} `downloader`,
if (assetNotExists) { `Retrying download for "${url}" (attempt ${nextAttempt}/${normalizedRetryCount})`,
throw e; ),
} retryCount,
//等待3秒后重试 shouldRetry: () => !assetNotExists,
await new Promise((resolve) => setTimeout(resolve, 3000)); });
}
}
} catch (e) { } catch (e) {
logger( logger(
`downloader`, `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(); errUrl[url] = Date.now();
} }

View File

@ -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<T> {
action: (attempt: number) => Promise<T>;
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<void> {
if (delayMs <= 0) return;
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
/**
*
*
* @param options -
*/
export async function runWithCacheClientRetry<T>(
options: CacheClientRetryOptions<T>,
): Promise<T> {
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;
}

View File

@ -5,9 +5,13 @@ import {
BANGDREAM_TSUGU_ENV_KEYS, BANGDREAM_TSUGU_ENV_KEYS,
normalizeBangDreamPositiveInteger, normalizeBangDreamPositiveInteger,
} from '@/qqbot/plugins/bangDream/tsugu/runtime/runtime-options'; } 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 errorUrlCache: { [url: string]: number } = {};
const ERROR_URL_CACHE_EXPIRY_MS = 12 * 60 * 60 * 1000;
const DEFAULT_REQUEST_TIMEOUT_MS = 8000; const DEFAULT_REQUEST_TIMEOUT_MS = 8000;
function getRequestTimeoutMs(): number { function getRequestTimeoutMs(): number {
@ -84,14 +88,14 @@ export async function download(
} }
return fileBuffer; return fileBuffer;
} catch (e) { } catch (e) {
if (getErrorResponseStatus(e) === 404) { if (getCacheClientResponseStatus(e) === 404) {
errorUrlCache[url] = Date.now(); errorUrlCache[url] = Date.now();
} }
if (url.includes('.png')) { if (url.includes('.png') || url.includes('.svg')) {
throw e; throw e;
} else { } else {
throw new Error( 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) { if (cachedAt == null) {
return false; return false;
} }
if (Date.now() - cachedAt >= ERROR_URL_CACHE_EXPIRY_MS) { if (Date.now() - cachedAt >= BANGDREAM_MISSING_URL_CACHE_EXPIRY_MS) {
delete errorUrlCache[url]; delete errorUrlCache[url];
return false; return false;
} }
return true; 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 * DirectoryExists
* *

View File

@ -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);
});
});

View File

@ -50,4 +50,27 @@ describe('BangDream file cache client', () => {
); );
expect(get).toHaveBeenCalledTimes(1); 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);
});
}); });