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/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

View File

@ -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}`,
);
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`,
`Failed to get JSON from "${url}" on attempt ${attempt}. Error: ${getCacheClientErrorMessage(error)}`,
);
},
retryCount,
shouldRetry: (error) => !isCacheClientNotFound(error),
});
}
export { callAPIAndCacheResponse };

View File

@ -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 {
return await runWithCacheClientRetry({
action: async () => {
assetNotExists = false;
const data = await download(url, cacheDir, fileName, cacheTime);
const htmlSig = Buffer.from('<!DOCTYPE html>');
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();
}

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,
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
*

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