refactor: 收口 BangDream 缓存客户端策略
This commit is contained in:
parent
bbd30ba2ea
commit
286f57f63a
@ -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
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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。
|
||||
*
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user