diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker.thread.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker.thread.ts index 07af9b9..f3cfa03 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker.thread.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/builtin-plugin-worker.thread.ts @@ -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('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)); + }, }; } diff --git a/src/modules/qqbot/plugins/bangdream/plugin.json b/src/modules/qqbot/plugins/bangdream/plugin.json index cea5ba7..1754ffd 100644 --- a/src/modules/qqbot/plugins/bangdream/plugin.json +++ b/src/modules/qqbot/plugins/bangdream/plugin.json @@ -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": [ { diff --git a/src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache.ts b/src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache.ts index 878e79f..85245e8 100644 --- a/src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache.ts +++ b/src/modules/qqbot/plugins/bangdream/src/application/catalog/bangdream-catalog-cache.ts @@ -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; diff --git a/src/modules/qqbot/plugins/bangdream/src/application/tasks/bestdori-main-data-sync.task.ts b/src/modules/qqbot/plugins/bangdream/src/application/tasks/bestdori-main-data-sync.task.ts new file mode 100644 index 0000000..365b809 --- /dev/null +++ b/src/modules/qqbot/plugins/bangdream/src/application/tasks/bestdori-main-data-sync.task.ts @@ -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, +): Promise { + 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(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') + ); +} diff --git a/src/modules/qqbot/plugins/bangdream/src/application/tasks/index.ts b/src/modules/qqbot/plugins/bangdream/src/application/tasks/index.ts new file mode 100644 index 0000000..42df65e --- /dev/null +++ b/src/modules/qqbot/plugins/bangdream/src/application/tasks/index.ts @@ -0,0 +1 @@ +export * from './bestdori-main-data-sync.task'; diff --git a/src/modules/qqbot/plugins/bangdream/src/index.ts b/src/modules/qqbot/plugins/bangdream/src/index.ts index eeede24..7d07f2b 100644 --- a/src/modules/qqbot/plugins/bangdream/src/index.ts +++ b/src/modules/qqbot/plugins/bangdream/src/index.ts @@ -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', }; } diff --git a/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io.ts b/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io.ts index 55ac5a8..ec313d9 100644 --- a/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io.ts +++ b/src/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io.ts @@ -6,6 +6,7 @@ export type BangDreamRuntimeIo = { ) => Promise; readJsonFile?: (filePath: string) => Promise; readJsonFileSync?: (filePath: string) => T; + renameFile?: (from: string, to: string) => Promise; 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) { diff --git a/test/modules/qqbot/plugins/bangdream/bestdori-main-data-sync.task.spec.ts b/test/modules/qqbot/plugins/bangdream/bestdori-main-data-sync.task.spec.ts new file mode 100644 index 0000000..473e5d8 --- /dev/null +++ b/test/modules/qqbot/plugins/bangdream/bestdori-main-data-sync.task.spec.ts @@ -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 (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 (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']), + ); + }); +});