feat: 业务节点发生命周期事件并调用链式钩子

- 订阅已建 / 已改 / 已完成 / 已取消,下载完成 / 失败 / 移除,整理结束(统一 organization.changed 终态),
  刮削完成 / 失败,作品进入关注,元数据已刷新(任务调用方),季集结构切换,站点不可达 / 恢复:
  都在调用方事务内写 outbox,带幂等键
- 钩子挂点:name.recognize(resolveRelease / identifyParsed)、organize.rename 与 organize.intercept(planImport)、
  resource.selection(订阅重评)、subscription.completion.check(completeSubscription)
- 测试:各节点事件 payload 与去重、整理改名 / 拦截 / 出错不影响整理、名称识别改写、资源选择、订阅完成拦截

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 19:27:14 +08:00
1 parent 7aa5182f3d
commit 079c3eafe9
18 files changed
+1049 -29

No files matched your search

@@ -0,0 +1,45 @@
import type { SourceReleaseRow } from "@umt/db";
import { describe, expect, it } from "vitest";
import { HookRegistry } from "../../src/hooks/index.ts";
import { selectResources } from "../../src/modules/acquisition-organize/services/acquisition.ts";
// 链式钩子 resource.selection(ADR-006,对照 MP ChainEventType.ResourceSelection):订阅重评时调整候选顺序 / 过滤,
// 只能重排与删减,不能加入新候选。
const rel = (id: string, title: string) => ({ id, title, parsed: null }) as unknown as SourceReleaseRow;
const rels = [
rel("r1", "Show.S01E01.1080p.WEB-DL"),
rel("r2", "Show.S01E01.2160p.WEB-DL"),
rel("r3", "Show.S01E01.720p"),
];
const context = { mediaEntityId: "e", subscriptionId: "s", slotId: null };
const ctxOf = (hooks?: HookRegistry) => ({ d: { ctx: { hooks } } }) as never;
describe("resource.selection 钩子", () => {
it("没有钩子时原样返回同一个数组", async () => {
expect(await selectResources(ctxOf(), rels, context)).toBe(rels);
expect(await selectResources(ctxOf(new HookRegistry()), rels, context)).toBe(rels);
});
it("重排与过滤生效;不认识的 ID 忽略、重复的只取一次", async () => {
const hooks = new HookRegistry();
const seen: unknown[] = [];
hooks.forModule("test").rewrite("resource.selection", (cands, c) => {
seen.push({ ids: cands.map((x) => x.sourceReleaseId), c });
const by = new Map(cands.map((x) => [x.sourceReleaseId, x]));
// 2160p 优先、去掉 720p,夹带一个不存在的候选和一个重复
return [by.get("r2")!, { ...by.get("r1")!, sourceReleaseId: "intruder" }, by.get("r1")!, by.get("r2")!];
});
const out = await selectResources(ctxOf(hooks), rels, context);
expect(out.map((r) => r.id)).toEqual(["r2", "r1"]);
expect(seen).toEqual([{ ids: ["r1", "r2", "r3"], c: context }]);
});
it("钩子出错时沿用原顺序", async () => {
const hooks = new HookRegistry();
hooks.forModule("test").rewrite("resource.selection", () => {
throw new Error("坏了");
});
expect((await selectResources(ctxOf(hooks), rels, context)).map((r) => r.id)).toEqual(["r1", "r2", "r3"]);
});
});