feat(files): W5-A2 move/copy via planImport, organize overrides, delete records, download

- /files/move|copy go through planImport's new `transfer` variant (keep names, directory
  expanded to per-file plan) → organization_record + verifyAndImport/executeRecord: per-file
  ledger resumable, fsproxy, cross-volume directory move = per-file copy→verify→delete then
  remove empty source dirs; shared plan store + plan_stale; asset_location follows per file,
  path_binding revised when a whole directory moved (finalizeTransfer); request waits up to
  FILES_TRANSFER_WAIT_SEC, else executed.pending
- /files/organize per-item overrides take effect: season/episodes (domain planOrganize
  overrides), contentUnitId coverage, targetDir (no binding), operation, slotId (satisfied
  after verify); warnings only for inapplicable overrides
- delete/trash/restore write organization_record (action trash|delete|restore, file-level
  results; migration 0026)
- GET /files/download (admin session, nfo/subtitle/image, size cap, streamed, audited)
- GET /files/records?ids|planIds for the organize page
- fs race errors map to 503/404/403/400 instead of 500
- runbook updated

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-27 23:53:50 +08:00
1 parent 3641ab9573
commit 36aa99b8aa
25 files changed
+1766 -271

No files matched your search

+18 -12
View File
@@ -1,7 +1,7 @@
# 文件管理(`/files`,W5-A)
实现:`apps/server/src/modules/file-manager/`(路由 `routes.ts`,服务按操作分文件),契约 `packages/contracts/src/files.ts`(端点登记在 `api.ts` 的 `Api.files`),迁移 `0024_trash`。规格见 `docs/plan/06-file-manager-spec.md`。
复用 acquisition-organize 的服务导出(`planImport`、`sourceGuard`、`authorizeMove`、`hnrBlockForMove`、`managedTasksUnder`、`emitRecord`、H&R 判定),不改它的文件。
复用 acquisition-organize 的服务导出(`planImport`、`sourceGuard`、`authorizeMove`、`hnrBlockForMove`、`managedTasksUnder`、`emitRecord`、H&R 判定)。W5-A2 给 `planImport` 加了可选参数(`fileOverrides` / `targetDir` / `slotId` / `transfer`,老调用不变),原样搬运的计划与终态在 `acquisition-organize/services/organize-transfer.ts`(`planTransfer` / `finalizeTransfer` / `relocate`)。
## 根与路径
@@ -19,27 +19,30 @@
- move / copy / organize / delete:先 `dryRun:true` 拿 `planId`(存 `artifact kind=plan, title=files_plan`,默认 30 分钟有效,`FILES_PLAN_TTL_SEC`),执行时回传。
- 执行校验:planId 存在、未过期、未用过,且与本次请求体一致(否则 409 `plan_stale`);源文件事实未变——文件看 size/mtime/inode,目录看递归文件清单(路径+size+mtime)指纹;organize 另外重算一次 dryRun,前后路径必须与冻结计划一致。
- 幂等:写请求带 `idempotencyKey`,按「调用者 + 操作 + 键」唯一(`artifact title=files_idem`,唯一索引 `artifact_files_idem_key`)。同键重放返回首次结果;同键不同请求体或首个请求仍在执行 → 409。执行失败不占键。dryRun 不占键。
- 所有执行都写 `audit_event`(`files.<op>`)并发 SSE `files.changed {rootId, dirs[]}`;路径变更同时写 `organization_record`(rename → `rename`,移动 → `relocate`,复制 → `copy`,整理 → planImport 原记录),外部程序按 changeSeq 增量感知。
- 所有执行都写 `audit_event`(`files.<op>`)并发 SSE `files.changed {rootId, dirs[]}`;路径变更同时写 `organization_record`(rename → `rename`,移动/复制 → planImport 记录 `move`/`copy`,整理 → planImport 原记录,删除 → `trash`/`delete`,回收站还原 → `restore`),外部程序按 changeSeq 增量感知。
- 整理页回查:`GET /files/records?ids=<recordId,…>` 或 `?planIds=<planId,…>`(move/copy/organize 经 `task.idempotency_key = files:<planId>`,删除等看 `plan.planId`),返回公开整理记录 + `planRecords{planId→recordId}`;`includeFiles=true` 带逐文件结果。
## 各操作要点
| 操作 | 要点 |
|---|---|
| rename | 同目录;`sanitizeSegment` 不一致 400;目标存在 409 且原文件不动(moveSafe:link+unlink / lstat+rename,事后 inode 比对);视频同主干 sidecar 一起改;已登记位置同事务改 `asset_location.relative_path` + `asset_operation` 账本;目录命中 `path_binding`(含子绑定):旧行 `superseded` 并 `superseded_by_id` 指向新 active 行;纳管下载内容 409;DB 失败把文件改回原名 |
| move/copy | 目标为 writable 库根下已存在目录;源≠目标、目标不在源内;不覆盖;跨卷文件 Copy→核验→删源(`crossDeviceMove`),跨卷目录移动暂不支持(该项失败,W5);move 触及纳管下载任务 → 409(请用整理入库,Move 会按 H&R 移交);API Key 的 move 需 `authorizeMove`(持续授权或一次性审批,plan_type `organize_plan`/`files_plan` 绑定 planId+内容哈希);已登记资产位置跟随(跨库根会改 `library_root_id`),绑定目录写修订 |
| organize | 按 `(mediaEntityId, contentUnitId)` 分组,每组一次 `planImport`,与 `POST /organization-records` 同实现;执行需 `planIds`(按请求匹配各组);单组失败只影响该组(`groups[].error`);成功组入队 `verifyAndImport`,返回 `recordIds` |
| delete | 默认回收站:`<根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>`(同卷 rename);根不可写/跨卷 → `DATA_DIR/trash/...` 复制(上限 `FILES_TRASH_CROSS_MAX_BYTES`,默认 2 GiB,超出请永久删除)。引用检查:纳管任务未完成或 H&R 未满足 409;已登记资产需 `retireAssets`(location→removed、asset 无其他位置→retired、满足的 Slot→attention + 待处理);绑定目录需 `unbind`(→superseded);根本身/只读根拒绝;离线 503。永不连带下载器任务 |
| move/copy | 目标为 writable 库根下已存在目录;源≠目标、目标不在源内;不覆盖。**走 `planImport` 的原样搬运变体**(`transfer`):目录递归成逐文件计划(保持文件名与结构,空子目录照建,符号链接不搬),dryRun 冻结源指纹 + 逐文件前后路径,执行时两者任一变化 → 409 `plan_stale`;执行写 `organization_record(move/copy)` + 入队 `verifyAndImport`(`executeRecord`:逐文件账本 `org:<recordId>:<ordinal>` 可恢复、fsproxy 挂死隔离、跨卷 Copy→核验→删源、EXDEV 不降级)。跨卷 Move 需 `settings.organize.allowCrossVolumeMove`(关闭时预览 `warnings` 提示、执行 400)。目标已存在的文件记失败、不覆盖。请求内最多等 `FILES_TRANSFER_WAIT_SEC`(默认 20 s):完成则 `executed.succeeded/failed` 为最终结果,否则 `executed.pending=true`,后台继续,按 `executed.recordId/taskId` 追踪。move 完成后(`finalizeTransfer`,记录行锁):已登记 `asset_location` 逐文件跟随(跨库根改 `library_root_id`),整项搬完时目录命中的 `path_binding` 写修订,源侧空目录删除。move 触及纳管下载任务 → 409(请用整理入库);API Key 的 move 需 `authorizeMove` |
| organize | 按 `(mediaEntityId, contentUnitId, operation, targetDir, slotId)` 分组,每组一次 `planImport`,与 `POST /organization-records` 同实现;执行需 `planIds`(按请求匹配各组);单组失败只影响该组(`groups[].error`);成功组入队 `verifyAndImport`,返回 `recordIds`。**逐项覆写生效**:`season`/`episodes`(连续区间)→ `planImport.fileOverrides`,决定季目录、文件命名与集单元覆盖;`contentUnitId`(集/分段单元)→ 额外覆盖;`targetDir`(writable 库根下已存在目录)→ 文件按模板命名直接放进去,不用也不建路径绑定;`operation` → 该项的 copy/move;`slotId` → 核验通过后以新资产满足该 Slot(获取中的 Slot 不动)。`groups[].warnings` 只列无法应用的覆写(电影的季/集、非连续集号、目录项指定集号、不属于该作品的单元/Slot、获取中的 Slot) |
| delete | 默认回收站:`<根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>`(同卷 rename);根不可写/跨卷 → `DATA_DIR/trash/...` 复制(上限 `FILES_TRASH_CROSS_MAX_BYTES`,默认 2 GiB,超出请永久删除)。引用检查:纳管任务未完成或 H&R 未满足 409;已登记资产需 `retireAssets`(location→removed、asset 无其他位置→retired、满足的 Slot→attention + 待处理);绑定目录需 `unbind`(→superseded);根本身/只读根拒绝;离线 503。永不连带下载器任务。执行写 `organization_record(action=trash|delete)`,文件级结果(目录展开到 2000 个文件,超过只记目录一行),返回 `recordId` |
| 永久删除 | `FILES_ALLOW_PERMANENT_DELETE=true` 才可用,且仅管理员会话;单项需 `confirmName` = 文件名 |
| 回收站 | `GET /files/trash`;`restore`(原位被占 → 该项 `exists`,可带 `target` 还原到别处;还原不恢复资产状态,需要时重新整理登记);`purge` 仅管理员会话,缺省只清过期项。持久任务 `purgeTrash` 每日入队(`FILES_TRASH_RETENTION_DAYS`,默认 7);清除前复核行仍存在,且路径必须在某根的 `.umt-trash` 或 `DATA_DIR/trash` 下 |
| 回收站 | `GET /files/trash`;`restore`(原位被占 → 该项 `exists`,可带 `target` 还原到别处;还原不恢复资产状态,需要时重新整理登记;有成功项时写 `organization_record(action=restore)`,返回 `recordId`);`purge` 仅管理员会话,缺省只清过期项。持久任务 `purgeTrash` 每日入队(`FILES_TRASH_RETENTION_DAYS`,默认 7);清除前复核行仍存在,且路径必须在某根的 `.umt-trash` 或 `DATA_DIR/trash` 下 |
| identify | 只读:`parseReleasePath`(文件 + 父/祖父目录)→ 本地召回(别名 pg_trgm + 外部身份)→ `catalog.lookup` 能力取远端候选(失败只给 warning)→ `rankCandidates` 前 5;目录展开视频上限 200;不建作品、不进待识别池 |
| probe | ≤20 项;已登记资产写回 `media_asset.media_info.probe`;未登记缓存 24 h(`DATA_DIR/cache/probe`) |
| scrape | 仅有 active 资产的作品;入队 `refreshMetadata`(字段锁优先,不动路径),返回 `lockedFields`;`writeNfo/images` 目前返回 `not_implemented` |
| download | `GET /files/download?root=&path=&inline=`:仅管理员会话(API Key 含 admin scope 也 403);只允许 NFO/字幕/图片(视频 403),≤ `FILES_DOWNLOAD_MAX_BYTES`(默认 20 MiB,超出 413 `too_large:`);路径守卫同其它接口,打开前再 realpath 复核、按文件句柄 stat;流式返回(非 JSON 信封),`inline=true` 仅对图片生效(SVG 一律附件),带 `nosniff` + `CSP sandbox`;写审计 `files.download` |
## 授权矩阵(实现)
| 操作 | 管理员会话 / DEV_ADMIN_KEY | API Key |
|---|---|---|
| roots / list / stat / identify / probe / trash 列表 | ✓ | `organize.read` |
| roots / list / stat / identify / probe / trash 列表 / records | ✓ | `organize.read` |
| download | ✓(仅会话) | ✗ 403 |
| mkdir / rename / copy / organize(copy) / restore / scrape | ✓ | `organize.execute` |
| move / organize(move) | ✓ | `organize.execute` + 持续授权或一次性审批 |
| delete(回收站) | ✓ | `organize.execute` + 每次一次性审批(dryRun 的 planId) |
@@ -47,11 +50,14 @@
## 环境变量
`FILES_LIST_MAX_ENTRIES`(20000)、`FILES_TRASH_RETENTION_DAYS`(7)、`FILES_TRASH_CROSS_MAX_BYTES`(2147483648)、`FILES_PLAN_TTL_SEC`(1800)、`FILES_ALLOW_PERMANENT_DELETE`(false)。
`FILES_LIST_MAX_ENTRIES`(20000)、`FILES_TRASH_RETENTION_DAYS`(7)、`FILES_TRASH_CROSS_MAX_BYTES`(2147483648)、`FILES_PLAN_TTL_SEC`(1800)、`FILES_ALLOW_PERMANENT_DELETE`(false)、`FILES_TRANSFER_WAIT_SEC`(20,移动/复制请求内等待执行完成的秒数)、`FILES_DOWNLOAD_MAX_BYTES`(20971520)。
## 已知缺口(W5)
## 错误兜底
- `GET /files/download`(小文件下载/图片预览)未实现。
- organize 的逐项 `season/episodes/slotId` 覆写暂不生效(返回 `groups[].warnings`),需 `planImport` 支持 `fileOverrides`;移动/复制未走 `planImport` 的"原样搬运"变体(`targetDir/keepNames`),而是用同一批 adapters 搬运原语 + 本模块冻结计划。
- 删除不写 `organization_record`(`OrganizationAction` 无 `delete`),只写审计;跨卷目录移动、NFO/图片投影、硬链接/空目录清理均为 W5。
处理器里漏出的文件系统错误(检查与使用之间目录被删、挂载掉线等竞态)不再 500:离线类 errno(EIO/ENOTCONN/ESTALE/ETIMEDOUT/EHOSTDOWN/ENODEV)→ 503 `root_unavailable`,ENOENT/ENOTDIR → 404,EACCES/EPERM → 403,其余 errno → 400(`fsErrorToFiles`)。
## 已知缺口
- NFO/图片投影(scrape 的 `writeNfo/images`)、硬链接、蓝光原盘整目录操作仍未做。
- 搬运 move 在终态提交前删源侧空目录;若进程恰在两者之间退出,重跑 finalize 幂等,但极端情况下可能留下空目录(无数据风险)。
- 路径守卫未下沉到 `packages/adapters/src/fs/guard.ts`(本模块自带按根解析的守卫,整理仍用 `sourceGuard`)。