feat: 增加BangDream主数据同步任务

This commit is contained in:
sunlei 2026-06-17 09:33:41 +08:00
parent 62161bac8f
commit 0263b423c2
8 changed files with 324 additions and 11 deletions

View File

@ -1,5 +1,11 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import {
existsSync,
mkdirSync,
readFileSync,
renameSync,
writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { parentPort, workerData } from 'node:worker_threads';
import * as XLSX from 'xlsx';
import type {
@ -330,6 +336,10 @@ function createBangDreamRuntimeIo(): BangDreamRuntimeIo {
readExcelRows: async (filePath) => readExcelRows(filePath),
readJsonFile: async (filePath) => readJsonFile(filePath),
readJsonFileSync: (filePath) => readJsonFile(filePath),
renameFile: async (from, to) => {
mkdirSync(dirname(to), { recursive: true });
renameSync(from, to);
},
requestArrayBuffer: async (url, options) => ({
body: Buffer.from(
await callHost<Uint8Array>('requestBuffer', {
@ -361,8 +371,10 @@ function createBangDreamRuntimeIo(): BangDreamRuntimeIo {
}),
sleep: async (ms) =>
await new Promise((resolve) => setTimeout(resolve, ms)),
writeJsonFile: async (filePath, data) =>
writeFileSync(filePath, JSON.stringify(data)),
writeJsonFile: async (filePath, data) => {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, JSON.stringify(data));
},
};
}

View File

@ -13,7 +13,8 @@
"runtime.http",
"asset.read",
"plugin.config.read",
"plugin.storage.read"
"plugin.storage.read",
"plugin.storage.write"
],
"runtime": {
"workerType": "node-worker",
@ -257,6 +258,22 @@
"timeoutMs": 30000
}
],
"tasks": [
{
"key": "bangdream.bestdori.sync-main-data",
"name": "同步 Bestdori 主数据",
"handlerName": "syncBestdoriMainData",
"description": "同步 BangDream 重命令依赖的 Bestdori JSON 主数据。",
"defaultCron": "0 */6 * * *",
"enabled": true,
"timeoutMs": 120000,
"permissions": [
"runtime.http",
"plugin.storage.read",
"plugin.storage.write"
]
}
],
"events": [],
"assets": [
{

View File

@ -1,3 +1,4 @@
import { join } from 'node:path';
import { bestdoriApiPath } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config';
import { bangdreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider';
import { bangdreamStaticPatchProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/static-patch.provider';
@ -7,6 +8,7 @@ import {
normalizeBangDreamPositiveInteger,
} from '@/modules/qqbot/plugins/bangdream/src/config/runtime-options';
import {
readBangDreamJsonFile,
readBangDreamRuntimeConfig,
sleepBangDreamRuntime,
} from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io';
@ -82,12 +84,18 @@ async function loadCatalogKey(key: BangDreamCatalogKey, useCache: boolean) {
const promise = (async () => {
if (useCache) {
bangdreamCatalogCache[key] = await bangdreamBestdoriProvider.getJson(
bestdoriApiPath[key],
{
cacheTime: 1 / 0,
},
);
try {
bangdreamCatalogCache[key] = await readBangDreamCatalogDataFromCache(
key,
);
} catch {
bangdreamCatalogCache[key] = await bangdreamBestdoriProvider.getJson(
bestdoriApiPath[key],
{
cacheTime: 1 / 0,
},
);
}
return;
}
@ -184,4 +192,34 @@ export async function waitForBangDreamCatalogReady(
}
}
export async function refreshBangDreamCatalogFromCache(
keys?: readonly BangDreamCatalogKey[],
) {
const catalogKeys = normalizeCatalogKeys(keys);
for (const key of catalogKeys) {
bangdreamCatalogCache[key] = {};
}
await loadCatalogData(catalogKeys, true);
}
export function resolveBangDreamMainDataCachePath(
cacheRoot: string,
key: BangDreamCatalogKey,
) {
return join(cacheRoot, 'bestdori', `${key}.json`);
}
function resolveBangDreamCatalogCacheRoot() {
return (
readBangDreamRuntimeConfig(BANGDREAM_TSUGU_ENV_KEYS.cacheRoot) ||
join(process.cwd(), '.kt-workspace', 'cache', 'bangdream')
);
}
async function readBangDreamCatalogDataFromCache(key: BangDreamCatalogKey) {
return readBangDreamJsonFile(
resolveBangDreamMainDataCachePath(resolveBangDreamCatalogCacheRoot(), key),
);
}
export default bangdreamCatalogCache;

View File

@ -0,0 +1,119 @@
import { join } from 'node:path';
import { bestdoriApiPath, bestdoriUrl } from '../../config/runtime-config';
import { BANGDREAM_TSUGU_ENV_KEYS } from '../../config/runtime-options';
import {
readBangDreamRuntimeConfig,
requestBangDreamJson,
writeBangDreamJsonFileAtomic,
} from '../../infrastructure/integration/runtime-io';
import {
refreshBangDreamCatalogFromCache,
resolveBangDreamMainDataCachePath,
type BangDreamCatalogKey,
} from '../catalog/bangdream-catalog-cache';
export const BANGDREAM_BESTDORI_MAIN_DATA_KEYS = [
'songs',
'meta',
'cards',
'skills',
'events',
'gacha',
'costumes',
'bands',
'characters',
'areaItems',
] as const satisfies readonly BangDreamCatalogKey[];
type BangDreamBestdoriMainDataKey =
(typeof BANGDREAM_BESTDORI_MAIN_DATA_KEYS)[number];
type BangDreamBestdoriMainDataSyncOutput = {
cacheRootConfigured: boolean;
durationMs: number;
failedCount: number;
successCount: number;
syncedKeys: BangDreamBestdoriMainDataKey[];
};
export function createBestdoriMainDataSyncTask() {
return {
execute: syncBestdoriMainData,
handlerName: 'syncBestdoriMainData',
key: 'bangdream.bestdori.sync-main-data',
};
}
async function syncBestdoriMainData(
input: Record<string, unknown>,
): Promise<BangDreamBestdoriMainDataSyncOutput> {
const startedAt = Date.now();
const keys = normalizeKeys(input.keys);
const cacheRoot = resolveCacheRoot();
const failures: Array<{ key: string; message: string }> = [];
const syncedKeys: BangDreamBestdoriMainDataKey[] = [];
for (const key of keys) {
try {
const response = await requestBangDreamJson(
new URL(bestdoriApiPath[key], bestdoriUrl).toString(),
{ timeoutMs: 30000 },
);
await writeBangDreamJsonFileAtomic(
resolveBangDreamMainDataCachePath(cacheRoot, key),
response.body,
);
syncedKeys.push(key);
} catch (error) {
failures.push({
key,
message: error instanceof Error ? error.message : `${error}`,
});
}
}
if (syncedKeys.length > 0) {
await refreshBangDreamCatalogFromCache(syncedKeys);
}
if (failures.length > 0) {
throw new Error(
`BangDream Bestdori 主数据同步失败:${failures
.map((failure) => `${failure.key}:${failure.message}`)
.join('; ')}`,
);
}
return {
cacheRootConfigured: Boolean(
readBangDreamRuntimeConfig(BANGDREAM_TSUGU_ENV_KEYS.cacheRoot),
),
durationMs: Date.now() - startedAt,
failedCount: failures.length,
successCount: syncedKeys.length,
syncedKeys,
};
}
function normalizeKeys(input: unknown): BangDreamBestdoriMainDataKey[] {
const requested = Array.isArray(input) ? input : [];
const allowed = new Set<string>(BANGDREAM_BESTDORI_MAIN_DATA_KEYS);
const keys =
requested.length > 0
? requested.filter(
(key): key is BangDreamBestdoriMainDataKey =>
typeof key === 'string' && allowed.has(key),
)
: BANGDREAM_BESTDORI_MAIN_DATA_KEYS;
const uniqueKeys = [...new Set(keys)];
return uniqueKeys.length > 0
? uniqueKeys
: [...BANGDREAM_BESTDORI_MAIN_DATA_KEYS];
}
function resolveCacheRoot() {
return (
readBangDreamRuntimeConfig(BANGDREAM_TSUGU_ENV_KEYS.cacheRoot) ||
join(process.cwd(), '.kt-workspace', 'cache', 'bangdream')
);
}

View File

@ -0,0 +1 @@
export * from './bestdori-main-data-sync.task';

View File

@ -11,6 +11,7 @@ import {
configureBangDreamRuntimeIo,
type BangDreamRuntimeIo,
} from './infrastructure/integration/runtime-io';
import { createBestdoriMainDataSyncTask } from './application/tasks';
import {
getBangDreamOperationsByHandlerName,
type BangDreamOperationModule,
@ -58,6 +59,7 @@ export function createPlugin(options: BangDreamPluginRuntimeOptions) {
createBangDreamOperationLogObserver(),
]);
const operationsByKey = resolveBangDreamOperations(options.operations);
const tasks = [createBestdoriMainDataSyncTask()];
const normalizeError =
options.normalizeError ||
((error: unknown) =>
@ -120,6 +122,7 @@ export function createPlugin(options: BangDreamPluginRuntimeOptions) {
execute: async (input: BangDreamCommandInput) =>
await executeOperation(operation.key, input),
})),
tasks,
version: options.version || '2.0.0',
};
}

View File

@ -6,6 +6,7 @@ export type BangDreamRuntimeIo = {
) => Promise<T[]>;
readJsonFile?: <T = unknown>(filePath: string) => Promise<T>;
readJsonFileSync?: <T = unknown>(filePath: string) => T;
renameFile?: (from: string, to: string) => Promise<void>;
requestArrayBuffer?: (
url: string,
options?: {
@ -111,6 +112,18 @@ export async function writeBangDreamJsonFile(filePath: string, data: unknown) {
await runtimeIo.writeJsonFile(filePath, data);
}
export async function writeBangDreamJsonFileAtomic(
filePath: string,
data: unknown,
) {
if (!runtimeIo.writeJsonFile || !runtimeIo.renameFile) {
throw new Error('BangDream JSON 原子写入器未初始化');
}
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
await runtimeIo.writeJsonFile(tempPath, data);
await runtimeIo.renameFile(tempPath, filePath);
}
export async function sleepBangDreamRuntime(ms: number) {
if (ms <= 0) return;
if (runtimeIo.sleep) {

View File

@ -0,0 +1,110 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import {
BANGDREAM_BESTDORI_MAIN_DATA_KEYS,
createBestdoriMainDataSyncTask,
} from '../../../../../src/modules/qqbot/plugins/bangdream/src/application/tasks';
import { BANGDREAM_TSUGU_ENV_KEYS } from '../../../../../src/modules/qqbot/plugins/bangdream/src/config/runtime-options';
import { configureBangDreamRuntimeIo } from '../../../../../src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io';
describe('BangDream Bestdori main-data sync task', () => {
let cacheRoot: string;
let consoleInfoSpy: jest.SpyInstance;
beforeEach(() => {
cacheRoot = mkdtempSync(join(tmpdir(), 'bangdream-sync-'));
consoleInfoSpy = jest.spyOn(console, 'info').mockImplementation();
});
afterEach(() => {
consoleInfoSpy.mockRestore();
rmSync(cacheRoot, { force: true, recursive: true });
});
it('downloads main JSON data, writes cache atomically, and returns safe summary', async () => {
const requestedUrls: string[] = [];
configureBangDreamRuntimeIo({
getConfig: (key) =>
key === BANGDREAM_TSUGU_ENV_KEYS.cacheRoot ? cacheRoot : undefined,
readJsonFile: async (filePath) =>
JSON.parse(readFileSync(filePath, 'utf8')),
readExcelRows: async () => [],
renameFile: async (from, to) => {
mkdirSync(dirname(to), { recursive: true });
renameSync(from, to);
},
requestJson: async <T = unknown>(url: string) => {
requestedUrls.push(`${url}`);
return { body: { ok: true, url } as T };
},
writeJsonFile: async (filePath, data) => {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(data)}\n`);
},
});
const task = createBestdoriMainDataSyncTask();
const output = await task.execute({ keys: ['songs', 'meta'] });
expect(output).toMatchObject({
failedCount: 0,
successCount: 2,
syncedKeys: ['songs', 'meta'],
});
expect(
readFileSync(join(cacheRoot, 'bestdori', 'songs.json'), 'utf8'),
).toContain('"ok":true');
expect(
readFileSync(join(cacheRoot, 'bestdori', 'meta.json'), 'utf8'),
).toContain('"ok":true');
expect(requestedUrls).toHaveLength(2);
expect(JSON.stringify(output)).not.toContain('/api/songs/all');
});
it('keeps existing cache file when one key fails', async () => {
const metaCachePath = join(cacheRoot, 'bestdori', 'meta.json');
mkdirSync(dirname(metaCachePath), { recursive: true });
writeFileSync(metaCachePath, '{"previous":true}\n');
configureBangDreamRuntimeIo({
getConfig: (key) =>
key === BANGDREAM_TSUGU_ENV_KEYS.cacheRoot ? cacheRoot : undefined,
readJsonFile: async (filePath) =>
JSON.parse(readFileSync(filePath, 'utf8')),
readExcelRows: async () => [],
renameFile: async (from, to) => {
mkdirSync(dirname(to), { recursive: true });
renameSync(from, to);
},
requestJson: async <T = unknown>(url: string) => {
if (`${url}`.includes('/api/songs/meta/')) {
throw new Error('network failed');
}
return { body: { ok: true } as T };
},
writeJsonFile: async (filePath, data) => {
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(data)}\n`);
},
});
const task = createBestdoriMainDataSyncTask();
await expect(task.execute({ keys: ['songs', 'meta'] })).rejects.toThrow(
'BangDream Bestdori 主数据同步失败',
);
expect(readFileSync(metaCachePath, 'utf8')).toContain('"previous":true');
expect(existsSync(join(cacheRoot, 'bestdori', 'songs.json'))).toBe(true);
expect(BANGDREAM_BESTDORI_MAIN_DATA_KEYS).toEqual(
expect.arrayContaining(['songs', 'meta', 'cards', 'skills', 'events']),
);
});
});