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]>
6.9 KiB
6.9 KiB
文件管理(/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>)并发 SSEfiles.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)。