feat: 生命周期广播事件与链式钩子设施(ADR-006)

- contracts:补齐生命周期广播事件(订阅、下载、整理结束、刮削、字幕、作品关注 / 元数据 / 季集结构、站点可达性)
  的类型与 payload schema,LIFECYCLE_EVENTS 清单与 lifecycleKey 幂等键;HOOK_POINTS 链式钩子挂点;
  FEATURES 功能清单与 SLOT_POSITIONS 前端插槽位置
- outbox 中继支持一条事件路由给多个订阅方(其余订阅方用 <键>#<任务名>);生命周期事件允许多模块订阅,
  没有订阅方时也被中继认领并镜像到 SSE
- 服务端 HookRegistry:按注册顺序执行、rewrite / intercept 两类、单钩子超时与异常隔离、无注册零开销;
  ServerModule 增加 manifest 与 registerHooks,bootstrap 装配 ctx.hooks
- ADR-006 与 server README「如何写一个功能模块」

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:02 +08:00
1 parent 0f656f0f58
commit 1db689d9a7
16 files changed
+1121 -22

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
import type { HookPoint } from "@umt/contracts";
import type { ServerContext } from "../modules/types.ts";
import type { HookContext, HookValue, InterceptPoint, RewritePoint } from "./points.ts";
import type { HookRejection } from "./registry.ts";
export type * from "./points.ts";
export {
DEFAULT_HOOK_TIMEOUT_MS,
type HookOptions,
type HookRegistrar,
HookRegistry,
type HookRejection,
} from "./registry.ts";
// 业务节点调用钩子的入口。ctx.hooks 由 bootstrap 装配;测试里手搭的上下文没有它,等同于没有注册。
/** 这个挂点有没有钩子(上下文构造代价大时先问一下) */
export function hasHook(ctx: Pick<ServerContext, "hooks">, point: HookPoint): boolean {
return ctx.hooks?.has(point) ?? false;
}
/** 改写:没有钩子时原样返回同一个值;accept 判定单个钩子的结果能不能用(不能用的跳过) */
export async function rewriteHook<P extends RewritePoint>(
ctx: Pick<ServerContext, "hooks">,
point: P,
value: HookValue<P>,
context: HookContext<P> | (() => HookContext<P>),
accept?: (v: HookValue<P>) => boolean,
): Promise<HookValue<P>> {
const h = ctx.hooks;
if (!h?.has(point)) return value;
return h.runRewrite(point, value, typeof context === "function" ? context() : context, accept);
}
/** 拦截:返回第一个拒绝,没有钩子或都放行时返回 null */
export async function interceptHook<P extends InterceptPoint>(
ctx: Pick<ServerContext, "hooks">,
point: P,
context: HookContext<P> | (() => HookContext<P>),
): Promise<HookRejection | null> {
const h = ctx.hooks;
if (!h?.has(point)) return null;
return h.runIntercept(point, typeof context === "function" ? context() : context);
}
+81
View File
@@ -0,0 +1,81 @@
import type { HOOK_POINTS, HookPoint } from "@umt/contracts";
import type { ParsedRelease } from "@umt/domain";
// 链式钩子挂点的入参 / 返回值类型(挂点清单与语义在 contracts/hooks.ts,ADR-006)。
// rewrite 挂点:value 是要改写的值,context 只读;intercept 挂点只有 context。
/** 资源选择的候选:只能重排或过滤,不能加入新候选(按 sourceReleaseId 映射回原资源) */
export interface SelectionCandidate {
sourceReleaseId: string;
title: string;
parsed: ParsedRelease;
}
export interface HookPointTypes {
"name.recognize": {
value: ParsedRelease;
context: {
title: string;
subtitle: string | null;
/** 来源资源;按名称识别整理文件时为 null */
sourceReleaseId: string | null;
trigger: string;
};
};
"organize.rename": {
/** 目标文件名(不含目录);返回值不能含路径分隔符 */
value: string;
context: {
targetPath: string;
sourcePath: string;
mediaEntityId: string;
mediaType: "movie" | "tv";
season: number | null;
episodes: { start: number; end: number } | null;
};
};
"organize.intercept": {
value: never;
context: {
mediaEntityId: string;
mediaType: "movie" | "tv";
action: string;
trigger: string;
downloadTaskId: string | null;
files: { src: string; dst: string; kind: string; role: "main" | "sidecar" }[];
};
};
"resource.selection": {
value: SelectionCandidate[];
context: { mediaEntityId: string; subscriptionId: string | null; slotId: string | null };
};
"subscription.completion.check": {
value: never;
context: {
subscriptionId: string;
mediaEntityId: string | null;
mediaKind: string;
/** 本次判定完成的原因(slot_satisfied / download_accepted) */
reason: string;
progress: Record<string, unknown>;
};
};
}
type KindOf<P extends HookPoint> = (typeof HOOK_POINTS)[P]["kind"];
export type RewritePoint = { [P in HookPoint]: KindOf<P> extends "rewrite" ? P : never }[HookPoint];
export type InterceptPoint = { [P in HookPoint]: KindOf<P> extends "intercept" ? P : never }[HookPoint];
export type HookValue<P extends HookPoint> = HookPointTypes[P]["value"];
export type HookContext<P extends HookPoint> = Readonly<HookPointTypes[P]["context"]>;
type MaybePromise<T> = T | Promise<T>;
/** 返回新值;返回 undefined 表示不改 */
export type RewriteHook<P extends RewritePoint> = (
value: HookValue<P>,
context: HookContext<P>,
) => MaybePromise<HookValue<P> | undefined>;
/** 返回拒绝原因;返回 null / undefined 表示放行 */
export type InterceptHook<P extends InterceptPoint> = (
context: HookContext<P>,
) => MaybePromise<string | null | undefined>;
+126
View File
@@ -0,0 +1,126 @@
import { HOOK_POINTS, type HookPoint } from "@umt/contracts";
import type { Logger } from "pino";
import type { HookContext, HookValue, InterceptHook, InterceptPoint, RewriteHook, RewritePoint } from "./points.ts";
// 链式钩子(ADR-006,参考 MoviePilot ChainEventType):模块启动时注册,业务节点同步调用。
// - 按注册顺序执行(模块顺序 = SERVER_MODULES 顺序,同一模块内按调用顺序);
// - 每个钩子单独限时(缺省 2 秒),超时或抛错只记日志、跳过这个钩子,不影响主流程;
// - 没有注册时 has() 为 false,调用方直接用原值(零开销)。
// 钩子可能在业务事务里被调用:只做内存里的判断与改写,不要发长耗时外部请求、不要写库。
export const DEFAULT_HOOK_TIMEOUT_MS = 2_000;
export interface HookOptions {
/** 单次执行上限,缺省 DEFAULT_HOOK_TIMEOUT_MS */
timeoutMs?: number;
/** 日志里显示的名字,缺省 <模块>#<序号> */
name?: string;
}
export interface HookRegistrar {
rewrite<P extends RewritePoint>(point: P, fn: RewriteHook<P>, opts?: HookOptions): void;
intercept<P extends InterceptPoint>(point: P, fn: InterceptHook<P>, opts?: HookOptions): void;
}
interface Entry {
owner: string;
name: string;
timeoutMs: number;
// biome-ignore lint/suspicious/noExplicitAny: 按挂点分表存,调用时由 run* 的类型参数约束
fn: (...args: any[]) => unknown;
}
const TIMEOUT = Symbol("timeout");
const FAILED = Symbol("failed");
export interface HookRejection {
reason: string;
/** 拒绝的钩子名 */
by: string;
}
export class HookRegistry {
private readonly hooks = new Map<HookPoint, Entry[]>();
constructor(private readonly logger?: Pick<Logger, "warn">) {}
/** 给某个模块的注册入口(owner 进日志) */
forModule(owner: string): HookRegistrar {
const add = (point: HookPoint, kind: "rewrite" | "intercept", fn: Entry["fn"], opts: HookOptions = {}) => {
if (HOOK_POINTS[point].kind !== kind)
throw new Error(`钩子挂点 ${point} 是 ${HOOK_POINTS[point].kind} 类,不能用 ${kind} 注册`);
const list = this.hooks.get(point) ?? [];
list.push({
owner,
name: opts.name ?? `${owner}#${list.filter((e) => e.owner === owner).length + 1}`,
timeoutMs: opts.timeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
fn,
});
this.hooks.set(point, list);
};
return {
rewrite: (point, fn, opts) => add(point, "rewrite", fn, opts),
intercept: (point, fn, opts) => add(point, "intercept", fn, opts),
};
}
has(point: HookPoint): boolean {
return (this.hooks.get(point)?.length ?? 0) > 0;
}
/** 已注册的钩子(挂点 → 钩子名),给功能清单与诊断用 */
list(): Record<string, string[]> {
return Object.fromEntries([...this.hooks].map(([p, l]) => [p, l.map((e) => e.name)]));
}
/** 依次改写;某个钩子超时 / 抛错 / 结果不被 accept 接受时跳过它,沿用上一个值 */
async runRewrite<P extends RewritePoint>(
point: P,
value: HookValue<P>,
context: HookContext<P>,
accept?: (v: HookValue<P>) => boolean,
): Promise<HookValue<P>> {
let cur = value;
for (const e of this.hooks.get(point) ?? []) {
const r = await this.call(point, e, [cur, context]);
if (r === undefined || r === TIMEOUT || r === FAILED) continue;
if (accept && !accept(r as HookValue<P>)) {
this.logger?.warn({ point, hook: e.name }, "钩子返回的结果不可用,已跳过");
continue;
}
cur = r as HookValue<P>;
}
return cur;
}
/** 依次判定;第一个返回原因的钩子拒绝,后面的不再执行 */
async runIntercept<P extends InterceptPoint>(point: P, context: HookContext<P>): Promise<HookRejection | null> {
for (const e of this.hooks.get(point) ?? []) {
const r = await this.call(point, e, [context]);
if (typeof r === "string" && r.trim()) return { reason: r.trim(), by: e.name };
}
return null;
}
private async call(point: HookPoint, e: Entry, args: unknown[]): Promise<unknown> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const r = await Promise.race([
Promise.resolve().then(() => e.fn(...args)),
new Promise<typeof TIMEOUT>((resolve) => {
timer = setTimeout(() => resolve(TIMEOUT), e.timeoutMs);
}),
]);
if (r === TIMEOUT) this.logger?.warn({ point, hook: e.name, timeoutMs: e.timeoutMs }, "钩子超时,已跳过");
return r;
} catch (err) {
this.logger?.warn(
{ point, hook: e.name, err: { message: err instanceof Error ? err.message : String(err) } },
"钩子出错,已跳过",
);
return FAILED;
} finally {
clearTimeout(timer);
}
}
}