Files
UHD_Media_Tool/docs/plan/07-v0.1-final-review.md
T

324 lines
35 KiB
Markdown
Raw 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.
# v0.1 第二轮独立验收审查(发布前终审)
审查日期:2026-09-28。基线 HEAD `36aa99b`(工作区只有 `deno.lock` 未提交改动)。只读审查,本文件是唯一写入。
对照:`04-v0.1-acceptance.md`(第一轮)、`00` §3.1 与 §6、`02`、`03`、`05`、`06`、`docs/design/agent-ui-reference.md`、`docs/porting/{mp-rust-logic-parity,moviepilot-rust-transfer}.md`、README、runbook、CHANGELOG。
**约束说明**:W6-A 正在独占修 e2e 稳定性,本轮**没有跑全量 `deno task ci`,也没有跑依赖 PG 的测试**。依赖 PG 的测试(server e2e、file-manager、acquisition-organize `__tests__`)只读代码核对断言,没有实际运行。
本轮实测(均通过):
| 项 | 结果 |
|---|---|
| 单包 vitest(不依赖 PG) | domain 510、contracts 589、adapters 264(另有 3 个 skip)、mapping 22、agent 31、web 27;server 里不依赖 PG 的 4 个文件(api-registry / logger / invoke-limits / health)共 56 个 |
| 类型检查 | `apps/server`、`apps/web` 的 `deno task typecheck` 退出码 0 |
| biome | 763 个文件,0 个 error;1 个 warning(`subscription.test.ts:132` 的 `dt2` 未使用),1 个 info(`subscriptions-policy.tsx:420` 有多余的 fragment) |
| 实机 `PORT=4396 --role api`(测完已关) | `/health` 返回 `version:"0.1.0"`;`/files/roots`、`/files`、`/files/trash`、`/engine/runs` 无凭证时 401;`path=../etc` 返回 422;用 DEV_ADMIN_KEY 的 Bearer 调 `POST /approvals` 返回 403 |
| 提交规范 | 82 个提交全部符合 `type(scope): 描述`;还没有任何 tag |
## 0. 结论
**前提是 W6-A 把 e2e 治稳、`deno task ci` 连续两次全绿。在这个前提下,修完 1 个阻塞项(只改文档)就可以打 `v0.1.0`。**
- 第一轮的 4 个阻塞项全部复核落地。11 个应修项里 10 个已验证,S9(控制台 TOTP 入口)仍缺。
- §6 追加范围:
- 文件管理:后端 18 个端点齐全,规格 §4.1 的 7 条不变量都有代码和测试对应。
- 订阅增强:05 清单 P0 的 1–7 条全部完成,第 8 条部分完成。
- Agent 页:agentui 映射表 11 行全部落地。
- 转移语义:5 项全部落地,挂死隔离在生产二进制里生效有容器演练记录。
- 差分补迁:missing 和 our_bug 已清零。
- 阻塞的是**发布说明**:CHANGELOG `[0.1.0]` 完全没写 W4-E4、W4-F、W5-A~E,"已知限制"里还有一条已经过时的表述。
- 安全面没有发现需要阻塞发布的漏洞。有 1 个多源 Move 的授权范围缺口(应修 S-1),危害比第一轮 R4 小:只能在已登记的根之间搬运,而且资产位置会跟着更新。
## 1. 问题清单
### 1.1 阻塞发布(1)
| # | 问题 | 位置 | 修法 |
|---|---|---|---|
| B1 | CHANGELOG `[0.1.0]` 只写到 W4-E3。缺少 W4-E4(转移语义)、W4-F(Agent 会话页)、W5-A/A2/B(文件管理)、W5-C(订阅增强)、W5-D(识别差分补迁)、W5-E(生产 fsworker、`autoInvestigateMode` 设置)。"已知限制"第 3 条("没有来源会把集单元确认……Slot 不会进入 satisfied")在 W5-C 引入总集数账本后已经不成立 | `CHANGELOG.md` | 按波次补 W4-E4 到 W5-E 的条目;把"已知限制"换成本文 §8 的清单(或直接链接本文),删掉过时的第 3 条 |
### 1.2 应修(v0.1.x,10 项)
| # | 问题 | 位置 | 修法 |
|---|---|---|---|
| S-1 | `/files/move` 用 API Key 执行时,只取 `sources[0]` 对应的作品去核对持续授权。只要持有覆盖作品 A 的 grant,就能在同一请求里把作品 B 的已登记文件一起 Move 走 | `file-manager/transfer.ts:236-244` | 对每个源(目录按其中已登记资产)求出作品集合,要求每个作品都被 grant 覆盖;有无法归属作品的源时只接受一次性审批。补一条"两个作品只授权一个 → 403"的测试 |
| S-2 | 注册表的鉴权标注又和实际行为不一致了(第一轮 S10 的回归)。`GET /files/download` 和 `POST /files/trash/purge` 在处理器里只放行管理员会话,但注册表只标了 `scope:"admin"`,没有标 `auth:"session"`,所以 api-registry 的双向测试没有覆盖到它们。另外 `isAdmin` 放行 `dev_admin`,而 `auth:"session"` 的端点会拒绝 DEV key,两处口径不同 | `packages/contracts/src/api.ts:1150,1182`;`file-manager/common.ts isAdmin` | 两个端点标 `auth:"session"`,在 `api-registry.test` 的会话端点列表里加上;确定 DEV key 的口径,两处统一 |
| S-3 | 第一轮 S9 仍未做:`useTotpStatus` / `useTotpAction` 写好了,但界面里没有任何地方调用,账户弹层只有改密和登出 | `apps/web/app/components/console/account-menu.tsx` | 账户弹层加"二次验证"开关,接 `GET/POST /auth/totp`;04 表里 S9 的状态同步更新 |
| S-4 | 脱敏键表还是三份(见 §5.4)。rss 和 browser 两处都漏了 `c_secure_pass`、`pass_key`/`auth_key` 这类带分隔符的写法、`refresh_token`、`password`、`uid_key`;browser 另外漏了 `auth`。adapters README 写的是"键表与规则取 contracts 的 redact.ts",与实际不符 | `packages/adapters/src/rss/redact.ts:9`、`browser/redact.ts:13` | rss/browser 的参数判定改为调用 `@umt/contracts` 的 `isSensitiveKey` 和 `SENSITIVE_QUERY_PARAMS`,各自只保留特有规则(路径式 passkey、cookie 名、a11y 密码框);加一个三处一致性测试 |
| S-5 | 回收站"还原到…"不检查目标根是否可写,可以还原进 `register_only` 库根。父目录不存在时,会先按 `resolve()` 递归 mkdir,再做 realpath 复核;如果中间一段目录是指向根外的符号链接,根外可能已经被建出目录 | `file-manager/trash.ts:478-506` | target 走 `isWritableTarget`;原位还原时先逐级 realpath 已存在的祖先目录,再 mkdir |
| S-6 | `/files/identify` 走 `capabilities.invoke("catalog.lookup")`,绕开了 `invoke-limits` 的 TMDB 并发限制。每项超时 15 秒;目录最多展开 200 个视频,在 HTTP 请求里串行 | `file-manager/identify.ts:150-160` | 复用 `createInvokeLimiter` 的 TMDB 信号量;单次请求内去重后的远端查询设上限(如 20),超出的项给 warning |
| S-7 | 文件管理的重活仍在 API 进程的 HTTP 请求内做:跨卷进回收站最多复制 2 GiB;还原时跨卷 `copyTree` **没有大小上限**;永久删除时递归 `rm`;所有 fs 调用都不经 fsproxy,挂死的挂载会占满 API 进程的 libuv 线程池,而 `probeRoot` 的 2 秒超时只挡住等待,挡不住卡住的系统调用 | `file-manager/trash.ts:390-399,513-516` | 还原沿用 `trashCrossMaxBytes`,或者跨卷的回收站/还原改为入队任务、返回 taskId;根探测与 readdir 走 FsProxy 的快操作。暂时做不到就写进已知限制(已列入 §8) |
| S-8 | 完成判定有两条路径。`organize.ts` 仍保留本地的 `slotComplete`,并直接把 once 订阅置为 `completed/slot_satisfied`;`services/subscriptions.ts` 按总集数账本判定并写 `subscription_history`。两者结论不同时,once 订阅可能不写历史就完成 | `acquisition-organize/services/organize.ts:1458-1492,1548` | 删掉本地判定,统一调用 `services/subscriptions.ts`(05 §4 遗留里已经点名) |
| S-9 | 文档与代码不同步 | 见右列 | `moviepilot-rust-transfer.md` §7 遗留第 1 条("编译二进制回退 in-process")已被 3641ab9 解决,改为已做;contracts README 缺 `files.ts`、`redact.ts`、`browser.ts`、`auth.ts`;adapters README 的 fs 行缺 `FsProxy` 和 `./fsworker` 导出;`apps/server/README.md` 的模块清单缺 `file-manager/`,角色缺 `worker:agent` 和 `fsworker`;04 表 S9 行没有标状态 |
| S-10 | 验收开关 `UMT_FS_TEST_HANG_AFTER_BYTES` 在生产二进制里同样生效。误设以后,所有数据类文件操作都会卡死,直到被 SIGKILL | `packages/adapters/src/fs/fsproxy.ts` | 只在 `NODE_ENV!=="production"`,或者同时设置 `UMT_FS_TEST_HOOKS=1` 时生效;preflight 发现已设置就 warn |
### 1.3 建议
- `POST /files/probe` 标的是 `organize.read`,但会写 `media_asset.media_info`,而且在请求里串行跑最多 20 次 ffprobe,每次 30 秒超时。建议改成 `organize.execute` 或入队。
- 永久删除只在单项时要求 `confirmName`,多项时没有服务端确认(`trash.ts:217`)。
- 测试可以补几处:trash 用例在审批消费后补一条"重放 → 403";download 用例补"admin scope 的 API Key → 403";E6 自动恢复现在只核实到 recheck 后回到 `downloading`,可以再延伸到入库;engine-retry 用的是 mock 的 enqueue,可以加一条真实引擎的用例。
- e2e 文件编号重复(`e6-auto-recover` 和 `e6-w4a-endpoints`),建议改成 E10。迁移 0017 空缺维持现状即可(已知、无害)。
- 04 §1.3 的 7 条建议都还没动(下载状态机 `ALLOWED=ALL`、`scanIncremental` 出错仍返回 complete、流程版本升级没有测试、AT18 e2e、AT42 未纳管、vertical 用例依赖执行顺序、web 单测少),已并入 §8。
- 清掉 biome 的 1 个 warning 和 1 个 info。
## 2. 第一轮问题复核
| # | 第一轮问题 | 结论 | 证据 |
|---|---|---|---|
| R1 | 后台循环绕过引擎;HTTP 内同步重活 | 已验证 | fe9693d:`index.ts planTick` 只调 `enqueueTracked`/`enqueueSweep`,W5-C 新增的 `sweepDueSlots`/`sweepSubscriptionSearch`/`sweepMetadataRefresh` 也只 enqueue;`routes/downloads.ts:100-125` 返回 202 `{taskId,runId}`;`__tests__/background-loop.test.ts` |
| R2 | 版本 0.0.0 | 已验证(tag 待打) | 根与 9 个成员 `deno.json`、`apps/web/package.json` 都是 0.1.0;实机 `/health` 返回 `0.1.0`;`git tag` 为空,由协调者打 |
| R3 | 授权内恢复 | 已验证 | 86af723;`e6-auto-recover` 3 条:无授权降级、已发布策略授权下 `task.inspect→download.recheck`→系统核实→事项 resolved、换源被拒、重复触发只有一个 run、预算超限。3641ab9 把 `autoInvestigateMode` 放进了设置页 |
| R4 | `sourcePaths` 无约束 | 已验证 | ceff9cf;`organize-guard.test.ts` 6 条:根外、`..`、符号链接 → 400,dryRun 同样受限;API Key 做 Move 没有授权 → 403;grant 绑定的策略非 published 不算;审批用后 consumed;H&R → 409 |
| S1 | 自己批准审批 | 已验证 | `api.ts` 中 approvals 两个端点为 `auth:"session"`;`approvals.test`「admin scope 的 Key 同样 403」;实机 DEV key → 403 |
| S2 | logger 脱敏窄 | 部分:logger 与模型上下文已验证,rss/browser 仍缺 | `contracts/redact.ts` 由 logger 与 agent 共用,`logger.test` 通过;rss/browser 见 S-4 |
| S3 | TMDB 错误带 api_key | 已验证 | f48e1aa;`tmdb/client.ts` 的 message 经过 `redactUrl` |
| S4 | 审批分支无测试 | 已验证 | `test/agent/approvals.test.ts` 5 条,断言了"改计划→被拒→重批→只消费一次→不可重放" |
| S5 | 剧集纵切 | 已验证 | `e7-tv-series`;W5-C 又加了 `e9-continuous-series` |
| S6 | AT24 | 已验证 | `e8-tag-removal` 4 条,用 qB 调用日志断言:`torrents/delete` 计数不变、资产与位置不变 |
| S7 | 生产构建能开 Agent mock | 已验证 | `agent-mock.test.ts`;文件页 mock 也按同一套规则只在 DEV 生效(`resolveFilesMock`,`model.test.ts:237`) |
| S8 | 引擎面板重跑 | 已验证 | `engine-retry.test.ts` 2 条(幂等键序号、审计、409/404/403) |
| S9 | 控制台 TOTP 入口 | **仍缺** | 见 S-3 |
| S10 | 注册表鉴权标注 | 已验证,但新端点回归 | `api-registry.test` 4 条通过;新增的 download/purge 没有标注,见 S-2 |
| S11 | 文档不一致 | 已验证,但有新的不同步 | 见 S-9 |
## 3. §6 追加范围
### 3.1 订阅增强(对照 05)
| 05 §3 条目 | 状态 | 证据 | 缺口 |
|---|---|---|---|
| 1 总集数来源与账本 | 完成 | 迁移 0025(`content_unit.episode_total*`);`sources-identity/service/metadata-totals.ts`;`subscription-search.test` 断言季总集数写入、pending→active | 只建季单元,不预建集单元(设计如此) |
| 2 `resolveEpisodeTotal` | 完成 | `domain/slot/episode-total.ts`;`domain/test/subscription.test.ts` 覆盖账本 §13 的样本 1、9 | — |
| 3 完成判定 | 完成(有双轨) | `services/subscriptions.ts slotComplete`;`__tests__/subscription.test.ts:233`「总集数未知不结束、记疑似缺集;就绪后补齐 → once 完成并写历史」 | S-8 |
| 4 `computeUnitGaps` | 完成 | `domain/slot/gaps.ts` + 单测;`services/acquisition.ts` | — |
| 5 Slot×单元预留 | 完成 | 0025 去掉 `acquisition_plan_slot_active_key`;`domain/slot/inflight.ts`;`subscription.test.ts:112`(20 个断言);E9「Slot 忙时 E02/E03 同时在途」 | 电影 Slot 仍然只允许一个在途计划(设计如此) |
| 6/7 忙后重评、主动搜索 | 完成 | `slot_deferral`、`sweepDueSlots`;`searchSubscription` 与 `POST /subscriptions/{id}/search`(admin,入队) | — |
| 8 元数据 6h 刷新 | 部分 | `sweepMetadataRefresh` | 完成前没有强制刷新 |
| P1 9–14 | 完成 | include/exclude 正则白名单、站点范围、每集体积、集范围、季包选文件、`slot_unit` 分集洗版 | — |
| P1 15–17、P2 18/21–23/25/26 | 未做或部分 | 05 §4 遗留 | 见 §8 |
REQ 增强点:"总集数未知不结束"和"临时集单元"从 ✗/◐ 变成 ✅;"Slot 多目标"(`require_distinct_asset` 没有接入)、"偏好标签参与评估"、"Agent 选片理由与独立预算"、"回退等待期"、"队列按优先级出队"仍是 ✗/◐。
### 3.2 文件管理(对照 06)
**端点**:规格里的 16 个全部实现,另外新增 `POST /files/copy` 和 `GET /files/records`。与规格的偏离:
| 端点 | 与规格的偏离 | 性质 |
|---|---|---|
| download | 规格写的是 `organize.read`,实现为仅管理员会话 | 更严,可以接受 |
| 永久删除开关 | 规格写的是设置项 `files.allowPermanentDelete`,实现为环境变量 `FILES_ALLOW_PERMANENT_DELETE` | 需要重启才能生效,可以接受 |
| scrape | `writeNfo/images` 返回 `not_implemented` | 规格允许拆到 W5 之后,已列入 §8 |
| 路径守卫 | 没有下沉到 `adapters/fs/guard.ts`,模块自带 `resolveIn/recheck` | runbook 已写明 |
**§4.1 不变量**:
| 不变量 | 状态 | 代码 | 测试 |
|---|---|---|---|
| 1 realpath 在根内;`..`、符号链接逃逸、根本身写操作拒绝;执行前再 realpath(TOCTOU) | 满足 | `common.ts resolveIn/recheck`;所有写端点都先过 `resolveIn`(mkdir/rename/move/copy/organize/delete/restore/probe/identify/download) | `browse.test:90`(`..`、URL 编码、根外、符号链接指向文件和目录、未登记根);`write.test:280` 的 TOCTOU 用例:预览后源被换成链接 → `path_escape`,根外文件不动 |
| 2 浏览器不直接写;审计与幂等键 | 满足 | `routes.ts idem()` + `ledger.ts`;每个写操作都调 `audit()` | `write.test:196`「幂等重放不重复执行」 |
| 3 路径变更只来自显式操作,同一事务更新位置与绑定 | 满足 | `mutate.ts`、`records.ts relocate` | `write.test:74/144` |
| 4 删除不碰 qB;做种中不能改名、删除、Move | 满足 | `trash.ts checkItem`、`transfer.ts download_active` | `trash.test:38`、`write.test:174` |
| 5 失败≠不存在 | 满足 | `onlineRoot` 返回 503;`fsErrorToFiles` | `browse.test:120`、`trash.test:202`、`w5a2.test:392` |
| 6 只执行 dryRun 产出的 planId,源事实不变 | 满足(move/copy/organize/delete) | `loadPlan` + 指纹 | `write.test:196`、`identify-organize.test:72` |
| 7 不覆盖 | 满足 | `moveSafe`;copy 遇到目标已存在记失败 | `write.test:74`、`w5a2.test:182` |
**前端**:路由 `/console/files` 和 `/console/files/trash`,组件 `DirTree`、`PathBreadcrumb`、`ContextMenu`、`DetailSheet`,4 步向导(未决项挡住下一步)。虚拟滚动是自己写的(没有引入 `@tanstack/react-virtual`,所以不需要登记许可)。键盘支持 `role="grid"`、F2、Del、Backspace、Shift 区间选、Ctrl+A;拖拽后先打开 dryRun 对话框;SSE `files.changed` 触发刷新。测试:`components/files/model.test.ts` 16 条(选择模型、徽标、禁用原因、集号偏移、未决判定、mock 开关),冒烟覆盖两个路由。
**缺口**:规格 §4.2 要求的前端 e2e("下载根散文件 → 识别 → 向导 Copy → 整理记录 → 状态变为已登记")和移动端快照没有做。
### 3.3 Agent 会话页(对照 agent-ui-reference §3 映射表)
| 事件/对象 | 落地 | 位置 |
|---|---|---|
| `run_started` / `text_delta` | `Message` + `StreamingResponse`(增量拼接,加流式光标) | `agent-conversation.tsx` |
| `thinking_delta` | 自写可展开的思考块,保留完整文本 | `:239` |
| `tool_call_start/end` | `ToolResult`,带类别/scope/风险/幂等徽章 | `agentui/tool-result.tsx` |
| `tool_call_rejected` 与授权失败(`unauthorized/forbidden`) | `ToolApproval` 的 `rejected` 醒目态(`role=alert`) | `:130-139` |
| `compaction` | `MessageMarker` | `:451` |
| `run_completed/failed`、超预算 | `MessageBubble` 的 warning/danger 变体 + 超限原因 | — |
| Approval | `ApprovalCard`,状态集对齐 pending/approved/rejected/revoked/expired/consumed;非管理员会话时按钮禁用 | `:626-670` |
| `AgentGoalReport` 声明与核实分开 | `FinalReport`,双栏 + `FileDiff` + `CitationList` | `:684-790` |
| Budget | `BudgetBar`(自写,有超限态) | `:563` |
| RunMode | 头部显示"只要方案 / 授权内执行";plan_only 用 `TodoList` | `:338,491-514` |
| 滚动跟随 | `MessageScroller`(阈值状态机 + "回到底部"按钮) | `agentui/message-scroller.tsx` |
结论:映射表每一行都落地了,许可文件 `LICENSE-agentui.md` 也在。缺口:组件层没有单测,只有 `agent-helpers.test.ts`;幂等键取自审计,最多翻 3 页(文档已写明)。
### 3.4 转移语义(对照 moviepilot-rust-transfer §7)
| 项 | 状态 | 证据 |
|---|---|---|
| 挂死隔离在**生产二进制**里生效 | 已验证 | 3641ab9:`main.ts` 在加载其余模块前分派 `--role fsworker`;`deno.json compile --allow-run=…,/app/umt-server`;`deploy.md` W5-E 容器演练:日志里 `workerKind:"self"`,`/proc` 里能看到 `umt-server --role fsworker`(uid 10001),停滞 3 秒 → SIGTERM → SIGKILL,共 3 次,之后账本记 `TIMEOUT` 并建待处理,只残留 `.uhd-partial`、没有最终名的半成品;`fsproxy.test` 的 10 条中有 3 条挂死用例(屏蔽 SIGTERM、FIFO、快操作) |
| partial 清理 | 已验证 | `copy.ts sweepPartialsOlderThanMs`,`executeRecord` 对每个目标目录也清一次;`organize-fs.test:48`「写前清走超过 24h 的 partial」 |
| replace | 已验证 | `copy.ts replaceCommit`(硬链接留底 → rename 原子替换);3641ab9 起旧文件进 `.umt-trash/<day>/<uuid>/` 并登记 `trash_item`;`organize-fs.test:48` |
| EEXIST 误报 | 已验证 | 对账阶段比对失败按 IO/TIMEOUT 处理,不再报 EEXIST;`organize-fs.test:118`:FIFO 让对账挂死 → 2 次 TIMEOUT 重试 → 第 3 次 failed + `fs_retry_exhausted` |
| TIMEOUT 错误码 | 已验证 | `FsErrorCode.TIMEOUT`,`ETIMEDOUT` 也映射为它;`fsErrorClass` 归为 retry;`fsproxy.test:184` |
| 缺口 | — | `planImport` 的 stat/哈希、`reconcile.ts`、文件管理都不经 fsproxy;没有根级熔断(把根标为 suspect);基准 B1–B3 没跑;失效软链、hardlink、蓝光原盘、organize 的空目录上溯清理 |
### 3.5 差分补迁(对照 mp-rust-logic-parity §10)
| 链路 | 补迁前 | 补迁后 | missing / our_bug |
|---|---|---|---|
| 标题识别(216 条 × 18 字段) | 92.9%;剔除登记项后 97.4% | 94.4%;剔除登记项后 100% | 0 / 0 |
| 显式 ID | 8/11 | 10/11 | 0 / 0 |
| 过滤规则(644 个 case) | 95.7% | 97.8% | 7(每集体积,属 Slot 能力)/ 0 |
| 站点索引解析 | 98.1% | 99.5% | 0 / 0 |
| 繁简转换 | 84.1% | 92.8%;剔除登记项后 100% | 0 / 0 |
| RSS | 95.5% | 未变 | 0 / 0 |
差分测试在 `adapters/test/differential/{indexer,rss}.test.ts` 和 `domain/test/differential/{meta,zhconv}.test.ts`,本轮单包测试全部通过。遗留:字幕搜索还没接到站点适配器;RSS 不带时区的时间按站点时区解释;HTML 命名实体。
## 4. 解耦审计(03)复核
| 项 | 状态 | 复核 |
|---|---|---|
| 后台循环只编排 | 满足 | `planTick` 只有 enqueue;qB 同步有独立定时器;文件管理的 `purgeTrash` 也是入队(`file-manager/index.ts enqueuePurge`) |
| HTTP 内无重活 | 部分 | cleanup/reconcile 返回 202;`/subscriptions/{id}/search` 入队;files 的 move/copy/organize 执行入队 `verifyAndImport`,请求内只等待,最多 20 秒。**遗留**:`GET /inventory/media-servers` 串行查 Emby;files 的回收站/还原跨卷复制、永久删除、identify、probe 仍在请求内(S-6、S-7) |
| worker 分组 | 满足 | `--role worker`(io 组)、`--role worker:agent`、`--role all`;认领时按任务名过滤(`worker-groups.test`);`purgeTrash` 注册在 io 组 |
| 超时与限并发 | 部分 | 4 个外部 IO 能力在 HTTP 路径上有 15 秒超时,站点并发 2、TMDB 并发 4。**`/files/identify` 绕开了限流器**(S-6);超时后底层调用仍会跑完 |
| 探针 | 满足 | 事件循环延迟探针,1 秒采样,偏差超过 5 秒告警,随心跳上报;fsproxy 的 `stats()` 写进 `organize.transfer` 日志 |
## 5. 安全复查
### 5.1 新端点的 scope 与会话要求
| 端点 | 注册表 scope | 处理器内的附加要求 |
|---|---|---|
| `GET /files/roots`、`/files`、`/files/stat`、`/files/trash`、`/files/records`;`POST /files/identify`、`/files/probe` | organize.read | 无(probe 会写 `media_info`,见 §1.3) |
| `POST /files/mkdir`、`/files/rename`、`/files/copy`、`/files/scrape`、`/files/trash/restore` | organize.execute | 目标或源根必须可写,或者是 managed 库根 |
| `POST /files/move`、`/files/organize`(action=move) | organize.execute | API Key 需要 grant 或一次性审批(`authorizeMove`);move 只核对第一个源的作品(S-1) |
| `POST /files/delete` | organize.execute | 进回收站:API Key 每次都要审批(绑定 planId 与内容哈希,用后 consumed);永久删除:必须设置 `FILES_ALLOW_PERMANENT_DELETE`,且只接受管理员会话 |
| `POST /files/trash/purge` | admin | 只接受管理员会话;注册表没有标 `auth:"session"`(S-2) |
| `GET /files/download` | admin | 只接受管理员会话,文件类型白名单,上限 20 MiB;注册表没有标 `auth:"session"`(S-2) |
| `POST /subscriptions/{id}/search`、`/subscriptions/{id}/episode-total` | admin | 无 |
| `GET /subscriptions/{id}/progress`、`/subscription-history` | read | 无 |
| `POST /engine/runs/{id}/retry` | admin | 只允许已结束的 run;幂等键带序号;写审计 |
| `POST /agent/runs`、`/agent/runs/{id}/messages`、`/agent/runs/{id}/abort` | organize.execute | 能力调用在服务端逐次授权 |
| `POST /approvals`、`PATCH /approvals/{id}` | admin + `auth:"session"` | API Key 和 DEV key 一律 403(已实测) |
注册表共 208 个端点:read 89(其中 3 个 public)、organize.read 10、organize.execute 27、admin 79(其中 5 个 session)。
### 5.2 其他项
- **路径守卫覆盖**:13 个 files 写端点和读端点都先过 `resolveIn`,执行前再调 `recheck`。只有 restore 的目标可写性,以及原位还原时的递归 mkdir 这两处有缺口(S-5)。
- **回收站与永久删除**:`purgeTrash` 删除前先复核路径在 `.umt-trash` 或 `DATA_DIR/trash` 下,并且先删行、再删文件。永久删除有双闸(环境变量开关 + 管理员会话)。restore 不需要审批(只把文件放回,风险低,可以接受)。
- **下载上限**:打开文件后按文件句柄 stat,超过 `FILES_DOWNLOAD_MAX_BYTES` 返回 413;SVG 一律作为附件下载;响应带 `nosniff` 和 `CSP sandbox`。`w5a2.test:350` 覆盖了上述行为。
- **审批只认管理员会话**:创建和决定两个端点都是 `auth:"session"`。`consumeApproval` 与 `authorizeMove` 都用"`status=approved` 才更新"来保证只消费一次。
### 5.3 脱敏键表四处对照
| 参数 | contracts(logger + redactForModel) | rss/redact.ts | browser/redact.ts |
|---|---|---|---|
| passkey / authkey / torrent_pass / rsskey / downhash / apikey / api_key / access_token / token / secret / sign / credential / key | ✓ | ✓ | ✓ |
| `pass_key` / `auth_key` 等带分隔符的写法 | ✓ | ✗ | ✗ |
| c_secure_pass(作为 query 参数) | ✓ | ✗ | ✗(只遮 cookie 里的 `c_secure_*`) |
| refresh_token / password / uid_key | ✓ | ✗ | ✗ |
| auth | ✓ | ✓ | ✗ |
| signature | ✓ | ✗ | ✓ |
| 请求头、Bearer、URL userinfo | ✓ | 只有 userinfo | cookie 头、userinfo |
结论:logger 与模型上下文共用同一份实现;rss 和 browser 仍各自维护(S-4)。
## 6. 工程规范
| 项 | 结论 |
|---|---|
| 提交信息 | 82 个提交全部合规,0 个违规 |
| README、runbook 与 deno 任务 | 文档里出现的 19 个 `deno task` 全部存在(根目录或成员包);没有残留 pnpm/npm 用法 |
| 包 README 与导出 | adapters 新增的 `./fsworker` 导出、contracts 新增的 4 个文件、server 的 file-manager 模块没有写进 README(S-9) |
| CHANGELOG | 没有覆盖 W4-E4 到 W5(B1) |
| 迁移与生成类型 | 0024 `trash_item`、0025 订阅单元、0026 整理动作,编号连续(0017 空缺是历史遗留);`db/src/types.ts` 已含 `trash_item`、`slot_unit`、`slot_deferral`、`subscription_history`、`episode_total*`,以及 action/mode 的 `trash/delete/restore` |
| api.ts 双向登记测试 | `api-registry.test` 4 条通过("OpenAPI 路由都已登记"、"登记的都已实现"、public 端点集合、session 端点集合);漏标见 S-2 |
## 7. 抽查 12 个新增测试
| 用例 | 结论 |
|---|---|
| `write.test`「TOCTOU:预览后源被换成指向根外的链接」 | 有效:断言 400 `path_escape`、根外文件内容不变、目标不存在 |
| `trash.test`「永久删除默认关闭;API Key 删除需一次性审批」 | 有效:断言 403 `permanent_disabled`、无审批时 `approval_required`、审批后执行成功且文件已删除。缺一条"重放 → 403" |
| `w5a2.test`「下载:仅管理员会话……」 | 有效:断言 MIME、`disposition`、`nosniff`、READ/EXEC key 403、视频 403、超限 413、`path_escape`、目录 400、审计 2 条。缺"admin scope 的 key" |
| `browse.test`「根离线 → 503,不返回空列表」 | 有效(按用例名与 `root_unavailable` 断言判断) |
| `organize-fs.test`「对账挂死 → TIMEOUT(不是 EEXIST)→ 有限重试 → 待处理」 | 有效:逐次断言错误信息、文件状态保持 running、最终 failed 且 `code=TIMEOUT`、decision 里 `attempts=3` |
| `fsproxy.test`「复制中途挂死 → SIGKILL → TIMEOUT」 | 有效:断言错误类别为 retry、耗时下限、进度大于 0、`timeouts/killed=1`、最终名不存在、partial 可被清理 |
| `subscription.test`「Slot×单元多在途……移除释放」 | 有效:20 个断言(在途单元、延后、释放、重评) |
| `e9`「Slot 忙时 E02/E03 同时在途,另一版本延后」 | 有效(E9 三步串联,最后断言入库和历史) |
| `e6-auto-recover`「授权内 recheck → 系统核实」 | 有效:断言 run 字段、调用序列 `[task.inspect, download.recheck]`、`download__submit` 被拒、`grant_id`、qB recheck 只 1 次、decision resolved、去重。只核实到 downloading |
| `e8`「qB 任务消失 → removed;资产不变,不重下」 | 有效:断言位置前后相同、Slot 仍 satisfied、计划只有 1 个、`torrents/add` 计数 |
| `approvals.test`「改计划 → 被拒 → 重批 → 只消费一次 → 不可重放」 | 有效 |
| `engine-retry.test`「按原任务名与输入重新入队,序号递增,写审计」 | 部分有效:enqueue 是 mock,没有验证真实引擎里的新 run |
## 8. v0.1 已知限制(供 CHANGELOG 引用)
**范围外(00 §3.2 / 02 后置)**
1. 国产综艺分段、`require_distinct_asset` 组合校验、浏览器/camofox 与原站视图、HHClub/CHD、旧库分范围接管、Agent 主观选片与规则学习、多用户。
2. Passkey、主密钥轮换、子代理、Transmission、TTG 自定义适配、馒头 adult 模式。
**订阅(05 遗留)**
3. 完成判定前不会强制刷新元数据;titleQuery 订阅不会自动建档回填;季范围语义(`current_season` 在没有季单元时固定)未做;偏好标签不参与评估。
4. 同作品同季重复订阅没有查重;`prefer_pack`、回退等待期、Agent 选片理由与独立预算、队列按优先级出队、实体级识别词都未做;没有"清账重置"(只能把状态改回 active 重新订阅)。
5. 完成判定暂时有两条路径(S-8)。
**文件管理**
6. NFO/图片投影(scrape 的 `writeNfo/images`)、硬链接、蓝光原盘整目录操作未做;路径守卫没有下沉到 adapters。
7. 从回收站还原不会恢复资产状态,需要重新整理登记;永久删除开关是环境变量,不是设置项。
8. 跨卷进回收站(≤2 GiB)、跨卷还原、永久删除、identify(每项超时 15 秒,不经 TMDB 限流器)、probe 都在 HTTP 请求内执行,也都不经 fsproxy(S-6、S-7)。
9. 前端没有向导 e2e 和移动端快照,页面行为靠 model 单测和冒烟覆盖。
**转移与文件系统**
10. `planImport` 规划期的 stat/哈希、资产对账和文件管理不经 fsproxy;没有根级熔断;失效软链一律按冲突处理;hardlink、organize 源侧空目录上溯清理、蓝光原盘未做。
11. 二进制必须位于 `/app/umt-server`,否则 fsproxy 回退为进程内看门狗(能按时报 TIMEOUT,但卡住的线程无法回收)。基准 B1(50 GB)、B2(并发)、B3(真实 FUSE 冻结)没有跑。
**Agent 与安全**
12. 控制台没有 TOTP 开关入口(S-3)。
13. rss 和 browser 的脱敏键表没有与 contracts 统一(S-4)。
14. `GET /inventory/media-servers` 在请求内串行查 Emby;能力调用超时后,底层调用仍会跑完。
15. Agent 页的幂等键来自审计(最多 3 页),审批卡片的 planId 靠解析工具摘要得到,较早的运行可能取不到。
16. 自动恢复只核实到下载恢复,入库由后续流程负责;恢复白名单只有 `task.retry`、`download.recheck`、`identity.resolve`。
**识别与站点**
17. 字幕搜索只到解析层;RSS 不带时区的时间与 HTML 命名实体未处理;过滤器的 tmdb 放行、SPECSUB 等属于 Slot 能力扩展。
**质量与运维**
18. 恢复流程只有人工演练;流程版本升级没有产品侧测试;下载状态机 `ALLOWED` 未收紧;`scanIncremental` 出错时仍返回 complete;AT18 恶意标题 e2e、AT42 未纳管占用没有测试;`vertical.test` 的用例依赖执行顺序;web 单测少。
19. `deno task ci` 约 135 秒以上,server/tasks 测试是主要耗时。
## 9. 给 M2 的前 5 条建议
1. **把 IO 统一收到 fsproxy 并加根级熔断**:`planImport`、reconcile、文件管理的 stat/readdir/复制全部经 FsProxy;根超时标为 suspect,暂停向它派发,建待处理;回收站和还原的跨卷复制改为入队。跑 B1–B3 基准,并把结果写进性能基线。
2. **凭证与授权收口**:只保留一份脱敏实现(rss/browser 改用 contracts)并加全仓凭证泄漏的故障注入测试;注册表的 `auth` 标注由处理器的真实行为自动核对(对每个端点用 admin scope 的 key 发一次请求,断言 403 与标注一致);多作品 Move 按作品集合授权;补 TOTP 入口。
3. **订阅闭环第二阶段**:消除双轨完成判定;完成前强制刷新;偏好标签编译进 SlotSpec;`require_distinct_asset` 接入评估与入库(这是综艺和双版本的前置);队列按优先级出队。
4. **文件管理补全**:NFO 投影(带 umt 标记与字段锁)、hardlink(`link` 自带 no-clobber)、蓝光原盘目录、空目录上溯清理;前端补向导 e2e、移动端快照和键盘可访问性测试。
5. **把 CI 当作发布门禁**:e2e 按包并行、给 server 测试分片,把 ci 控制在 90 秒内;夜间跑"备份 → 清库 → 恢复 → 只读对账 → 映射比对"和流程版本升级回归;CHANGELOG 与已知限制由每个波次的汇报强制更新,避免再出现 B1。
## 10. W6-B 处理状态(2026-09-28,打 tag 前)
| # | 状态 | 提交 | 说明 |
|---|---|---|---|
| B1 | 已修 | b48d859、本文档提交 | CHANGELOG `[0.1.0]` 按功能补齐文件管理、订阅增强、转移语义、Agent 会话页、识别补迁、稳定性(W4-D~W6-A)与 W6-B 安全修复;"已知限制"换成本文 §8 的 19 条,过时的集单元条目已删 |
| S-1 | 已修 | 17cf3cb | `authorizeMoveEntities`:每个源的作品集合(声明作品 ∪ 源下已登记资产的作品)逐一核对 grant,一次性审批覆盖整请求;无法归属作品的源只认审批;拒绝时列出未授权项并写审计。`/files/move` 与 `/files/organize`(move,先整体预检、不消费审批)都接入;copy 不删源、delete 每次审批,无此问题。测试:`write.test`「两个作品只授权一个 → 403」、`identify-organize.test`「源是别的作品的已登记资产 → 403」 |
| S-2 | 已修 | 17cf3cb | download / trash purge 标 `auth:"session"`,files 路由按注册表挂 `requireAdminSession`;口径:DEV key 等同 admin scope Key,仅会话端点与永久删除一律拒绝,Move/删除审批的免授权仍放行 DEV key(与 `authorizeMove` 一致)。`api-registry.test` 会话列表加这两个,新增反向断言(未标 session 的端点对 admin scope Bearer 不得 403,会话中间件在入参校验之前,暗挂必暴露);download/purge 用例补 admin scope Key 与 DEV key 403 |
| S-3 | 已修 | ba1361a | 账户弹层"二次验证":生成密钥(base32 分组 + otpauth 链接)→ 6 位码确认开启;已开启时凭当前密码 + 验证码关闭。没有二维码(不引新依赖);没有组件测试,只有类型检查 |
| S-4 | 已修 | 47bd04a | contracts 新增 `isSensitiveQueryParam`;rss 删掉本地 `SENSITIVE_QUERY_KEYS`,browser 的 `CREDENTIAL_PARAM` 由 `SENSITIVE_QUERY_PARAMS` 生成,各自只保留特有规则;`adapters/src/redact-consistency.test.ts` 24 种写法三处一致 |
| S-5 | 已修 | 17cf3cb | 还原目标走 `isWritableTarget`;原位还原 `mkdirWithin` 逐级 lstat + realpath,不跟随符号链接;跨卷还原受 `trashCrossMaxBytes`,进出回收站的跨卷复制经 fsproxy。测试:下载根 / register_only 目标 403、父目录换成根外链接 → path_escape 且根外无新目录、逐级重建、跨卷超限 too_large |
| S-6 | 已修 | b4560be | `sharedInvokeLimiter()` 进程共享;identify 的远端查询经 TMDB 信号量 + 15 s 超时,单次请求去重后最多 20 个,超出项给 warning |
| S-7 | 部分,余下延期至 v0.1.1 | 17cf3cb | 做了:跨卷还原加上限、回收站进出的跨卷复制经 fsproxy。未做:回收站 / 还原 / 永久删除 / identify / probe 改入队,根探测与 readdir 经 FsProxy 快操作。已写入 CHANGELOG 已知限制第 8 条 |
| S-8 | 已修(W6-A) | 887843f | 已核对:`organize.ts` 不再有本地 `slotComplete` 与订阅完成写入,手动整理指定 Slot 改调 `services/subscriptions.ts slotComplete`;完成与历史只由 `applySlotEvent(verify_passed)` 负责 |
| S-9 | 已修 | 2e3c209、cb3fb9b | rust-transfer §7 遗留(编译二进制 fsworker、trash_item)标已做;contracts / adapters / server README 补齐;04 表 S9 标已修;files runbook 授权矩阵同步 |
| S-10 | 已修 | 2ddaf7b | 开关需同时 `UMT_FS_TEST_HOOKS=1`(`fsTestHookFromEnv` 单测);`preflight` 发现已设置就 warn;deploy.md 演练命令同步 |
§1.3 建议:
| 建议 | 状态 |
|---|---|
| probe 标 `organize.read` 却写 `media_info` | 已修(d3de2c8):改为 `organize.execute`;仍在请求内串行,见 S-7 |
| 多项永久删除没有服务端确认 | 延期至 v0.1.1(写入 CHANGELOG 已知限制第 8 条) |
| trash 用例补"重放 → 拒绝" | 已补(d3de2c8) |
| download 用例补"admin scope 的 Key → 403" | 已补(17cf3cb) |
| E6 自动恢复延伸到入库、engine-retry 真实引擎用例 | 延期至 v0.1.1(已知限制第 18 条) |
| e2e 编号重复 | 已修(d3de2c8):`e6-w4a-endpoints` → `e10-w4a-endpoints` |
| 04 §1.3 的 7 条 | 延期(已知限制第 18 条) |
| biome 1 warning + 1 info | 已清(9887d8b) |
另:第一次全量 ci 暴露 `fsproxy.test`「正常复制」偶发失败(fsworker 的进度节流吞掉了复制阶段最后一跳,收到 6291456 而非 6291461),7daf544 改为阶段完成立即上报,单测连跑 3 次通过。
**ci**:修复后连续两次 `deno task ci` 全绿:357 s、341 s(server 44 文件 / 267 用例)。未打 tag。