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,314 @@
import { type EventType, isEventType, LIFECYCLE_EVENTS, parseEventPayload } from "@umt/contracts";
import { OutboxRelay } from "@umt/tasks";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { collectOutboxRoutes } from "../../src/context.ts";
import { HookRegistry } from "../../src/hooks/index.ts";
import {
emitDownloadLifecycle,
emitScrapeFinished,
} from "../../src/modules/acquisition-organize/services/lifecycle-events.ts";
import { emitRecord } from "../../src/modules/acquisition-organize/services/organize.ts";
import { completeSubscription } from "../../src/modules/acquisition-organize/services/subscriptions.ts";
import { SERVER_MODULES } from "../../src/modules/index.ts";
import {
emitMetadataRefreshed,
emitSiteReachability,
} from "../../src/modules/sources-identity/service/lifecycle-events.ts";
import type { ServerContext, ServerModule } from "../../src/modules/types.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "../acquisition-organize/harness.ts";
// 生命周期广播事件(ADR-006):各业务节点在事务内写 outbox,payload 过 contracts schema,幂等键防重复。
const hasPg = await pgAvailable();
describe.skipIf(!hasPg)("生命周期广播事件(真实 PG)", () => {
let h: Harness;
let base: Awaited<ReturnType<typeof seedBase>>;
/** 某类事件的全部行;顺带确认 payload 过 schema */
const events = async (type: EventType) => {
const rows = await h.outbox(type);
for (const r of rows) expect(parseEventPayload(type, r.payload), `${type} payload`).not.toBeNull();
return rows;
};
beforeAll(async () => {
h = await createHarness("lifecycle");
base = await seedBase(h.db, {
libraryPath: "/tmp/umt-lc-lib",
cachePath: "/tmp/umt-lc-dl",
qbUrl: "http://127.0.0.1:9",
});
}, 60_000);
afterAll(async () => {
await h?.close();
});
it("清单里的事件都是已登记类型", () => {
for (const e of LIFECYCLE_EVENTS) expect(isEventType(e.type)).toBe(true);
});
it("订阅:已建(并发作品进入关注)/ 已改 / 暂停也算已改 / 删除发已取消", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "订阅事件片", year: 2024 });
const created = await h.api("POST", "/subscriptions", { mediaEntityId: e, mediaKind: "movie" });
expect(created.status).toBe(201);
const id = created.body.data.id as string;
expect((await events("subscription.created")).filter((r) => r.payload.subscriptionId === id)).toEqual([
expect.objectContaining({
payload: { subscriptionId: id, mediaEntityId: e, mediaKind: "movie", origin: "user", status: "active" },
idempotency_key: `subscription.created/${id}`,
}),
]);
expect((await events("media.watched")).find((r) => r.payload.mediaEntityId === e)).toMatchObject({
payload: { mediaEntityId: e, reason: "subscription" },
idempotency_key: `media.watched/${e}/subscription/${id}`,
});
expect((await h.api("PATCH", `/subscriptions/${id}`, { priority: "new_work" })).status).toBe(200);
expect((await h.api("POST", `/subscriptions/${id}/pause`)).status).toBe(200);
const updated = (await events("subscription.updated")).filter((r) => r.payload.subscriptionId === id);
expect(updated.map((r) => [r.payload.changes, r.payload.status])).toEqual([
[["priority"], "active"],
[["status"], "paused"],
]);
expect((await h.api("POST", "/subscriptions/batch", { ids: [id], action: "delete" })).status).toBe(200);
expect((await events("subscription.cancelled")).filter((r) => r.payload.subscriptionId === id)).toEqual([
expect.objectContaining({ payload: expect.objectContaining({ reason: "user_deleted" }) }),
]);
});
it("订阅完成:发已完成(按历史行去重);钩子 subscription.completion.check 拒绝时不完成、不发事件", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "完成判定片", year: 2023 });
const sub = await h.db
.insertInto("subscription")
.values({ media_entity_id: e, media_kind: "movie", status: "active" })
.returningAll()
.executeTakeFirstOrThrow();
const hooks = new HookRegistry();
const seen: unknown[] = [];
hooks.forModule("test").intercept("subscription.completion.check", (c) => {
seen.push(c);
return "还要等导演剪辑版";
});
h.ctx.hooks = hooks;
try {
const done = await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"));
expect(done).toBe(false);
} finally {
h.ctx.hooks = undefined;
}
expect(seen).toEqual([
{ subscriptionId: sub.id, mediaEntityId: e, mediaKind: "movie", reason: "slot_satisfied", progress: {} },
]);
const still = await h.db.selectFrom("subscription").select("status").where("id", "=", sub.id).executeTakeFirst();
expect(still?.status).toBe("active");
expect((await events("subscription.completed")).filter((r) => r.payload.subscriptionId === sub.id)).toEqual([]);
expect(await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"))).toBe(true);
// 已完成的再判一次不会重复发
expect(await h.db.transaction().execute((trx) => completeSubscription(h.d, trx, sub, "slot_satisfied"))).toBe(
false,
);
const completed = (await events("subscription.completed")).filter((r) => r.payload.subscriptionId === sub.id);
expect(completed).toHaveLength(1);
expect(completed[0]!.payload.reason).toBe("slot_satisfied");
expect(completed[0]!.idempotency_key).toMatch(new RegExp(`^subscription\\.completed/${sub.id}/`));
});
it("整理结束:终态才发 organization.finished,同一次状态变化只发一次;进行中不发", async () => {
const e = await seedEntity(h.db, { kind: "movie", title: "整理事件片", year: 2022 });
const rec = await h.db
.insertInto("organization_record")
.values({ trigger: "auto", actor_type: "system", action: "copy", status: "running", media_entity_id: e })
.returning("id")
.executeTakeFirstOrThrow();
await emitRecord(h.d, h.db, rec.id);
expect((await events("organization.finished")).filter((r) => r.payload.recordId === rec.id)).toEqual([]);
await h.db.updateTable("organization_record").set({ status: "succeeded" }).where("id", "=", rec.id).execute();
await emitRecord(h.d, h.db, rec.id);
await emitRecord(h.d, h.db, rec.id); // 重放:同一 change_seq
const fin = (await events("organization.finished")).filter((r) => r.payload.recordId === rec.id);
expect(fin).toHaveLength(1);
expect(fin[0]!.payload).toMatchObject({
outcome: "succeeded",
action: "copy",
trigger: "auto",
mediaEntityId: e,
downloadTaskId: null,
});
expect(fin[0]!.idempotency_key).toBe(`organization.finished/${rec.id}/${fin[0]!.payload.changeSeq}`);
// organization.changed 照旧每次都发
expect((await events("organization.changed")).filter((r) => r.payload.recordId === rec.id)).toHaveLength(3);
// 重新整理后失败:新的状态变化再发一条
await h.db
.updateTable("organization_record")
.set({ status: "failed", reason: "target_exists" })
.where("id", "=", rec.id)
.execute();
await emitRecord(h.d, h.db, rec.id);
const again = (await events("organization.finished")).filter((r) => r.payload.recordId === rec.id);
expect(again.map((r) => [r.payload.outcome, r.payload.reason])).toEqual([
["succeeded", null],
["failed", "target_exists"],
]);
});
it("下载:完成 / 失败 / 移除各发一次(按任务行代数去重),其它状态不发", async () => {
const dt = await h.db
.insertInto("download_task")
.values({ instance_id: base.instanceId, torrent_hash: "a".repeat(40), name: "Some.Movie.2024.mkv" })
.returning("id")
.executeTakeFirstOrThrow();
await emitDownloadLifecycle(h.d, h.db, dt.id, "queued", "downloading");
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "completed");
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "completed");
await emitDownloadLifecycle(h.d, h.db, dt.id, "completed", "removed");
const done = (await events("download.completed")).filter((r) => r.payload.downloadTaskId === dt.id);
expect(done).toEqual([
expect.objectContaining({
payload: {
downloadTaskId: dt.id,
planId: null,
mediaEntityId: null,
name: "Some.Movie.2024.mkv",
generation: 0,
},
idempotency_key: `download.completed/${dt.id}/g0`,
}),
]);
expect((await events("download.removed")).filter((r) => r.payload.downloadTaskId === dt.id)).toEqual([
expect.objectContaining({ payload: expect.objectContaining({ from: "completed" }) }),
]);
// 复活后(代数 +1)再完成会再发
await h.db.updateTable("download_task").set({ generation: 1 }).where("id", "=", dt.id).execute();
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "completed");
await emitDownloadLifecycle(h.d, h.db, dt.id, "downloading", "failed");
expect((await events("download.completed")).filter((r) => r.payload.downloadTaskId === dt.id)).toHaveLength(2);
expect((await events("download.failed")).filter((r) => r.payload.downloadTaskId === dt.id)).toHaveLength(1);
});
it("刮削:失败发 scrape.failed,成功发 scrape.completed", async () => {
const lib = await h.db.selectFrom("library_root").select("id").executeTakeFirstOrThrow();
const mk = async (status: "failed" | "succeeded") =>
h.db
.insertInto("scrape_record")
.values({
root_id: lib.id,
root_path: "/tmp/umt-lc-lib",
status,
reason: status === "failed" ? "scrape_no_metadata" : null,
written: status === "failed" ? 0 : 3,
finished_at: new Date(),
})
.returningAll()
.executeTakeFirstOrThrow();
const bad = await mk("failed");
const ok = await mk("succeeded");
await emitScrapeFinished(h.d, bad);
await emitScrapeFinished(h.d, bad);
await emitScrapeFinished(h.d, ok);
expect((await events("scrape.failed")).map((r) => r.payload)).toEqual([
expect.objectContaining({ scrapeRecordId: bad.id, reason: "scrape_no_metadata", status: "failed" }),
]);
expect((await events("scrape.completed")).map((r) => r.payload)).toEqual([
expect.objectContaining({ scrapeRecordId: ok.id, written: 3, status: "succeeded" }),
]);
});
it("站点不可达 / 恢复、元数据已刷新:payload 合法,带键的按键去重", async () => {
await emitSiteReachability(h.ctx, h.db, base.siteId, {
reachable: false,
reason: "auth_expired",
message: "登录失效",
});
await emitSiteReachability(
h.ctx,
h.db,
base.siteId,
{ reachable: true, reason: "health_check" },
"site.recovered/x",
);
await emitSiteReachability(
h.ctx,
h.db,
base.siteId,
{ reachable: true, reason: "health_check" },
"site.recovered/x",
);
expect((await events("site.unreachable")).map((r) => r.payload)).toEqual([
{ siteId: base.siteId, reason: "auth_expired", message: "登录失效" },
]);
expect(await events("site.recovered")).toHaveLength(1);
const e = await seedEntity(h.db, { kind: "series", title: "刷新剧", year: 2021 });
for (let i = 0; i < 2; i++)
await emitMetadataRefreshed(h.ctx, h.db, {
mediaEntityId: e,
full: true,
changedFields: ["overview"],
runId: "run-1",
});
expect((await events("media.metadata_refreshed")).map((r) => r.payload)).toEqual([
{ mediaEntityId: e, full: true, changedFields: ["overview"] },
]);
});
it("补字幕订阅整理结束:中继投递时补字幕与其它订阅方都收到,入库类整理被认作补字幕候选", async () => {
await h.db.deleteFrom("outbox_event").execute();
const e = await seedEntity(h.db, { kind: "movie", title: "字幕订阅片", year: 2020 });
const recs = [];
for (const action of ["copy", "trash"] as const) {
const r = await h.db
.insertInto("organization_record")
.values({ trigger: "auto", actor_type: "system", action, status: "succeeded", media_entity_id: e })
.returning("id")
.executeTakeFirstOrThrow();
await emitRecord(h.d, h.db, r.id);
recs.push(r.id);
}
const debug = vi.fn();
const ctx = { ...h.ctx, logger: { debug, warn: vi.fn() } } as unknown as ServerContext;
const other: ServerModule = {
name: "test-other",
registerRoutes() {},
registerTasks() {},
outboxRoutes: () => ({
"organization.finished": (ev) => ({
task: "otherTask",
input: { id: (ev.payload as { recordId: string }).recordId },
}),
}),
};
const enqueued: { task: string; key: string }[] = [];
const relay = new OutboxRelay({
db: h.db,
engine: {
enqueue: async (task, _input, o) => {
enqueued.push({ task, key: o.idempotencyKey });
return { runId: o.idempotencyKey, deduplicated: false };
},
},
routes: collectOutboxRoutes([...SERVER_MODULES, other], ctx),
});
await relay.pollOnce();
// 补字幕只认复制 / 移动 / 原地入库(进回收站不算),自动补字幕还没做,不建任务
expect(debug.mock.calls.map((c) => c[0])).toEqual([{ recordId: recs[0], mediaEntityId: e }]);
expect(enqueued.map((x) => x.task)).toEqual(["otherTask", "otherTask"]);
const pending = await h.db
.selectFrom("outbox_event")
.select(["type", "status"])
.where("type", "=", "organization.finished")
.execute();
expect(pending.map((p) => p.status)).toEqual(["dispatched", "dispatched"]);
});
it("payload 不合法直接抛错,不写 outbox", async () => {
await expect(h.ctx.outbox.emit(h.db, "organization.finished", { recordId: "x" } as never)).rejects.toThrow(
/不符合 contracts/,
);
});
});
@@ -0,0 +1,68 @@
import { parseRelease } from "@umt/domain";
import { createMockTmdb, type MockTmdb, TMDB_FIXTURES } from "@umt/testkit";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { HookRegistry } from "../../src/hooks/index.ts";
import { resolveRelease } from "../../src/modules/sources-identity/service/resolve.ts";
import { createHarness, type Harness, pgAvailable, seedSite } from "../sources-identity/harness.ts";
// 链式钩子 name.recognize(ADR-006,对照 MP ChainEventType.NameRecognize):资源标题解析之后、召回之前改写解析结果。
const up = await pgAvailable();
const GARBLED = "Zhuo.Wu.Ming.2023.2160p.UHD.BluRay.REMUX.HDR.HEVC-FGT";
describe.skipIf(!up)("name.recognize 钩子改写解析结果", () => {
let h: Harness;
let tmdb: MockTmdb;
let siteId: string;
let n = 0;
const release = async (title: string) =>
h.db
.insertInto("source_release")
.values({ site_id: siteId, site_release_id: String(910000 + ++n), title })
.returningAll()
.executeTakeFirstOrThrow();
beforeAll(async () => {
tmdb = await createMockTmdb({ fixtures: TMDB_FIXTURES });
h = await createHarness("name_hook", { tmdbUrl: tmdb.url });
siteId = (await seedSite(h, { baseUrl: "https://hook.example/" })).site.id;
await h.db
.insertInto("settings")
.values({ key: "identity", value: JSON.stringify({ eagerTmdbLookup: true }) } as never)
.execute();
});
afterEach(() => {
h.ctx.hooks = undefined;
});
afterAll(async () => {
await h?.close();
await tmdb?.close();
});
it("没有钩子:认不出的标题照常不自动识别", async () => {
const r = await release(GARBLED);
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
expect(res.outcome).not.toBe("auto");
});
it("钩子按站点别名改写解析结果后自动识别;上下文带原标题与来源资源", async () => {
const seen: unknown[] = [];
const hooks = new HookRegistry();
hooks.forModule("test").rewrite("name.recognize", (p, c) => {
seen.push({ title: c.title, sourceReleaseId: c.sourceReleaseId, trigger: c.trigger });
return c.title.startsWith("Zhuo.Wu.Ming") ? parseRelease(c.title.replace("Zhuo.Wu.Ming", "Oppenheimer")) : p;
});
h.ctx.hooks = hooks;
const r = await release(GARBLED);
const res = await resolveRelease(h.rt, { sourceReleaseId: r.id });
expect(res.outcome).toBe("auto");
const e = await h.db
.selectFrom("external_identity")
.select("external_id")
.where("media_entity_id", "=", res.mediaEntityId!)
.where("provider", "=", "tmdb")
.executeTakeFirstOrThrow();
expect(e.external_id).toBe("872585");
expect(seen).toEqual([{ title: GARBLED, sourceReleaseId: r.id, trigger: "observed" }]);
});
});
@@ -0,0 +1,112 @@
import { existsSync } from "node:fs";
import { createTempTree, type TempTree } from "@umt/testkit";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { HookRegistry } from "../../src/hooks/index.ts";
import { executeRecord, planImport } from "../../src/modules/acquisition-organize/services/organize.ts";
import { createHarness, type Harness, pgAvailable, seedBase, seedEntity } from "../acquisition-organize/harness.ts";
// 整理上的链式钩子(ADR-006):organize.rename 改写目标文件名(字幕跟着改)、organize.intercept 拒绝整理;
// 钩子出错不影响整理。整理结束发 organization.finished。
const hasPg = await pgAvailable();
const SIZE = 200_000;
const DIR = "Inception.2010.1080p.BluRay.x264-GRP";
const VIDEO = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.mkv`;
const SUB = `${DIR}/Inception.2010.1080p.BluRay.x264-GRP.chs.srt`;
describe.skipIf(!hasPg)("整理钩子:改名与拦截(真实 PG + 临时目录)", () => {
let h: Harness;
let lib: TempTree;
let src: TempTree;
let entity = "";
const req = () => ({
sourcePaths: [src.path(DIR)],
mediaEntityId: entity,
action: "copy" as const,
trigger: "manual" as const,
actorType: "user" as const,
});
beforeAll(async () => {
lib = await createTempTree({}, { sentinel: true, prefix: "umt-hklib-" });
src = await createTempTree({ [VIDEO]: SIZE, [SUB]: 2_000 }, { prefix: "umt-hksrc-" });
h = await createHarness("organize_hooks");
await seedBase(h.db, { libraryPath: lib.root, cachePath: src.root, qbUrl: "http://127.0.0.1:9" });
await h.setSettings("organize", { scrapeAfterImport: false });
entity = await seedEntity(h.db, { kind: "movie", title: "盗梦空间", year: 2010 });
await h.db.updateTable("media_entity").set({ original_title: "Inception" }).where("id", "=", entity).execute();
}, 60_000);
afterEach(() => {
h.ctx.hooks = undefined;
});
afterAll(async () => {
await h?.close();
for (const t of [lib, src]) await t?.cleanup();
});
it("拦截:钩子返回原因 → 记录失败(intercepted:<原因>),不动文件;整理结束事件 outcome=failed", async () => {
const hooks = new HookRegistry();
const seen: string[][] = [];
hooks.forModule("test").intercept("organize.intercept", (c) => {
seen.push(c.files.map((f) => `${f.role}:${f.kind}`));
return c.action === "copy" ? "演示:不许复制" : null;
});
h.ctx.hooks = hooks;
const plan = await planImport(h.d, req());
expect(plan).toMatchObject({ status: "failed", reason: "intercepted:演示:不许复制" });
expect(seen).toEqual([["main:video", "sidecar:subtitle"]]);
const rec = await h.db
.selectFrom("organization_record")
.select(["status", "error"])
.where("id", "=", plan.recordId!)
.executeTakeFirstOrThrow();
expect(rec).toMatchObject({ status: "failed", error: { hook: "test#1", reason: "演示:不许复制" } });
const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
expect(fin.map((r) => [r.payload.outcome, r.payload.reason])).toEqual([["failed", "intercepted:演示:不许复制"]]);
});
it("改名:主视频按钩子返回的文件名入库,同主干的字幕跟着改;成功后发 organization.finished", async () => {
const hooks = new HookRegistry();
const ctxs: unknown[] = [];
hooks.forModule("test").rewrite("organize.rename", (name, c) => {
ctxs.push({ name, mediaType: c.mediaType, mediaEntityId: c.mediaEntityId });
return name.replace(/\.mkv$/, " [自定义].mkv");
});
// 返回带路径分隔符的结果会被忽略(排在后面,拿到的是上一个钩子的结果)
hooks.forModule("bad").rewrite("organize.rename", (name) => `../${name}`);
h.ctx.hooks = hooks;
const plan = await planImport(h.d, req());
expect(plan.status).toBe("running");
const ex = await executeRecord(h.d, plan.recordId!);
expect(ex.status).toBe("succeeded");
expect(ctxs).toEqual([{ name: expect.stringMatching(/\.mkv$/), mediaType: "movie", mediaEntityId: entity }]);
const files = await h.db
.selectFrom("organization_file_result")
.select(["target_path", "status"])
.where("organization_record_id", "=", plan.recordId!)
.execute();
const targets = files.map((f) => f.target_path ?? "").sort();
expect(targets).toHaveLength(2);
const video = targets.find((t) => t.endsWith(".mkv"))!;
const sub = targets.find((t) => t.endsWith(".srt"))!;
expect(video).toMatch(/ \[自定义\]\.mkv$/);
const stem = video.slice(0, -".mkv".length);
expect(sub.startsWith(stem)).toBe(true);
expect(existsSync(`${lib.root}/${video}`) || existsSync(video)).toBe(true);
const fin = (await h.outbox("organization.finished")).filter((r) => r.payload.recordId === plan.recordId);
expect(fin.map((r) => r.payload.outcome)).toEqual(["succeeded"]);
});
it("钩子出错 / 超时不影响整理:按模板名照常生成计划", async () => {
const hooks = new HookRegistry();
hooks.forModule("broken").rewrite("organize.rename", () => {
throw new Error("坏了");
});
hooks.forModule("broken").intercept("organize.intercept", () => new Promise<string>(() => {}), { timeoutMs: 20 });
h.ctx.hooks = hooks;
const plan = await planImport(h.d, { ...req(), dryRun: true, action: "move" });
expect(plan.status).toBe("queued");
const main = plan.plan!.organize.files.find((f) => f.role === "main")!;
expect(main.dst).not.toContain("自定义");
});
});