Files
UHD_Media_Tool/docs/adr/ADR-006-lifecycle-events.md

62 lines
6.8 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-006:生命周期事件与功能模块约定
- 状态:采纳(2026-10-06);2026-10-12 精简通用链式钩子,保留生命周期事件和功能模块约定。
- 关联:ADR-002(outbox 与任务引擎)、`packages/contracts/src/events.ts`(`LIFECYCLE_EVENTS`)、`packages/contracts/src/features.ts`(`FEATURES`、`SLOT_POSITIONS`)、`apps/web/app/lib/slots.ts`。
- 参照:MoviePilot v3 `app/schemas/types.py` 的 `EventType`(广播)与 `ChainEventType`(链式),分发在 `app/runtime/events.py`。
## 背景
外围功能(补字幕、以后的入库后通知、自动补字幕等)需要在业务节点上「知道发生了什么」或「插一手」。MoviePilot 用插件系统 + 两类事件解决。我们是内部工具,用户定了:
- 不做插件系统、插件市场、启停开关;
- 做「功能模块」约定:功能清单只给智能体知道;补齐生命周期事件(参考 MP 的两类事件);前端插槽要做、默认直接显示。
## 决定
### 1. 业务调用与事后通知
同步规则由所属模块 service 明确实现,路由、能力与任务共用业务入口。跨模块需要同步取得结果时显式调用 service。
需要在业务提交后可靠执行的动作(补字幕、通知、预热等)订阅生命周期事件。业务事务内调用 `ctx.outbox.emit(trx, …)`,与业务写入一起提交或回滚;payload 只放 ID 和少量判断事实。中继至少一次投递成任务,接收方按 ID 回查业务表并幂等执行。
原先参考 MP `ChainEventType` 实现的通用 rewrite/intercept 钩子没有实际业务注册者,本轮删除其注册表、挂点与调用层。以后出现真实扩展需求时再判断抽象方式。`modules/decision-hooks.ts` 是已有待处理事项的业务处理器,语义不同,继续保留。
### 2. 广播事件清单
payload schema 都在 `EVENT_PAYLOADS`(`emit` 时校验,不合法直接抛错)。已有事件保持原样;`download.status_changed` / `organization.changed` 仍是状态流(SSE 与增量游标用,每次变化都发),下表是给功能模块订阅的生命周期事件。
| 事件 | 含义 | 发出位置 | 幂等键 |
| --- | --- | --- | --- |
| `release.resolved` | 资源识别完成(已有) | sources-identity `resolve.ts` 等 | 已有 |
| `subscription.created` | 订阅已建 | 订阅 API 新建、策略自动建(`acquisition.ts`) | `subscription.created/<id>` |
| `subscription.updated` | 订阅已改(参数、暂停、恢复、重新订阅;`changes` 列字段) | 订阅 API 修改、状态动作 | 无(每次改动一条) |
| `subscription.completed` | 订阅已完成 | `completeSubscription`、停止日期到达 | `…/<id>/<历史行 id>`、`…/<id>/stop_after:<日期>` |
| `subscription.cancelled` | 订阅已取消(删除即取消) | 订阅 API 删除 / 改状态为 cancelled | 无 |
| `download.submitted` | 下载已提交(已有) | `downloads.ts` 提交 | 已有 |
| `download.completed` / `download.failed` / `download.removed` | 下载完成 / 失败 / 移除 | `downloads.ts` `emitStatus`(与 `download.status_changed` 同事务) | `<type>/<任务 id>/g<代数>` |
| `organization.finished` | 整理结束,`outcome` = succeeded / partial / failed(统一 `organization.changed` 的终态) | `emitRecord`(所有整理记录写入都经过它) | `organization.finished/<记录 id>/<change_seq>` |
| `scrape.completed` / `scrape.failed` | 刮削完成(含部分 / 跳过)/ 失败 | `scrape.ts` `runScrapeRecord` 收尾 | `<type>/<记录 id>/<结束时间>` |
| `subtitle.written` | 字幕已写入(每个文件一条) | 补字幕 `applySubtitles` | `subtitle.written/<写入 id>` |
| `media.watched` | 作品进入关注(建订阅、开始下载、用户搜索 / 手动认定) | 调用方:订阅 API、`submitDownload`、`resolveRelease` | `media.watched/<作品>/<原因>/<触发对象 id>` |
| `media.metadata_refreshed` | 作品元数据已刷新 | `refreshMetadata` 任务处理器(调用方) | `…/<作品>/<任务 run id>` |
| `media.episode_structure_changed` | 季集结构已切换(剧集组) | `setEpisodeGroup` 事务内 | `…/<作品>/<时间>` |
| `site.unreachable` / `site.recovered` | 站点不可达 / 恢复 | 列表抓取连续失败到阈值(与待处理同口径)、登录失效、站点检测结果跨越 | 列表抓取带 `…/<站点>/<快照 id>`,其余只在跨越时发 |
幂等键用斜杠分隔,与已有的 `<type>:<id>` 键分开(`download.completed:<id>` 已被 `download.status_changed` 占用,outbox 幂等键全表唯一)。
**订阅**:模块在 `outboxRoutes` 里写 `type → 路由函数`。生命周期事件可以有多个订阅方(`collectOutboxRoutes` 按模块顺序合成),其它事件(内部流程,如 `release.observed → resolveObservation`)仍只能一个模块认领。路由函数返回任务、任务数组或 `null`(不建任务);中继对第一个任务用事件的幂等键、其余用 `<键>#<任务名>`。没有订阅方的生命周期事件也会被中继认领、标记已投递,并镜像到 SSE(`worker/index.ts` 的镜像前缀)。
### 3. 功能模块约定
一个外围功能 = 服务端一个 `ServerModule` + contracts 里一份 manifest + (可选)前端插槽注册:
- **manifest**(`FEATURES`):名称、说明、怎么用、订阅的事件、提供的能力、页面入口、插槽位置。服务端模块 `manifest: featureManifest("<id>")`;`modules.test.ts` 核对模块与清单一一对应、订阅的事件与 `outboxRoutes` 一致。
- **功能清单只给智能体**:系统提示「能力地图」一节末尾列出外围功能(`renderFeatures`),能力只列本次可用的。**不做开关**:功能都直接启用。
- **前端插槽**(`apps/web/app/lib/slots.ts`):位置见 `SLOT_POSITIONS`(侧栏入口、作品页操作区、作品页面板区、整理记录行操作、设置分区)。动作类位置注册 `{ label, icon, visible?, Dialog }`,页面渲染成按钮 / 菜单项、点了打开对话框;面板类位置注册组件,挂载点按顺序渲染。注册写在 `app/features/<功能>.tsx`,`app/features/index.ts` 汇总。**默认直接显示,不做开关**。已挂载:作品页操作区、作品页面板区(`library.$id.tsx`)、整理记录行菜单(`organize.tsx`);侧栏与设置分区先只定义位置。
- 样板:补字幕(`modules/subtitles`)——manifest、订阅 `organization.finished`(认出值得补字幕的整理,自动补字幕还没做,只记调试日志不建任务)、写入后发 `subtitle.written`、前端在作品页操作区和整理记录行菜单挂「补字幕」。
## 不做
- 插件系统、热加载、插件市场、功能开关。
- 把广播事件放进内存队列(MP 做法):我们已有 outbox,事件要能跨进程、跨重启。