feat(server): W5-A file manager module (/files)
Roots/list/stat with inline business status, path guard (realpath within registered roots, symlink escape, offline root 503), mkdir/rename syncing asset_location and path_binding revisions, two-phase move/copy/organize (planId + source facts), trash with reference checks and purgeTrash task, identify/probe/scrape, audit + idempotency keys. Runbook docs/runbook/files.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
09f6e3a3e5
commit
29cd10b27e
20 files changed
+4380
-1
No files matched your search
@@ -0,0 +1,57 @@
|
||||
# 文件管理(`/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 判定),不改它的文件。
|
||||
|
||||
## 根与路径
|
||||
|
||||
- 根只来自登记:启用的 `library_root`(`lib:<id>`)+ 下载器路径映射的本机前缀(`dl:<instanceId>:<序号>`;实例没配映射时取保存路径)。`/` 永不出现。
|
||||
- 所有路径参数都是 `{root, path}`,`path` 为根内相对路径(`""` = 根)。服务端:拒绝 `..`/`.` 段与绝对路径 → 父目录 realpath 必须在根 realpath 之下 → 最后一段是符号链接时,目标也必须在根内(否则 `path_escape`,列表里该行 `escape:true` 且禁用操作)。执行阶段再 realpath 一次(TOCTOU)。
|
||||
- 根健康实时探测(≤2 s):库根看哨兵 `.uhd-root`(或 `sentinel_file`),下载根看目录可 stat。离线/超时 → **503**,`error.message` 以 `root_unavailable:` 开头,**不返回空列表**。
|
||||
- 可写:库根 `managed` 且进程 W_OK 才能作为 mkdir / 移动 / 复制的目标;`register_only` 库根只读(不能改名/删除/移出);下载根可以改名/删除/作为移动源,不能作为目标。
|
||||
|
||||
## 错误约定
|
||||
|
||||
业务错误的 `error.message` 以 `<reason>: ` 开头,前端按前缀分支:`root_unavailable`(503)、`path_escape`(400)、`not_found`(404)、`exists` / `plan_stale` / `download_active` / `needs_retire` / `needs_unbind`(409)、`not_writable` / `permanent_disabled` / `approval_required` / `forbidden`(403)。
|
||||
|
||||
## 两段式写操作
|
||||
|
||||
- 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 增量感知。
|
||||
|
||||
## 各操作要点
|
||||
|
||||
| 操作 | 要点 |
|
||||
|---|---|
|
||||
| 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。永不连带下载器任务 |
|
||||
| 永久删除 | `FILES_ALLOW_PERMANENT_DELETE=true` 才可用,且仅管理员会话;单项需 `confirmName` = 文件名 |
|
||||
| 回收站 | `GET /files/trash`;`restore`(原位被占 → 该项 `exists`,可带 `target` 还原到别处;还原不恢复资产状态,需要时重新整理登记);`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` |
|
||||
|
||||
## 授权矩阵(实现)
|
||||
|
||||
| 操作 | 管理员会话 / DEV_ADMIN_KEY | API Key |
|
||||
|---|---|---|
|
||||
| roots / list / stat / identify / probe / trash 列表 | ✓ | `organize.read` |
|
||||
| mkdir / rename / copy / organize(copy) / restore / scrape | ✓ | `organize.execute` |
|
||||
| move / organize(move) | ✓ | `organize.execute` + 持续授权或一次性审批 |
|
||||
| delete(回收站) | ✓ | `organize.execute` + 每次一次性审批(dryRun 的 planId) |
|
||||
| delete(永久)/ trash purge | ✓(仅会话) | ✗ 403 |
|
||||
|
||||
## 环境变量
|
||||
|
||||
`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)。
|
||||
|
||||
## 已知缺口(W5)
|
||||
|
||||
- `GET /files/download`(小文件下载/图片预览)未实现。
|
||||
- organize 的逐项 `season/episodes/slotId` 覆写暂不生效(返回 `groups[].warnings`),需 `planImport` 支持 `fileOverrides`;移动/复制未走 `planImport` 的"原样搬运"变体(`targetDir/keepNames`),而是用同一批 adapters 搬运原语 + 本模块冻结计划。
|
||||
- 删除不写 `organization_record`(`OrganizationAction` 无 `delete`),只写审计;跨卷目录移动、NFO/图片投影、硬链接/空目录清理均为 W5。
|
||||
- 路径守卫未下沉到 `packages/adapters/src/fs/guard.ts`(本模块自带按根解析的守卫,整理仍用 `sourceGuard`)。
|
||||
Reference in new issue
Block a user