diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index e80dd8f..5188cba 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -394,8 +394,9 @@ export interface TsuguHook { - 已新增 `tsugu/runtime/hook-registry.ts`,定义 `TsuguHookContext`、`TsuguHook`、`TsuguHookRegistry` 和默认 `TsuguLogHook`,命令执行日志包含 `operation`、`stage`、`handler`、`query`、`imageCount`、`durationMs` 和错误字符串。 - 已新增 `renderer/tsugu-application.service.ts` 作为 BangDream Tsugu 应用入口,统一执行主数据 ready 等待、operation registry 查找、handler 调度、hook 触发和错误字符串化。 - `QqbotBangDreamClientService` 已改为注入 `TsuguApplicationService`,不再直接调用 renderer;`QqbotBangDreamRendererService` 移除 operation key 调度,只保留字典刷新、健康检查、渲染选项解析和 15 个具体 handler。 +- 已新增 `tsugu/runtime/operation-pipeline.ts`,把主数据 ready、operation resolve、handler render、output hook 和 error hook 固定为 `TsuguOperationPipeline.run`,`TsuguApplicationService.execute` 只负责调用 pipeline。 - `scripts/bangdream-render-smoke.ps1` 已切到 `TsuguApplicationService`,本地 smoke 继续走真实应用入口,避免 facade 改造后验证脚本回退到旧链路。 -- 已新增 `hook-registry.spec.ts` 和 `tsugu-application.service.spec.ts`,覆盖 hook 顺序、错误 hook、应用入口执行、字典刷新和错误字符串化;本地 Phase 7 smoke 已生成 `phase7-hook-event-50.jpg`、`phase7-hook-cutoff-detail-100-50-cn.jpg`、`phase7-hook-cutoff-recent-100-50-cn.jpg`。 +- 已新增 `hook-registry.spec.ts`、`operation-pipeline.spec.ts` 和 `tsugu-application.service.spec.ts`,覆盖 hook 顺序、错误 hook、pipeline 成功/未知 operation/handler 错误、应用入口执行、字典刷新和错误字符串化;本地 Phase 7 smoke 已生成 `phase7-hook-event-50.jpg`、`phase7-hook-cutoff-detail-100-50-cn.jpg`、`phase7-hook-cutoff-recent-100-50-cn.jpg`、`phase7-pipeline-event-50.jpg`、`phase7-pipeline-cutoff-detail-100-50-cn.jpg`、`phase7-pipeline-cutoff-recent-100-50-cn.jpg`。 ## 文件迁移优先级 diff --git a/src/qqbot/plugins/bangDream/renderer/tsugu-application.service.ts b/src/qqbot/plugins/bangDream/renderer/tsugu-application.service.ts index 6ae8adf..e0950d1 100644 --- a/src/qqbot/plugins/bangDream/renderer/tsugu-application.service.ts +++ b/src/qqbot/plugins/bangDream/renderer/tsugu-application.service.ts @@ -7,21 +7,33 @@ import type { } from '../qqbot-bangdream.types'; import { waitForMainDataReady } from '../tsugu/models/main-data-store'; import { - createTsuguHookContext, createTsuguLogHook, TsuguHookRegistry, } from '../tsugu/runtime/hook-registry'; import { getBangDreamOperationDefinition } from '../tsugu/runtime/operation-registry'; +import { TsuguOperationPipeline } from '../tsugu/runtime/operation-pipeline'; import { QqbotBangDreamRendererService } from './qqbot-bangdream-renderer.service'; @Injectable() export class TsuguApplicationService implements OnApplicationBootstrap { private readonly hookRegistry = new TsuguHookRegistry([createTsuguLogHook()]); + private readonly operationPipeline: TsuguOperationPipeline; constructor( private readonly rendererService: QqbotBangDreamRendererService, private readonly toolsService: ToolsService, - ) {} + ) { + this.operationPipeline = new TsuguOperationPipeline({ + executeHandler: async (handlerName, input) => + await this.rendererService.executeOperationHandler(handlerName, input), + hookRegistry: this.hookRegistry, + normalizeError: (error) => + this.toolsService.getErrorMessage(error, 'BangDream 命令执行失败'), + resolveOperation: (operationKey) => + getBangDreamOperationDefinition(operationKey), + waitForReady: async () => await waitForMainDataReady(), + }); + } async onApplicationBootstrap() { await this.rendererService.refreshDictionaryCache(); @@ -35,40 +47,6 @@ export class TsuguApplicationService implements OnApplicationBootstrap { operationKey: QqbotBangDreamOperationKey, input: QqbotBangDreamCommandInput, ): Promise { - const context = createTsuguHookContext(operationKey, input); - await this.hookRegistry.beforeParse(context); - - try { - context.stage = 'mainData'; - await waitForMainDataReady(); - - context.stage = 'operation'; - const operation = getBangDreamOperationDefinition(operationKey); - if (!operation) { - throw new Error(`BangDream 插件能力不存在:${operationKey}`); - } - context.handlerName = operation.handlerName; - await this.hookRegistry.afterResolve(context); - - context.stage = 'handler'; - await this.hookRegistry.beforeRender(context); - const output = await this.rendererService.executeOperationHandler( - operation.handlerName, - input, - ); - - context.stage = 'output'; - context.imageCount = output.imageCount; - context.query = output.query || context.query; - await this.hookRegistry.afterOutput(context); - return output; - } catch (err) { - const message = this.toolsService.getErrorMessage( - err, - 'BangDream 命令执行失败', - ); - await this.hookRegistry.onError(context, message); - throw new Error(message); - } + return await this.operationPipeline.run(operationKey, input); } } diff --git a/src/qqbot/plugins/bangDream/tsugu/runtime/operation-pipeline.ts b/src/qqbot/plugins/bangDream/tsugu/runtime/operation-pipeline.ts new file mode 100644 index 0000000..3e32f3c --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/runtime/operation-pipeline.ts @@ -0,0 +1,71 @@ +import type { + QqbotBangDreamCommandInput, + QqbotBangDreamCommandOutput, + QqbotBangDreamOperationKey, +} from '../../qqbot-bangdream.types'; +import type { + QqbotBangDreamOperationHandlerName, + TsuguOperationDefinition, +} from './operation-registry'; +import { + createTsuguHookContext, + type TsuguHookRegistry, +} from './hook-registry'; + +export type TsuguOperationPipelineOptions = { + executeHandler: ( + handlerName: QqbotBangDreamOperationHandlerName, + input: QqbotBangDreamCommandInput, + ) => Promise; + hookRegistry: TsuguHookRegistry; + normalizeError: (error: unknown) => string; + resolveOperation: ( + operationKey: QqbotBangDreamOperationKey, + ) => TsuguOperationDefinition | undefined; + waitForReady: () => Promise; +}; + +/** + * 串联 Tsugu 命令执行阶段:准备主数据、解析 operation、调用渲染 handler、输出 hook。 + */ +export class TsuguOperationPipeline { + constructor(private readonly options: TsuguOperationPipelineOptions) {} + + async run( + operationKey: QqbotBangDreamOperationKey, + input: QqbotBangDreamCommandInput, + ) { + const context = createTsuguHookContext(operationKey, input); + await this.options.hookRegistry.beforeParse(context); + + try { + context.stage = 'mainData'; + await this.options.waitForReady(); + + context.stage = 'operation'; + const operation = this.options.resolveOperation(operationKey); + if (!operation) { + throw new Error(`BangDream 插件能力不存在:${operationKey}`); + } + context.handlerName = operation.handlerName; + await this.options.hookRegistry.afterResolve(context); + + context.stage = 'handler'; + await this.options.hookRegistry.beforeRender(context); + const output = await this.options.executeHandler( + operation.handlerName, + input, + ); + + context.stage = 'output'; + context.imageCount = output.imageCount; + context.query = output.query || context.query; + await this.options.hookRegistry.afterOutput(context); + return output; + } catch (error) { + const message = this.options.normalizeError(error); + await this.options.hookRegistry.onError(context, message); + throw new Error(message); + } + } +} diff --git a/test/qqbot/plugins/bangDream/tsugu/operation-pipeline.spec.ts b/test/qqbot/plugins/bangDream/tsugu/operation-pipeline.spec.ts new file mode 100644 index 0000000..25c98c3 --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/operation-pipeline.spec.ts @@ -0,0 +1,119 @@ +import { TsuguHookRegistry } from '@/qqbot/plugins/bangDream/tsugu/runtime/hook-registry'; +import { TsuguOperationPipeline } from '@/qqbot/plugins/bangDream/tsugu/runtime/operation-pipeline'; +import type { TsuguOperationDefinition } from '@/qqbot/plugins/bangDream/tsugu/runtime/operation-registry'; + +const songSearchOperation: TsuguOperationDefinition = { + description: '查询歌曲', + handlerName: 'searchSong', + key: 'bangdream.song.search', + name: '查曲', + onlineCommand: { + aliases: ['查曲'], + cooldownMs: 1500, + remark: '查询歌曲', + }, +}; + +describe('TsuguOperationPipeline', () => { + it('runs ready check, operation resolve, handler, and output hooks', async () => { + const events: string[] = []; + const hookRegistry = new TsuguHookRegistry([ + { + afterOutput: (context) => { + events.push(`afterOutput:${context.stage}:${context.imageCount}`); + }, + afterResolve: (context) => { + events.push(`afterResolve:${context.stage}:${context.handlerName}`); + }, + beforeParse: (context) => { + events.push(`beforeParse:${context.stage}:${context.query}`); + }, + beforeRender: (context) => { + events.push(`beforeRender:${context.stage}:${context.handlerName}`); + }, + name: 'recorder', + }, + ]); + const pipeline = new TsuguOperationPipeline({ + executeHandler: async (handlerName, input) => { + events.push(`handler:${handlerName}:${input.text}`); + return { + imageCount: 1, + operationKey: 'bangdream.song.search', + query: '夏祭り', + replyText: '[CQ:image,file=base64://base64-song-card]', + source: 'Tsugu BangDream Bot 内置源码', + }; + }, + hookRegistry, + normalizeError: (error) => `${error}`, + resolveOperation: () => songSearchOperation, + waitForReady: async () => { + events.push('ready'); + }, + }); + + const output = await pipeline.run('bangdream.song.search', { + text: '夏祭り', + }); + + expect(output.imageCount).toBe(1); + expect(events).toEqual([ + 'beforeParse:start:夏祭り', + 'ready', + 'afterResolve:operation:searchSong', + 'beforeRender:handler:searchSong', + 'handler:searchSong:夏祭り', + 'afterOutput:output:1', + ]); + }); + + it('normalizes unknown operation errors and emits error hook', async () => { + const errors: string[] = []; + const pipeline = new TsuguOperationPipeline({ + executeHandler: jest.fn(), + hookRegistry: new TsuguHookRegistry([ + { + name: 'error-recorder', + onError: (context, error) => { + errors.push(`${context.stage}:${error}`); + }, + }, + ]), + normalizeError: (error) => + error instanceof Error ? error.message : `${error}`, + resolveOperation: () => undefined, + waitForReady: jest.fn().mockResolvedValue(undefined), + }); + + await expect( + pipeline.run('bangdream.song.search', { text: '夏祭り' }), + ).rejects.toThrow('BangDream 插件能力不存在:bangdream.song.search'); + expect(errors).toEqual([ + 'operation:BangDream 插件能力不存在:bangdream.song.search', + ]); + }); + + it('normalizes handler errors at handler stage', async () => { + const errors: string[] = []; + const pipeline = new TsuguOperationPipeline({ + executeHandler: jest.fn().mockRejectedValue('图片渲染失败'), + hookRegistry: new TsuguHookRegistry([ + { + name: 'error-recorder', + onError: (context, error) => { + errors.push(`${context.stage}:${context.handlerName}:${error}`); + }, + }, + ]), + normalizeError: (error) => `${error}`, + resolveOperation: () => songSearchOperation, + waitForReady: jest.fn().mockResolvedValue(undefined), + }); + + await expect( + pipeline.run('bangdream.song.search', { text: '夏祭り' }), + ).rejects.toThrow('图片渲染失败'); + expect(errors).toEqual(['handler:searchSong:图片渲染失败']); + }); +});