feat: 增加BangDream主数据同步任务
This commit is contained in:
parent
62161bac8f
commit
0263b423c2
@ -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));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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": [
|
||||
{
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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')
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from './bestdori-main-data-sync.task';
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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']),
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user