From d6135a20124941014a8e6c925232aadeae868d37 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 4 Jul 2026 10:23:40 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=99=8D=E7=BA=A7BangDream=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=81=A5=E5=BA=B7=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/plugin-platform.service.ts | 33 +++- .../qqbot/plugins/bangdream/src/index.ts | 42 +++-- .../plugin-lifecycle-runtime.spec.ts | 170 ++++++++++++++++++ .../bangdream-package-entry.spec.ts | 19 +- 4 files changed, 243 insertions(+), 21 deletions(-) diff --git a/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts b/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts index 88d8bef..8edce1b 100644 --- a/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts +++ b/src/modules/qqbot/plugin-platform/application/plugin-platform.service.ts @@ -843,13 +843,42 @@ export class QqbotPluginPlatformService persistedInstallation, ); - await this.startWorker(installation, version); - startedCount += 1; + try { + await this.startWorker(installation, version); + startedCount += 1; + } catch (error) { + await this.recordBuiltinWorkerStartFailure(installation, error); + } } return startedCount; } + /** + * Records one failed built-in worker boot without blocking other built-in plugin packages. + * @param installation - Persisted installation whose worker failed during module startup. + * @param error - Startup error from worker load, activate, or health checks. + */ + private async recordBuiltinWorkerStartFailure( + installation: QqbotPluginInstallation, + error: unknown, + ) { + const message = error instanceof Error ? error.message : `${error}`; + await this.updateInstallationRuntime( + installation, + 'enabled', + 'unhealthy', + ); + await this.recordRuntimeEvent( + installation, + 'builtin-start-failed', + 'error', + { + message, + }, + ); + } + /** * Ensures descriptor-discovered built-in package state is persisted. * @param descriptor - Manifest descriptor discovered from a controlled plugin package root. diff --git a/src/modules/qqbot/plugins/bangdream/src/index.ts b/src/modules/qqbot/plugins/bangdream/src/index.ts index 2b8c6fc..c42feeb 100644 --- a/src/modules/qqbot/plugins/bangdream/src/index.ts +++ b/src/modules/qqbot/plugins/bangdream/src/index.ts @@ -137,6 +137,28 @@ function buildBangDreamRuntimePlugin( (error instanceof Error ? error.message : `${error}`) || 'BangDream 命令执行失败'); + /** + * Checks BangDream runtime health without turning external catalog outages into worker startup failures. + * @returns Health payload consumed by the plugin platform and Admin health view. + */ + const checkBangDreamHealth = async () => { + const checkedAt = formatBangDreamCheckedAt(new Date()); + try { + await context.checkHealth(); + return { + checkedAt, + message: 'BangDream 插件可用', + status: 'healthy', + }; + } catch (error) { + return { + checkedAt, + message: normalizeError(error) || 'BangDream 插件不可用', + status: 'degraded', + }; + } + }; + /** * 执行 BangDream 插件局部步骤。 * @param operationKey - operationKey 输入;影响 executeOperation 的返回值。 @@ -176,27 +198,11 @@ function buildBangDreamRuntimePlugin( /** * 执行 BangDream回调。 */ - health: () => context.checkHealth(), + health: checkBangDreamHealth, /** * 执行 BangDream回调。 */ - healthCheck: async () => { - const checkedAt = formatBangDreamCheckedAt(new Date()); - try { - await context.checkHealth(); - return { - checkedAt, - message: 'BangDream 插件可用', - status: 'healthy', - }; - } catch (error) { - return { - checkedAt, - message: normalizeError(error) || 'BangDream 插件不可用', - status: 'degraded', - }; - } - }, + healthCheck: checkBangDreamHealth, key: options.pluginKey || 'bangdream', legacyKeys: options.legacyAliases, name: options.name || 'BangDream 查询', diff --git a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts index f3eb794..f14de3f 100644 --- a/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-lifecycle-runtime.spec.ts @@ -851,6 +851,176 @@ describe('QQBot plugin platform lifecycle runtime contract', () => { expect(worker.executeOperation).not.toHaveBeenCalled(); }); + it('continues starting other built-in workers when one worker health check fails', async () => { + /** + * 创建测试 manifest。 + * @param pluginKey - 插件 key;用于区分当前被发现的内置插件包。 + * @returns 可被插件平台解析的内置插件 manifest。 + */ + const createManifest = (pluginKey: string) => ({ + assets: [], + configSchema: {}, + entry: 'src/index.ts', + events: [], + legacyAliases: [], + migrations: [], + minApiSdkVersion: '1.0.0', + name: pluginKey, + operations: [ + { + handlerName: 'echo', + key: `${pluginKey}.echo`, + name: 'Echo', + permissions: [], + timeoutMs: 123, + }, + ], + permissions: [], + pluginKey, + runtime: { + maxConcurrency: 1, + memoryMb: 128, + timeoutMs: 456, + workerType: 'node-worker', + }, + version: '0.1.0', + }); + const failingManifest = createManifest('failing-plugin'); + const healthyManifest = createManifest('healthy-plugin'); + const savedPlugins: any[] = []; + const savedVersions: any[] = []; + const savedInstallations: any[] = []; + const runtimeEvents: any[] = []; + const pluginRepository = { + find: jest.fn(async () => savedPlugins), + findAndCount: jest.fn(async () => [savedPlugins, savedPlugins.length]), + findOne: jest.fn(async ({ where }: any) => + savedPlugins.find((plugin) => plugin.id === where?.id), + ), + save: jest.fn(async (value) => { + const row = { id: `plugin-${value.pluginKey}`, ...value }; + savedPlugins.push(row); + return row; + }), + update: jest.fn(async () => ({ affected: 1 })), + }; + const versionRepository = { + findOne: jest.fn(async ({ where }: any) => + savedVersions.find((version) => version.id === where?.id), + ), + save: jest.fn(async (value) => { + const row = { id: `version-${value.pluginId}`, ...value }; + savedVersions.push(row); + return row; + }), + update: jest.fn(async () => ({ affected: 1 })), + }; + const installationRepository = { + find: jest.fn(async () => savedInstallations), + findAndCount: jest.fn(async () => [ + savedInstallations, + savedInstallations.length, + ]), + findOne: jest.fn(async ({ where }: any) => + savedInstallations.find( + (installation) => installation.id === where?.id, + ), + ), + save: jest.fn(async (value) => { + const row = { id: `installation-${value.pluginId}`, ...value }; + savedInstallations.push(row); + return row; + }), + update: jest.fn(async () => ({ affected: 1 })), + }; + const runtimeEventRepository = { + save: jest.fn(async (value) => { + runtimeEvents.push(value); + return value; + }), + }; + const failingWorker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + drainRuntimeEvents: jest.fn(() => []), + executeOperation: jest.fn(), + handleEvent: jest.fn(), + health: jest.fn(async () => { + throw new Error('Bestdori unavailable'); + }), + load: jest.fn(async () => ({ ok: true })), + }; + const healthyWorker = { + activate: jest.fn(async () => ({ ok: true })), + deactivate: jest.fn(async () => ({ ok: true })), + dispose: jest.fn(async () => undefined), + drainRuntimeEvents: jest.fn(() => []), + executeOperation: jest.fn(async () => ({ replyText: 'ok' })), + handleEvent: jest.fn(async () => true), + health: jest.fn(async () => ({ ok: true })), + load: jest.fn(async () => ({ ok: true })), + }; + const runtimeFactory = { + create: jest.fn((_installation, version) => + version.manifestJson.pluginKey === 'failing-plugin' + ? failingWorker + : healthyWorker, + ), + }; + const packageSource = { + discoverPackages: jest.fn(async () => + [failingManifest, healthyManifest].map((manifest) => ({ + entry: manifest.entry, + entryFile: `D:/plugins/${manifest.pluginKey}/src/index.ts`, + manifest, + packageRoot: `D:/plugins/${manifest.pluginKey}`, + pluginKey: manifest.pluginKey, + })), + ), + }; + const service = new (QqbotPluginPlatformService as any)( + pluginRepository, + versionRepository, + installationRepository, + { update: jest.fn(async () => ({ affected: 1 })) }, + { update: jest.fn(async () => ({ affected: 1 })) }, + { find: jest.fn(async () => []) }, + {}, + {}, + runtimeEventRepository, + undefined, + runtimeFactory, + undefined, + undefined, + undefined, + packageSource, + ) as QqbotPluginPlatformService; + + await expect(service.onModuleInit()).resolves.toBeUndefined(); + + expect(failingWorker.dispose).toHaveBeenCalled(); + expect(healthyWorker.health).toHaveBeenCalled(); + await expect( + service.executeOperation({ + input: {}, + operationKey: 'healthy-plugin.echo', + pluginKey: 'healthy-plugin', + }), + ).resolves.toEqual({ replyText: 'ok' }); + expect(runtimeEvents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + eventType: 'builtin-start-failed', + level: 'error', + safeSummary: expect.objectContaining({ + message: 'Bestdori unavailable', + }), + }), + ]), + ); + }); + it('persists built-in installations before syncing manifest tasks', async () => { const manifest = { assets: [], diff --git a/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts b/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts index 0295ea2..4bd8d7b 100644 --- a/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts +++ b/test/qqbot/plugins/bangdream/application/bangdream-package-entry.spec.ts @@ -214,7 +214,10 @@ describe('BangDream package entry', () => { }); await plugin.activate(); - await expect(plugin.health()).resolves.toBe(true); + await expect(plugin.health()).resolves.toMatchObject({ + message: 'BangDream 插件可用', + status: 'healthy', + }); await expect( plugin.executeOperation('bangdream.song.search', { text: '夏祭り' }), ).resolves.toMatchObject({ @@ -236,6 +239,20 @@ describe('BangDream package entry', () => { expect(manifestOperations).toHaveLength(15); }); + it('reports degraded health when catalog health fails instead of throwing', async () => { + mockContext.checkHealth.mockRejectedValueOnce( + new Error('Bestdori unavailable'), + ); + const plugin = createPlugin({ + operations: manifestOperations, + }); + + await expect(plugin.health()).resolves.toMatchObject({ + message: 'Bestdori unavailable', + status: 'degraded', + }); + }); + it('normalizes operation errors without an application-service wrapper', async () => { const plugin = createPlugin({ /**