refactor: 收口 BangDream Tsugu 执行流水线
This commit is contained in:
parent
4eec2d6d05
commit
5d97b8822a
@ -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`。
|
||||
|
||||
## 文件迁移优先级
|
||||
|
||||
|
||||
@ -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<QqbotBangDreamCommandOutput> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<QqbotBangDreamCommandOutput>;
|
||||
hookRegistry: TsuguHookRegistry;
|
||||
normalizeError: (error: unknown) => string;
|
||||
resolveOperation: (
|
||||
operationKey: QqbotBangDreamOperationKey,
|
||||
) => TsuguOperationDefinition | undefined;
|
||||
waitForReady: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 串联 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
119
test/qqbot/plugins/bangDream/tsugu/operation-pipeline.spec.ts
Normal file
119
test/qqbot/plugins/bangDream/tsugu/operation-pipeline.spec.ts
Normal file
@ -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:图片渲染失败']);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user