fix: 按BangDream能力懒加载目录数据

This commit is contained in:
sunlei 2026-06-16 07:18:01 +08:00
parent 4e5ed876c3
commit 0fe3f99c36
24 changed files with 488 additions and 84 deletions

View File

@ -58,9 +58,9 @@ export class BangDreamCommandContext {
} }
async checkHealth() { async checkHealth() {
await waitForBangDreamCatalogReady(); await waitForBangDreamCatalogReady(['songs']);
const data = bangdreamCatalogCache as { cards?: unknown; songs?: unknown }; const data = bangdreamCatalogCache as { songs?: unknown };
if (!data.songs || !data.cards) { if (!data.songs) {
throw new Error('BangDream 数据配置未加载'); throw new Error('BangDream 数据配置未加载');
} }
fuzzySearch('夏祭り'); fuzzySearch('夏祭り');

View File

@ -11,15 +11,16 @@ import {
sleepBangDreamRuntime, sleepBangDreamRuntime,
} from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io';
const bangdreamCatalogCache: Record<string, any> = {}; export type BangDreamCatalogKey = keyof typeof bestdoriApiPath;
const bangdreamCatalogCache: Record<string, any> = Object.fromEntries(
Object.keys(bestdoriApiPath).map((key) => [key, {}]),
);
const REQUIRED_CATALOG_KEYS = [ const REQUIRED_CATALOG_KEYS = [
'cards',
'characters',
'events',
'gacha',
'songs', 'songs',
]; ] as const satisfies readonly BangDreamCatalogKey[];
const DEFAULT_CATALOG_READY_TIMEOUT_MS = 15000; const DEFAULT_CATALOG_READY_TIMEOUT_MS = 15000;
const catalogLoadPromises = new Map<BangDreamCatalogKey, Promise<void>>();
function getCatalogReadyTimeoutMs(): number { function getCatalogReadyTimeoutMs(): number {
return normalizeBangDreamPositiveInteger( return normalizeBangDreamPositiveInteger(
@ -28,8 +29,22 @@ function getCatalogReadyTimeoutMs(): number {
); );
} }
function isCatalogReady(): boolean { function normalizeCatalogKeys(
return REQUIRED_CATALOG_KEYS.every((key) => { keys: readonly BangDreamCatalogKey[] = REQUIRED_CATALOG_KEYS,
): BangDreamCatalogKey[] {
return [...new Set(keys)].filter((key) => key in bestdoriApiPath);
}
function isCatalogKeyReady(key: BangDreamCatalogKey): boolean {
const collection = bangdreamCatalogCache[key];
if (!collection) return false;
return typeof collection === 'object'
? Object.keys(collection).length > 0
: true;
}
function isCatalogReady(keys?: readonly BangDreamCatalogKey[]): boolean {
return normalizeCatalogKeys(keys).every((key) => {
const collection = bangdreamCatalogCache[key]; const collection = bangdreamCatalogCache[key];
return collection && Object.keys(collection).length > 0; return collection && Object.keys(collection).length > 0;
}); });
@ -45,26 +60,56 @@ async function rejectAfter(ms: number): Promise<never> {
* *
* @param useCache - use缓存参数使 * @param useCache - use缓存参数使
*/ */
async function loadCatalogData(useCache: boolean = false) { async function loadCatalogData(
keys?: readonly BangDreamCatalogKey[],
useCache: boolean = false,
) {
const catalogKeys = normalizeCatalogKeys(keys);
logger('catalog', 'loading catalog...'); logger('catalog', 'loading catalog...');
const promiseAll = Object.keys(bestdoriApiPath).map(async (key) => {
for (const key of catalogKeys) {
await loadCatalogKey(key, useCache);
}
await applyStaticPatches(catalogKeys);
logger('catalog', 'catalog loaded');
}
async function loadCatalogKey(key: BangDreamCatalogKey, useCache: boolean) {
if (isCatalogKeyReady(key)) return;
const pending = catalogLoadPromises.get(key);
if (pending) return await pending;
const promise = (async () => {
if (useCache) { if (useCache) {
return (bangdreamCatalogCache[key] = bangdreamCatalogCache[key] = await bangdreamBestdoriProvider.getJson(
await bangdreamBestdoriProvider.getJson(bestdoriApiPath[key], { bestdoriApiPath[key],
{
cacheTime: 1 / 0, cacheTime: 1 / 0,
})); },
} else { );
return;
}
try { try {
return (bangdreamCatalogCache[key] = bangdreamCatalogCache[key] = await bangdreamBestdoriProvider.getJson(
await bangdreamBestdoriProvider.getJson(bestdoriApiPath[key])); bestdoriApiPath[key],
);
} catch { } catch {
logger('catalog', `load ${key} failed`); logger('catalog', `load ${key} failed`);
} }
})();
catalogLoadPromises.set(key, promise);
try {
await promise;
} finally {
catalogLoadPromises.delete(key);
}
} }
});
await Promise.all(promiseAll);
async function applyStaticPatches(keys: readonly BangDreamCatalogKey[]) {
const keySet = new Set(keys);
if (keySet.has('cards')) {
const cardsCnFix = const cardsCnFix =
await bangdreamStaticPatchProvider.readJson<Record<string, unknown>>( await bangdreamStaticPatchProvider.readJson<Record<string, unknown>>(
'cards-cn-fix.json', 'cards-cn-fix.json',
@ -72,6 +117,8 @@ async function loadCatalogData(useCache: boolean = false) {
for (const key in cardsCnFix) { for (const key in cardsCnFix) {
bangdreamCatalogCache['cards'][key] = cardsCnFix[key]; bangdreamCatalogCache['cards'][key] = cardsCnFix[key];
} }
}
if (keySet.has('skills')) {
const skillsCnFix = const skillsCnFix =
await bangdreamStaticPatchProvider.readJson<Record<string, unknown>>( await bangdreamStaticPatchProvider.readJson<Record<string, unknown>>(
'skills-cn-fix.json', 'skills-cn-fix.json',
@ -79,6 +126,8 @@ async function loadCatalogData(useCache: boolean = false) {
for (const key in skillsCnFix) { for (const key in skillsCnFix) {
bangdreamCatalogCache['skills'][key] = skillsCnFix[key]; bangdreamCatalogCache['skills'][key] = skillsCnFix[key];
} }
}
if (keySet.has('areaItems')) {
const areaItemFix = const areaItemFix =
await bangdreamStaticPatchProvider.readJson<Record<string, unknown>>( await bangdreamStaticPatchProvider.readJson<Record<string, unknown>>(
'area-item-fix.json', 'area-item-fix.json',
@ -88,6 +137,8 @@ async function loadCatalogData(useCache: boolean = false) {
bangdreamCatalogCache['areaItems'][key] = areaItemFix[key]; bangdreamCatalogCache['areaItems'][key] = areaItemFix[key];
} }
} }
}
if (keySet.has('songs')) {
try { try {
const songNickname = await bangdreamStaticPatchProvider.readExcelRows<{ const songNickname = await bangdreamStaticPatchProvider.readExcelRows<{
Id: number; Id: number;
@ -103,33 +154,32 @@ async function loadCatalogData(useCache: boolean = false) {
} catch { } catch {
logger('catalog', '读取 nickname-song.xlsx 失败'); logger('catalog', '读取 nickname-song.xlsx 失败');
} }
logger('catalog', 'catalog loaded'); }
} }
let initialLoadPromise: Promise<void> | undefined; async function ensureCatalogInitialLoad(keys?: readonly BangDreamCatalogKey[]) {
const catalogKeys = normalizeCatalogKeys(keys);
function ensureCatalogInitialLoad() { if (isCatalogReady(catalogKeys)) return;
if (!initialLoadPromise) {
logger('catalog', 'initializing...'); logger('catalog', 'initializing...');
initialLoadPromise = loadCatalogData(true).then(() => { await loadCatalogData(catalogKeys, true);
logger('catalog', 'initializing done'); logger('catalog', 'initializing done');
});
}
return initialLoadPromise;
} }
/** /**
* BangDream * BangDream
*/ */
export async function waitForBangDreamCatalogReady(): Promise<void> { export async function waitForBangDreamCatalogReady(
if (isCatalogReady()) { keys?: readonly BangDreamCatalogKey[],
): Promise<void> {
const catalogKeys = normalizeCatalogKeys(keys);
if (isCatalogReady(catalogKeys)) {
return; return;
} }
await Promise.race([ await Promise.race([
ensureCatalogInitialLoad(), ensureCatalogInitialLoad(catalogKeys),
rejectAfter(getCatalogReadyTimeoutMs()), rejectAfter(getCatalogReadyTimeoutMs()),
]); ]);
if (!isCatalogReady()) { if (!isCatalogReady(catalogKeys)) {
throw new Error('BangDream 主数据未完成关键集合加载'); throw new Error('BangDream 主数据未完成关键集合加载');
} }
} }

View File

@ -46,6 +46,7 @@ type BangDreamManifestOperation = {
}; };
type BangDreamResolvedOperation = BangDreamManifestOperation & { type BangDreamResolvedOperation = BangDreamManifestOperation & {
catalogKeys?: BangDreamOperationModule['catalogKeys'];
execute: BangDreamOperationModule['execute']; execute: BangDreamOperationModule['execute'];
}; };
@ -131,6 +132,7 @@ function resolveBangDreamOperations(operations: BangDreamManifestOperation[]) {
operation.key, operation.key,
{ {
...operation, ...operation,
catalogKeys: operationModule.catalogKeys,
execute: operationModule.execute, execute: operationModule.execute,
}, },
] as const; ] as const;
@ -153,9 +155,6 @@ async function executeBangDreamOperation(options: {
await options.lifecycle.beforeParse(operationContext); await options.lifecycle.beforeParse(operationContext);
try { try {
operationContext.stage = 'catalog';
await waitForBangDreamCatalogReady();
operationContext.stage = 'operation'; operationContext.stage = 'operation';
const operation = options.operationsByKey.get(options.operationKey); const operation = options.operationsByKey.get(options.operationKey);
if (!operation) { if (!operation) {
@ -164,6 +163,9 @@ async function executeBangDreamOperation(options: {
operationContext.handlerName = operation.handlerName; operationContext.handlerName = operation.handlerName;
await options.lifecycle.afterResolve(operationContext); await options.lifecycle.afterResolve(operationContext);
operationContext.stage = 'catalog';
await waitForBangDreamCatalogReady(operation.catalogKeys);
operationContext.stage = 'handler'; operationContext.stage = 'handler';
await options.lifecycle.beforeRender(operationContext); await options.lifecycle.beforeRender(operationContext);
const output = await operation.execute(options.input, options.context); const output = await operation.execute(options.input, options.context);

View File

@ -1,7 +1,9 @@
import { Card } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card.model'; import { Card } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card.model';
import { BANGDREAM_CARD_ILLUSTRATION_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const cardIllustrationOperation: BangDreamOperationModule = { export const cardIllustrationOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_CARD_ILLUSTRATION_CATALOG_KEYS,
handlerName: 'getCardIllustration', handlerName: 'getCardIllustration',
execute: async (input, context) => { execute: async (input, context) => {
const cardId = context.requireNumber( const cardId = context.requireNumber(

View File

@ -1,8 +1,10 @@
import { drawCardDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-detail.renderer'; import { drawCardDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-detail.renderer';
import { drawCardList } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-search.renderer'; import { drawCardList } from '@/modules/qqbot/plugins/bangdream/src/domain/card/card-search.renderer';
import { BANGDREAM_CARD_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const cardSearchOperation: BangDreamOperationModule = { export const cardSearchOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_CARD_CATALOG_KEYS,
handlerName: 'searchCard', handlerName: 'searchCard',
execute: async (input, context) => { execute: async (input, context) => {
const query = context.requireText(input, '请提供卡牌关键词或卡牌 ID'); const query = context.requireText(input, '请提供卡牌关键词或卡牌 ID');

View File

@ -0,0 +1,105 @@
import type { BangDreamCatalogKey } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache';
export const BANGDREAM_BAND_CATALOG_KEYS = [
'singer',
'bands',
'characters',
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_SONG_CATALOG_KEYS = [
'songs',
'meta',
...BANGDREAM_BAND_CATALOG_KEYS,
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_SONG_SEARCH_CATALOG_KEYS = [
...BANGDREAM_SONG_CATALOG_KEYS,
'events',
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_CARD_ILLUSTRATION_CATALOG_KEYS = [
'cards',
'skills',
'characters',
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_CARD_CATALOG_KEYS = [
...BANGDREAM_CARD_ILLUSTRATION_CATALOG_KEYS,
'singer',
'bands',
'events',
'gacha',
'costumes',
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_CHARACTER_CATALOG_KEYS = [
'characters',
'singer',
'bands',
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_EVENT_CATALOG_KEYS = [
'events',
'characters',
'singer',
'bands',
'cards',
'skills',
'gacha',
'songs',
'meta',
'degrees',
'deco',
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_EVENT_STAGE_CATALOG_KEYS = [
'events',
'characters',
'songs',
'meta',
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_GACHA_CATALOG_KEYS = [
'gacha',
'cards',
'skills',
'characters',
'singer',
'bands',
'events',
'items',
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_GACHA_SIMULATE_CATALOG_KEYS = [
'gacha',
'cards',
'skills',
'characters',
'singer',
'bands',
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_CUTOFF_BASE_CATALOG_KEYS = [
'events',
'characters',
'rates',
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_CUTOFF_DETAIL_CATALOG_KEYS = [
...BANGDREAM_CUTOFF_BASE_CATALOG_KEYS,
'cards',
'skills',
'singer',
'bands',
'degrees',
] as const satisfies readonly BangDreamCatalogKey[];
export const BANGDREAM_PLAYER_CATALOG_KEYS = [
'cards',
'skills',
'characters',
'singer',
'bands',
'degrees',
'areaItems',
] as const satisfies readonly BangDreamCatalogKey[];

View File

@ -1,8 +1,10 @@
import { drawCharacterDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character-detail.renderer'; import { drawCharacterDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character-detail.renderer';
import { drawCharacterList } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character-search.renderer'; import { drawCharacterList } from '@/modules/qqbot/plugins/bangdream/src/domain/character/character-search.renderer';
import { BANGDREAM_CHARACTER_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const characterSearchOperation: BangDreamOperationModule = { export const characterSearchOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_CHARACTER_CATALOG_KEYS,
handlerName: 'searchCharacter', handlerName: 'searchCharacter',
execute: async (input, context) => { execute: async (input, context) => {
const query = context.requireText(input, '请提供角色关键词或角色 ID'); const query = context.requireText(input, '请提供角色关键词或角色 ID');

View File

@ -1,8 +1,10 @@
import { drawCutoffAll } from '@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-all.renderer'; import { drawCutoffAll } from '@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-all.renderer';
import { getPresentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model'; import { getPresentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model';
import { BANGDREAM_CUTOFF_BASE_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const cutoffAllOperation: BangDreamOperationModule = { export const cutoffAllOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_CUTOFF_BASE_CATALOG_KEYS,
handlerName: 'getCutoffAll', handlerName: 'getCutoffAll',
execute: async (input, context) => { execute: async (input, context) => {
const tokens = context.getTokens(input); const tokens = context.getTokens(input);

View File

@ -1,6 +1,7 @@
import { drawCutoffDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-detail.renderer'; import { drawCutoffDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-detail.renderer';
import { drawCutoffEventTop } from '@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-event-top.renderer'; import { drawCutoffEventTop } from '@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-event-top.renderer';
import { getPresentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model'; import { getPresentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model';
import { BANGDREAM_CUTOFF_DETAIL_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
const CUTOFF_DETAIL_COMMAND_ALIASES = new Set([ const CUTOFF_DETAIL_COMMAND_ALIASES = new Set([
@ -12,6 +13,7 @@ const CUTOFF_DETAIL_COMMAND_ALIASES = new Set([
const DEFAULT_CUTOFF_DETAIL_TIER = 1000; const DEFAULT_CUTOFF_DETAIL_TIER = 1000;
export const cutoffDetailOperation: BangDreamOperationModule = { export const cutoffDetailOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_CUTOFF_DETAIL_CATALOG_KEYS,
handlerName: 'getCutoffDetail', handlerName: 'getCutoffDetail',
execute: async (input, context) => { execute: async (input, context) => {
const tokens = context.getTokens(input); const tokens = context.getTokens(input);

View File

@ -1,8 +1,10 @@
import { drawCutoffListOfRecentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-recent.renderer'; import { drawCutoffListOfRecentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/cutoff/cutoff-recent.renderer';
import { getPresentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model'; import { getPresentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model';
import { BANGDREAM_CUTOFF_BASE_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const cutoffRecentOperation: BangDreamOperationModule = { export const cutoffRecentOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_CUTOFF_BASE_CATALOG_KEYS,
handlerName: 'getCutoffRecent', handlerName: 'getCutoffRecent',
execute: async (input, context) => { execute: async (input, context) => {
const tokens = context.getTokens(input); const tokens = context.getTokens(input);

View File

@ -1,8 +1,10 @@
import { drawEventDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event-detail.renderer'; import { drawEventDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event-detail.renderer';
import { drawEventList } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event-search.renderer'; import { drawEventList } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event-search.renderer';
import { BANGDREAM_EVENT_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const eventSearchOperation: BangDreamOperationModule = { export const eventSearchOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_EVENT_CATALOG_KEYS,
handlerName: 'searchEvent', handlerName: 'searchEvent',
execute: async (input, context) => { execute: async (input, context) => {
const query = context.requireText(input, '请提供活动关键词或活动 ID'); const query = context.requireText(input, '请提供活动关键词或活动 ID');

View File

@ -1,8 +1,10 @@
import { getPresentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model'; import { getPresentEvent } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event.model';
import { drawEventStage } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event-stage.renderer'; import { drawEventStage } from '@/modules/qqbot/plugins/bangdream/src/domain/event/event-stage.renderer';
import { BANGDREAM_EVENT_STAGE_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const eventStageOperation: BangDreamOperationModule = { export const eventStageOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_EVENT_STAGE_CATALOG_KEYS,
expectedImageCount: 5, expectedImageCount: 5,
handlerName: 'getEventStage', handlerName: 'getEventStage',
execute: async (input, context) => { execute: async (input, context) => {

View File

@ -1,7 +1,9 @@
import { drawGachaDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha-detail.renderer'; import { drawGachaDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/gacha/gacha-detail.renderer';
import { BANGDREAM_GACHA_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const gachaSearchOperation: BangDreamOperationModule = { export const gachaSearchOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_GACHA_CATALOG_KEYS,
handlerName: 'searchGacha', handlerName: 'searchGacha',
execute: async (input, context) => { execute: async (input, context) => {
const gachaId = context.requireNumber( const gachaId = context.requireNumber(

View File

@ -7,9 +7,11 @@ import {
BANGDREAM_GACHA_DEFAULT_SPIN_COUNT, BANGDREAM_GACHA_DEFAULT_SPIN_COUNT,
isBirthdayGachaType, isBirthdayGachaType,
} from '@/modules/qqbot/plugins/bangdream/src/domain/policy/gacha.policy'; } from '@/modules/qqbot/plugins/bangdream/src/domain/policy/gacha.policy';
import { BANGDREAM_GACHA_SIMULATE_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const gachaSimulateOperation: BangDreamOperationModule = { export const gachaSimulateOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_GACHA_SIMULATE_CATALOG_KEYS,
handlerName: 'simulateGacha', handlerName: 'simulateGacha',
execute: async (input, context) => { execute: async (input, context) => {
const tokens = context.getTokens(input); const tokens = context.getTokens(input);

View File

@ -4,6 +4,7 @@ import type {
BangDreamCommandOutput, BangDreamCommandOutput,
BangDreamOperationHandlerName, BangDreamOperationHandlerName,
} from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types'; } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream.types';
import type { BangDreamCatalogKey } from '@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache';
export type BangDreamOperationExecute = ( export type BangDreamOperationExecute = (
input: BangDreamCommandInput, input: BangDreamCommandInput,
@ -11,6 +12,7 @@ export type BangDreamOperationExecute = (
) => Promise<BangDreamCommandOutput>; ) => Promise<BangDreamCommandOutput>;
export type BangDreamOperationModule = { export type BangDreamOperationModule = {
catalogKeys?: readonly BangDreamCatalogKey[];
execute: BangDreamOperationExecute; execute: BangDreamOperationExecute;
expectedImageCount?: number; expectedImageCount?: number;
handlerName: BangDreamOperationHandlerName; handlerName: BangDreamOperationHandlerName;

View File

@ -1,7 +1,9 @@
import { drawPlayerDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/player/player-detail.renderer'; import { drawPlayerDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/player/player-detail.renderer';
import { BANGDREAM_PLAYER_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const playerSearchOperation: BangDreamOperationModule = { export const playerSearchOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_PLAYER_CATALOG_KEYS,
handlerName: 'searchPlayer', handlerName: 'searchPlayer',
execute: async (input, context) => { execute: async (input, context) => {
const tokens = context.getTokens(input); const tokens = context.getTokens(input);

View File

@ -1,7 +1,9 @@
import { drawSongChart } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song-chart.renderer'; import { drawSongChart } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song-chart.renderer';
import { BANGDREAM_SONG_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const songChartOperation: BangDreamOperationModule = { export const songChartOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_SONG_CATALOG_KEYS,
handlerName: 'getSongChart', handlerName: 'getSongChart',
execute: async (input, context) => { execute: async (input, context) => {
const tokens = context.getTokens(input); const tokens = context.getTokens(input);

View File

@ -1,8 +1,10 @@
import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model';
import { drawSongMetaList } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song-meta.renderer'; import { drawSongMetaList } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song-meta.renderer';
import { BANGDREAM_SONG_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const songMetaOperation: BangDreamOperationModule = { export const songMetaOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_SONG_CATALOG_KEYS,
handlerName: 'getSongMeta', handlerName: 'getSongMeta',
execute: async (input, context) => { execute: async (input, context) => {
const mainServer = context.pickMainServer(input, context.getTokens(input)); const mainServer = context.pickMainServer(input, context.getTokens(input));

View File

@ -1,9 +1,11 @@
import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model'; import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model';
import { fuzzySearch } from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search'; import { fuzzySearch } from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search';
import { drawSongRandom } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song-random.renderer'; import { drawSongRandom } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song-random.renderer';
import { BANGDREAM_SONG_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const songRandomOperation: BangDreamOperationModule = { export const songRandomOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_SONG_CATALOG_KEYS,
handlerName: 'randomSong', handlerName: 'randomSong',
execute: async (input, context) => { execute: async (input, context) => {
const query = context.pickText(input); const query = context.pickText(input);

View File

@ -1,9 +1,11 @@
import { Song } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song.model'; import { Song } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song.model';
import { drawSongDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song-detail.renderer'; import { drawSongDetail } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song-detail.renderer';
import { drawSongList } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song-search.renderer'; import { drawSongList } from '@/modules/qqbot/plugins/bangdream/src/domain/song/song-search.renderer';
import { BANGDREAM_SONG_SEARCH_CATALOG_KEYS } from '@/modules/qqbot/plugins/bangdream/src/operations/catalog-keys';
import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation'; import type { BangDreamOperationModule } from '@/modules/qqbot/plugins/bangdream/src/operations/operation';
export const songSearchOperation: BangDreamOperationModule = { export const songSearchOperation: BangDreamOperationModule = {
catalogKeys: BANGDREAM_SONG_SEARCH_CATALOG_KEYS,
handlerName: 'searchSong', handlerName: 'searchSong',
execute: async (input, context) => { execute: async (input, context) => {
const query = context.requireText(input, '请提供歌曲名或歌曲 ID'); const query = context.requireText(input, '请提供歌曲名或歌曲 ID');

View File

@ -0,0 +1,129 @@
import { getBangDreamOperationsByHandlerName } from '@/modules/qqbot/plugins/bangdream/src/operations';
describe('BangDream operation catalog keys', () => {
it('declares the catalog keys required by each operation', () => {
const operations = getBangDreamOperationsByHandlerName();
expectCatalogKeys(operations.get('searchSong')?.catalogKeys, [
'songs',
'meta',
'singer',
'bands',
'characters',
'events',
]);
expectCatalogKeys(operations.get('getSongChart')?.catalogKeys, [
'songs',
'meta',
'singer',
'bands',
'characters',
]);
expectCatalogKeys(operations.get('randomSong')?.catalogKeys, [
'songs',
'meta',
'singer',
'bands',
'characters',
]);
expectCatalogKeys(operations.get('getSongMeta')?.catalogKeys, [
'songs',
'meta',
'singer',
'bands',
'characters',
]);
expectCatalogKeys(operations.get('searchCard')?.catalogKeys, [
'cards',
'skills',
'characters',
'singer',
'bands',
'events',
'gacha',
'costumes',
]);
expectCatalogKeys(operations.get('getCardIllustration')?.catalogKeys, [
'cards',
'skills',
'characters',
]);
expectCatalogKeys(operations.get('searchCharacter')?.catalogKeys, [
'characters',
'singer',
'bands',
]);
expectCatalogKeys(operations.get('searchEvent')?.catalogKeys, [
'events',
'characters',
'singer',
'bands',
'cards',
'skills',
'gacha',
'songs',
'meta',
'degrees',
'deco',
]);
expectCatalogKeys(operations.get('getEventStage')?.catalogKeys, [
'events',
'characters',
'songs',
'meta',
]);
expectCatalogKeys(operations.get('searchPlayer')?.catalogKeys, [
'cards',
'skills',
'characters',
'singer',
'bands',
'degrees',
'areaItems',
]);
expectCatalogKeys(operations.get('searchGacha')?.catalogKeys, [
'gacha',
'cards',
'skills',
'characters',
'singer',
'bands',
'events',
'items',
]);
expectCatalogKeys(operations.get('simulateGacha')?.catalogKeys, [
'gacha',
'cards',
'skills',
'characters',
'singer',
'bands',
]);
expectCatalogKeys(operations.get('getCutoffDetail')?.catalogKeys, [
'events',
'characters',
'rates',
'cards',
'skills',
'singer',
'bands',
'degrees',
]);
expectCatalogKeys(operations.get('getCutoffAll')?.catalogKeys, [
'events',
'characters',
'rates',
]);
expectCatalogKeys(operations.get('getCutoffRecent')?.catalogKeys, [
'events',
'characters',
'rates',
]);
expect(operations.size).toBe(15);
});
});
function expectCatalogKeys(actual: readonly string[] | undefined, keys: string[]) {
expect(actual).toEqual(keys);
}

View File

@ -51,6 +51,17 @@ jest.mock('@/modules/qqbot/plugins/bangdream/src/operations', () => ({
return [ return [
handlerName, handlerName,
{ {
catalogKeys:
key === 'bangdream.song.search'
? [
'songs',
'meta',
'singer',
'bands',
'characters',
'events',
]
: undefined,
execute, execute,
handlerName, handlerName,
}, },
@ -109,7 +120,14 @@ describe('BangDream package entry', () => {
}); });
expect(mockContext.refreshDictionaryCache).toHaveBeenCalledTimes(1); expect(mockContext.refreshDictionaryCache).toHaveBeenCalledTimes(1);
expect(waitForBangDreamCatalogReady).toHaveBeenCalledTimes(1); expect(waitForBangDreamCatalogReady).toHaveBeenCalledWith([
'songs',
'meta',
'singer',
'bands',
'characters',
'events',
]);
expect(songSearch).toHaveBeenCalledWith( expect(songSearch).toHaveBeenCalledWith(
{ text: '夏祭り' }, { text: '夏祭り' },
mockContext, mockContext,

View File

@ -0,0 +1,63 @@
describe('BangDream catalog cache partial loading', () => {
it('loads only requested catalog keys and their matching static patches', async () => {
const getJson = jest.fn<
Promise<Record<string, { path: string }>>,
[string, { cacheTime?: number }?]
>(async (path) => ({
1: { path },
}));
const readJson = jest.fn(async () => ({}));
const readExcelRows = jest.fn(async () => []);
jest.resetModules();
jest.doMock(
'@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider',
() => ({
bangdreamBestdoriProvider: {
getJson,
},
}),
);
jest.doMock(
'@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/static-patch.provider',
() => ({
bangdreamStaticPatchProvider: {
readExcelRows,
readJson,
},
}),
);
jest.doMock(
'@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger',
() => ({
logger: jest.fn(),
}),
);
jest.doMock(
'@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io',
() => ({
readBangDreamRuntimeConfig: jest.fn(() => undefined),
sleepBangDreamRuntime: jest.fn(() => new Promise(() => undefined)),
}),
);
const { BANGDREAM_BESTDORI_API_PATHS } = await import(
'@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream-protocol'
);
const { waitForBangDreamCatalogReady } = await import(
'@/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache'
);
await waitForBangDreamCatalogReady(['songs', 'meta']);
expect(getJson).toHaveBeenCalledTimes(2);
expect(getJson.mock.calls.map(([path]) => path).sort()).toEqual(
[
BANGDREAM_BESTDORI_API_PATHS.meta,
BANGDREAM_BESTDORI_API_PATHS.songs,
].sort(),
);
expect(readJson).not.toHaveBeenCalled();
expect(readExcelRows).toHaveBeenCalledTimes(1);
});
});

View File

@ -51,9 +51,13 @@ describe('BangDream catalog cache', () => {
}); });
it('loads each Bestdori catalog path only once during the initial ready wait', async () => { it('loads each Bestdori catalog path only once during the initial ready wait', async () => {
await waitForBangDreamCatalogReady!();
const catalogPaths = Object.values(BANGDREAM_BESTDORI_API_PATHS); const catalogPaths = Object.values(BANGDREAM_BESTDORI_API_PATHS);
await waitForBangDreamCatalogReady(
Object.keys(BANGDREAM_BESTDORI_API_PATHS) as Array<
keyof typeof BANGDREAM_BESTDORI_API_PATHS
>,
);
expect(mockGetJson).toHaveBeenCalledTimes(catalogPaths.length); expect(mockGetJson).toHaveBeenCalledTimes(catalogPaths.length);
expect(mockGetJson.mock.calls.map(([path]) => path).sort()).toEqual( expect(mockGetJson.mock.calls.map(([path]) => path).sort()).toEqual(
[...catalogPaths].sort(), [...catalogPaths].sort(),
@ -66,4 +70,5 @@ describe('BangDream catalog cache', () => {
expect(mockReadJson).toHaveBeenCalledTimes(3); expect(mockReadJson).toHaveBeenCalledTimes(3);
expect(mockReadExcelRows).toHaveBeenCalledTimes(1); expect(mockReadExcelRows).toHaveBeenCalledTimes(1);
}); });
}); });