Files
UHD_Media_Tool/docs/plan/06-file-manager-spec.md
T
2026-09-27 22:34:13 +08:00

237 lines
23 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.
# 06 文件管理页规格(`/console/files`)
> 来源:`00-first-version-plan.md` §6 追加范围;对照 `docs/porting/moviepilot-filemanager.md`(下称"MP 账本",痛点编号 P1–P12)。
> 需求约束:REQ §2.1 五原则、§7.1/§7.1.1(路径绑定固定、媒体级主动整理)、§8.2(扫描与"失败≠不存在")、§8.3(Copy/Move、删除保护)。
> 本文只定规格,不含实现。实现时 contracts 先行(`packages/contracts/src/files.ts`)。
## 1. 目标与边界
**目标**:覆盖 MP 文件管理的全部操作(浏览、存储/根切换、面包屑、目录树、排序过滤、新建目录、重命名、删除、识别、刮削、整理、批量、预览/下载),并在四个方面做得更好:
1. **有业务上下文**:每一行都显示"已登记资产 / 纳管下载中 / 临时文件 / 待识别",不用挨个点识别(P5)。
2. **安全**:只能浏览登记过的根;删除进回收站并做引用检查;改名同步资产与路径绑定(P1/P2/P3)。
3. **一条向导走完整理**:识别 → 逐项确认作品/季/集/Slot → dryRun 预览前后路径 → 执行 → 跳转整理记录(P7/P8)。
4. **大目录与离线可用**:游标分页、截断提示、挂载离线显式报错而不是显示空目录(P6/P12)。
**边界(不做)**:
- 整库迁移、底层分支盘/存储池调度(REQ §7.1.1);网盘存储(115/阿里/Alist/SMB/rclone)——只有本机可达路径(含 NFS/FUSE 挂载)。
- 不提供文件上传、在线编辑、解压;下载只允许非媒体小文件(≤ 20 MB,如 NFO/字幕/图片),视频不经浏览器下载。
- **浏览器无媒体库写权限原则不变**:所有写操作由服务端执行,浏览器只提交意图;服务端做守卫、授权、审计。
- 硬链接/软链接创建、空目录上溯清理、蓝光原盘整目录操作:列为 W5(与 `moviepilot-rust-transfer.md` 的 W5 项一起)。
- 不改元数据自动刷新语义:刷新不改路径;只有本页的"重命名/移动/整理"是显式路径变更。
## 2. 后端 API
### 2.1 模块归属
- 新建子模块 `apps/server/src/modules/acquisition-organize/files/`(路由 `routes/files.ts` + 服务 `services/files*.ts`),复用 `organize-guard.ts`、`planImport`、`volumes.ts`。
- `POST /files/identify` 需要身份候选排序,归 `sources-identity` 模块注册(遵守"模块间不互相 import service",它只读 DB + 调 TMDB 适配器,不落库)。
- **路径守卫下沉**:把 `organize-guard.ts` 里的 `sourceGuard` 的纯 IO 部分抽到 `packages/adapters/src/fs/guard.ts`(输入:根列表;输出:`check(p) → realpath`),两个模块共用;根列表由各自从 DB 读取。
- 前缀统一 `/files`,scope:读 `organize.read`;写 `organize.execute`,另加 §2.4 的授权规则。
### 2.2 读接口
**`GET /files/roots`** → `FileRoot[]`
| 字段 | 说明 |
| --- | --- |
| `id` | `lib:<library_root.id>` 或 `dl:<downloader_instance.id>:<mapping idx>` |
| `kind` | `library` \| `download` |
| `name` / `hostPath` | 显示名 / 本机路径(下载根 = 路径映射的 hostPath;无映射时取 localSavePath) |
| `mediaKind` | 库根的 movie/tv/mixed;下载根为 null |
| `writable` | 库根 `management_mode='managed'` 且进程对目录有 W_OK;`register_only` 恒 false(只读登记) |
| `health` | `healthy/degraded/offline/unknown` + `checkedAt`,来自 `checkRoot`(哨兵)实时探测,≤2 s 超时 |
| `usage` | `diskUsage` 的 total/free,失败为 null |
与 `allowedSourceRoots` 同源,根 `/` 永不出现。
**`GET /files?root=&path=&cursor=&limit=&sort=&order=&q=&filter=&hidden=`** → `{ root, path, entries, nextCursor, total, truncated, scannedAt }`
- `path` 是根内相对路径(`""` = 根);服务端 `join(root.hostPath, path)` 后过守卫。响应里不回显绝对路径以外的东西。
- 目录读取用 `opendir` 流式;单次最多读 `FILES_LIST_MAX_ENTRIES`(默认 20 000)项,超出 `truncated=true`,前端提示"请用搜索或进入子目录"。
- 排序 `name`(自然排序,目录优先)/ `mtime` / `size` / `status`;`order=asc|desc`;`q` 为子串或 glob(大小写不敏感);`filter` 可多选:`video|subtitle|registered|unregistered|downloading|temp|unidentified`;`hidden=false` 默认隐藏点文件和 `.uhd-partial`/`.umt-trash`/哨兵。
- 分页:排序在服务端全量(≤上限)完成后按 `limit`(默认 200,最大 1000)切片,`cursor` 为不透明偏移 + 目录快照 mtime;目录 mtime 变了返回 `stale: true` 让前端重载。
- 每个 entry:
| 字段 | 说明 |
| --- | --- |
| `name` / `type`(`file\|dir\|symlink\|other`)/ `size` / `mtime` | lstat;符号链接不跟随,仅标记,目标在根外时 `escape: true` 并禁用所有操作 |
| `ext` / `fileKind` | video/subtitle/audio/nfo/image/other(同 domain/organize 的分类) |
| `status` | 见下表,只取一个主状态,按优先级 |
| `asset` | 已登记时:`{ assetId, locationId, mediaEntityId, title, year, contentUnit(S01E02), slotId?, slotStatus?, isPrimary }` |
| `download` | 路径落在纳管 `download_task.content_path`(经 hostPathOf 映射)内:`{ taskId, status, progress, hnr: satisfied\|unsatisfied\|unknown }` |
| `media` | 已登记资产取 `media_asset.media_info` 摘要(分辨率/HDR/编码/音轨数);未登记不探测,仅在 `/files/probe` 后缓存 |
| `dirSummary` | 目录项:懒计算,仅列表当前页的目录给 `{ videoCount, registeredCount }`,上限 500 项/目录,超出给 `partial: true` |
`status` 优先级:`temp`(`.uhd-partial`、`.!qB`、`.part`)> `downloading`(纳管任务未完成)> `registered`(有 available 的 asset_location)> `seeding`(纳管任务已完成未入库/或 Copy 后仍做种)> `unidentified`(视频且无资产)> `plain`。
- 资产关联查询:本页 entries 批量 `WHERE library_root_id=? AND relative_path = ANY(?)`,下载关联按任务 content_path 前缀,不逐行查库。
**`GET /files/stat?root=&path=`** → 单项 entry + `{ realPath, inode, device, nlink, sidecars[], bindings[] }`。`bindings` 为命中该目录的 path_binding(作品/季);`nlink>1` 标记"共享 inode,禁止原地改写"(REQ §8.3)。
**`GET /files/download?root=&path=`**:仅 `fileKind ∈ {nfo, subtitle, image}` 且 ≤ 20 MB;`image` 同时作为预览。
**挂载离线**:根哨兵检查失败或 `readdir` 报 EIO/ENOTCONN/ESTALE/ETIMEDOUT → `503 root_unavailable`,不返回空列表(原则 4,P12)。
### 2.3 写接口
所有写请求体都带 `idempotencyKey`(前端生成 UUID);同键重放返回首次结果(存 `task.idempotency_key`)。所有写操作:守卫 → 授权 → 前置检查 → 执行 → `audit_event`(action `files.<op>`,payload 含 root/相对路径/结果)→ SSE `files.changed { rootId, dirs[] }`。
| 端点 | 请求 | 行为 |
| --- | --- | --- |
| `POST /files/mkdir` | `{root, path, name}` | `sanitizeSegment(name)` 后与原值不同则 400;`mkdir` 非递归,已存在 409;只允许 writable 根 |
| `POST /files/rename` | `{root, path, newName, withSidecars=true, dryRun}` | 同目录改名,见 §2.3.1 |
| `POST /files/move` | `{items:[{root,path}], target:{root,path}, mode:"move"\|"copy", dryRun, planId?}` | 见 §2.3.2 |
| `POST /files/delete` | `{items:[{root,path}], mode:"trash"\|"permanent", dryRun}` | 见 §2.3.3 |
| `POST /files/trash/restore` / `GET /files/trash` / `POST /files/trash/purge` | — | 回收站列表/还原/清空 |
| `POST /files/identify` | `{items:[{root,path}], hints?:{mediaKind,year,tmdbId}}` | 见 §2.3.4,只读 |
| `POST /files/organize` | 见 §2.3.5 | 复用 `/organization-records` |
| `POST /files/scrape` | `{mediaEntityId, writeNfo:boolean, images:boolean}` | 见 §2.3.6 |
| `POST /files/probe` | `{items:[{root,path}]}`(≤ 20 个视频) | `ffprobe` 摘要;已登记资产写回 `media_asset.media_info`,未登记只缓存 24 h(DATA_DIR/cache/probe) |
#### 2.3.1 rename
- `newName` 过 `sanitizeSegment`;不得含 `/`;目标已存在 → 409(**不覆盖**,修 P3)。实现用 `link+unlink` 或 `renameat2(RENAME_NOREPLACE)` 语义,不能用会覆盖的裸 rename;Deno 下用"先 lstat 再 rename + 事后 inode 比对"并在 `asset_operation` 记账。
- `withSidecars`:视频改名时同 stem 的字幕/NFO/图片一起改(复用 `domain/organize/sidecars.ts` 的匹配规则),dryRun 返回全部前后名。
- **已登记资产**:同一事务里更新 `asset_location.relative_path`(含 sidecar 的 location),写 `asset_operation` 账本(commit 阶段);发 `organization_record(trigger=manual, action=rename)`(枚举已有 `rename`)让外部程序增量感知。
- **目录改名命中 path_binding**:这是显式路径变更(REQ §7.1.1),插入新绑定并把旧绑定置 `superseded`、`superseded_by_id` 指向新行,`bound_by` = actor;该目录下所有 asset_location 的 relative_path 前缀批量替换。**仅译名刷新不触发**,只有本端点和整理会改。
- 目录内有纳管下载任务(做种中):拒绝 409 —— 改名会让 qB 找不到文件;提示先完成/移交。
- 递归自动改名(MP 的 recursive)不照搬:改为 §3.4 向导里"按模板重命名"的一种模式,先预览全部,再一次执行,单项失败不影响其他项并列出失败清单(修 P9)。
#### 2.3.2 move / copy
- 目标必须是 writable 根下的已存在目录;源 ≠ 目标、目标不在源内。
- **已登记资产或含已登记资产的目录**:不走裸 FS,转成 organize plan(记录 action 用已有的 `relocate`/`copy`):`planImport({sourcePaths, action, targetLibraryRootId, ...})` 的"原样搬运"变体(保持文件名,目标目录由用户指定)——需要在 `ImportRequest` 加 `targetDir`(绝对路径,须在库根下)与 `keepNames: true`。计划照常检查冲突、下载器引用、可用空间、H&R;执行走现有 `verifyAndImport` → 逐文件可恢复、跨卷 Copy→核验→删源、EXDEV 不降级。完成后 asset_location/path_binding 更新由整理流程负责。
- **未登记文件**(如下载根里的散文件、临时整理):同样走 organize plan,但 `registerAssets=false`,只产生 organization_record,不建资产。这样搬运都是可恢复的,不存在第二套搬运代码。
- `dryRun=true` 返回 planId + 前后路径表 + 冲突/空间/H&R 结论;执行必须带 dryRun 返回的 `planId`,服务端校验计划内容哈希与当前请求一致,且源文件 size/mtime 与计划冻结时一致,否则 409 `plan_stale`(dryRun 一致性)。
- `mode=move` 授权同 `/organization-records`:admin 会话,或 API Key + grant/approval;H&R 未满足 → 409 + 待处理,不静默改 Copy。
#### 2.3.3 delete(原则 5)
- 默认 `mode=trash`:同卷移入 `<根>/.umt-trash/<yyyymmdd>/<uuid>/<原相对路径>`(rename,O(1));根不可写或跨卷时移入 `DATA_DIR/trash/` 需 Copy,超过 `FILES_TRASH_CROSS_MAX_BYTES`(默认 2 GiB)拒绝并提示用永久删除。`trash_item` 表记录原根、原相对路径、actor、时间、关联资产快照。
- 保留 `FILES_TRASH_RETENTION_DAYS`(默认 7);清理由持久任务 `purgeTrash` 每日执行,删前复核 `trash_item` 仍存在。还原:原路径被占用 → 409,可选"还原到…"。
- `mode=permanent`:设置项 `files.allowPermanentDelete`(默认 false)开启后才可用,且仅 admin 会话;前端二次确认输入文件名。
- **引用检查(两种模式都做,dryRun 可预览)**:
| 情况 | 处理 |
| --- | --- |
| 命中纳管 download_task 的 content_path(未完成或仍在做种/H&R 未满足) | 拒绝 409,列出任务 |
| 已登记资产(asset_location available) | 允许,但必须勾选"同时退役资产":asset_location→`removed`,media_asset→`retired`,若为 Slot 满足资产则 Slot 退回 `attention` 并建待处理;写 organization_record(action=delete) |
| 目录是 library_root 本身、`path_binding` 绑定目录 | 根本身拒绝;绑定目录需勾选"解除绑定"(绑定置 superseded),否则 409 |
| 共享 inode(nlink>1) | 允许,提示"只删除这一处链接" |
| 根离线/stat 失败 | 拒绝(原则 4),不当作已删除 |
- 删除永不连带 qB 任务;qB 侧清理仍只走 retention 流程。
#### 2.3.4 identify(只读)
- 对每个选中项:`parseReleasePath`(文件路径含父目录上下文)→ 本地实体优先召回(title_alias pg_trgm + external_identity)→ 不足时查 TMDB(走缓存)→ `rankCandidates`。目录项先展开为其中视频(上限 200)。
- 返回每项:`{ parsed(标题/年份/季集/分辨率/来源/发布组), candidates:[{mediaEntityId?, provider, externalId, title, year, kind, level, reasons[]}] (≤5), suggestion:{mediaEntityId?, contentUnit?, slotId?}, existing:{assetId?} }`。
- **不写库**:不建 media_entity、不写 identity_revision、不进待识别池;用户在向导里确认后才由 organize 落库。`resolveRelease` 需要拆出纯排序函数(`rankForPath`)供这里调用。
#### 2.3.5 organize
- 请求:`{ items:[{root, path, mediaEntityId, contentUnitId?, season?, episodes?, slotId?}], action:"copy"|"move", targetLibraryRootId?, dryRun, planId? }`。
- 服务端按 `mediaEntityId` 分组,每组调用一次 `POST /organization-records` 同一实现(`planImport`),因此自动与手动整理产出同格式记录(REQ §7.1.1)。
- **缺口**:现有 `OrganizeRequest` 只接受整批一个 `mediaEntityId/contentUnitId`,没有逐文件季集覆写。需扩 contracts:`fileOverrides: [{path, contentUnitId?, season?, episodes?}]`,planImport 在 facts 生成时优先用覆写值;`slotId` 用于指定满足哪个 Slot(缺省由 Slot 评估选)。
- dryRun 返回每组 `planId`;执行请求携带全部 planId,逐组执行,任一组 plan_stale 只让该组失败。执行返回 `recordIds`,前端跳 `/console/organize?ids=`。
#### 2.3.6 scrape
- 只对**已登记作品**:等价于 `POST /media-entities/{id}/refresh`(字段级、用户锁优先、不动路径),可选 `writeNfo`/`images` 把最终字段投影成 NFO/海报写到绑定目录。
- 写 NFO 规则(REQ §7.3):临时文件 + 原子提交;目标 NFO 若非本系统生成(无 `<!-- umt -->` 标记)且字段已被用户锁定来源为"NFO 导入",不覆盖,返回 `skipped`;不写媒体容器字节。NFO 投影本体目前未实现,列入后端工作量。
- 未登记文件点"刮削" → 前端引导先走识别/整理,不提供"直接按路径刮削写 NFO"(避免对未登记内容写文件,修 P10)。
### 2.4 授权矩阵
| 操作 | admin 会话 | API Key |
| --- | --- | --- |
| roots/list/stat/download/identify/probe(只读缓存) | ✓ | `organize.read` |
| mkdir / rename / copy / organize(copy) | ✓ | `organize.execute` |
| move / organize(move) | ✓ | `organize.execute` + automation_grant 覆盖目标作品,或一次性 approval(绑定 planId 哈希) |
| delete(trash) | ✓ | `organize.execute` + approval(每次) |
| delete(permanent)、trash purge | 仅 admin 会话 | ✗ |
Agent 侧暂不暴露 files.* 能力;若加,只开放 `files.list/identify`(只读)。
## 3. 前端规格 `/console/files`
### 3.1 布局
- 桌面(≥ lg):左栏 `pg-chrome` 贴边 260 px(可拖宽,存 localStorage):根列表(按 库/下载 分组,每根带健康 StatusBadge 和剩余空间)+ 当前根的懒加载目录树;右侧 `pg-slab`:工具栏(面包屑、搜索、筛选、排序、视图、显示隐藏文件开关、新建目录、刷新)+ 列表 + 底部状态栏(项数、选中数/总大小、截断提示)。
- 选中单项时右侧抽屉 `DetailSheet`:文件信息、资产/作品/Slot 链接、下载任务链接、sidecars、ffprobe 摘要、路径绑定;图片/NFO/字幕文本预览。
- 移动端(< md):根和树收进 `Sheet`,列表单列卡片(名称 + 状态徽标 + 大小/时间一行),长按进入多选,操作放底部操作栏;面包屑折叠为"…/父/当前"。
- URL 状态:`?root=&path=&q=&sort=&sel=` 可分享、可回退;刷新保持位置。
### 3.2 列表
- 列:选择框 | 图标 + 名称(已登记显示作品标题副行)| 状态徽标 | 季集 | 大小(目录显示视频数/已登记数)| 修改时间 | 行操作(⋯)。
- 状态徽标(`StatusBadge` + 文字,不只靠颜色):已登记(success)/ 下载中 n%(neutral)/ 做种中·H&R 未满足(warning)/ 临时文件(neutral)/ 待识别(warning)/ 链接逃逸(destructive)。
- 虚拟滚动(新增依赖 `@tanstack/react-virtual`,需登记许可 MIT);服务端分页 200 条,滚到底自动取下一页。
- 快捷筛选 chips:全部 / 视频 / 待识别 / 已登记 / 下载中 / 临时文件;搜索框支持子串与 glob,300 ms 防抖,服务端过滤。
- 空态:空目录(`EmptyState plain`,提供"新建目录")、过滤无结果(提供"清除筛选")、根离线(destructive 提示 + 上次健康时间 + "重试")、截断("只显示前 20 000 项,请搜索")、无权限。
### 3.3 操作
- 入口三处一致:行 ⋯ 菜单、右键菜单(桌面)、多选后的浮动操作栏。菜单按条件禁用并在 tooltip 说明原因(例如"做种中,不能改名")。
- 单项:打开 / 重命名(F2)/ 移动到… / 复制到… / 整理到… / 识别 / 刮削(已登记)/ 探测 / 删除(Del)/ 复制路径(本机路径 + 下载器路径 + 媒体服务器路径三种,取路径映射)/ 在整理记录中查看。
- 多选:整理向导 / 识别 / 移动到 / 复制到 / 删除 / 探测。
- 重命名:行内编辑;右侧"建议名"按钮(按模板渲染,来自 identify 结果);显示将一起改名的 sidecars;已登记/绑定目录时提示"将同步资产与路径绑定"。
- 移动/复制到:目标选择器复用目录树,只列 writable 根;先显示 dryRun 预览(前后路径、冲突、空间、H&R),再确认。
- 删除:对话框列出引用检查结果,默认"移入回收站(7 天后清除)";需要额外勾选的项逐条列出。
- 拖拽:把列表项拖到树节点或面包屑段 = "移动到…",松手后一律打开 dryRun 预览对话框,不直接执行;按住 Alt 为复制。
- 键盘:↑↓ 移动焦点,Enter 进入/打开,Backspace 上一级,Space 勾选,Shift+点击/Shift+↑↓ 区间选,Ctrl/Cmd+A 全选,F2 改名,Del 删除,`/` 聚焦搜索,Esc 清除选择;列表为 `role="grid"`,焦点可见。
- 长任务(整理/移动)提交后 toast 带任务链接,列表通过 SSE `files.changed` 与 `organization.*` 事件自动刷新受影响目录。
### 3.4 批量整理向导(Sheet 全高,4 步)
1. **识别**:对选中项调 `/files/identify`,表格:源文件 | 解析(标题/年/季集/质量)| 候选作品(下拉,显示等级与理由,可搜索改选)| 季/集(可编辑,支持批量"集号 +N 偏移")| Slot(下拉,默认自动)。同一目录的项默认共享作品选择;"应用到同组"按钮。未决项(无候选/低置信)高亮,必须处理或排除才能下一步。
2. **选项**:动作 Copy/Move(Move 在有 H&R 未满足项时禁用并说明)、目标库根(按作品类型过滤)、是否按模板重命名(默认是)、已存在目标处理(跳过/冲突待处理)。
3. **预览**:调用 dryRun,按作品分组显示前后路径对照(差异高亮)、新建目录、将创建/沿用的路径绑定(已绑定显示"沿用 旧目录名",体现原则 2)、冲突、空间、截断警告。任何组失败可返回第 1 步修改。
4. **执行**:提交 planId,显示每组状态;完成后按钮"查看整理记录"跳 `/console/organize?ids=…`。
### 3.5 与 MP 的对比(更好用的具体点)
| MP | 我们 |
| --- | --- |
| 从 `/` 浏览整个文件系统(P1) | 只列登记的库根与下载根,带健康与空间 |
| 列表无业务状态(P5) | 每行显示已登记/下载中/做种/待识别,并链接作品、Slot、任务 |
| 删除直接 rmtree(P2) | 回收站 + 引用检查 + 资产退役联动 |
| 改名不同步历史、可能覆盖(P3) | 不覆盖;同步 asset_location、path_binding 修订、sidecars;发整理记录 |
| 无通用移动/复制(P4) | 移动/复制到 + 拖拽,统一走可恢复的 organize plan |
| 识别单结果只读,与整理分离(P7) | 候选 Top5 可改选,结果直接进入向导 |
| 批量整理只能一组参数(P8) | 逐项作品/季/集/Slot,按作品分组预览 |
| 递归改名失败即中止(P9) | 全量预览,逐项结果,失败不影响其他 |
| 刮削恒覆盖 NFO(P10) | 字段锁 + 仅对已登记作品 + 非本系统 NFO 不覆盖 |
| 无键盘/拖拽/右键/隐藏文件开关(P11) | 全部提供 |
| 离线显示空目录、大目录全量返回(P6/P12) | 503 显式离线;游标分页 + 截断提示 |
### 3.6 设计纪律
遵守 `docs/design/README.md`:只用语义 token;材质:左栏 `pg-chrome`,列表 `pg-slab`,菜单/对话框 `pg-float`,卡片内空态用 `EmptyState plain`;复用 `PageHeader`、`FilterBar`、`DataTable`(虚拟化版本新增 `virtual` 选项而非另造表格)、`DetailSheet`、`ConfirmDialog`、`StatusBadge`、`EmptyState`;新增共享件 `DirTree`、`PathBreadcrumb`、`ContextMenu`(基于 ui/popover)放 `components/console/`。导航:`nav.ts` 加"文件"工作区。`deno task check:tokens` 必须绿。
## 4. 不变量、测试与实施拆分
### 4.1 数据与安全不变量
1. 任何请求解析出的 realpath 必须在某个登记根之下;`..`、绝对 name、符号链接逃逸、根本身写操作一律拒绝;遍历中每个文件复核(TOCTOU:执行阶段再次 realpath)。
2. 浏览器永远不直接写文件;所有写操作有 audit_event 与幂等键。
3. 路径变更只来自显式操作(rename/move/organize),且同事务更新 asset_location 与 path_binding 修订;元数据刷新不改路径。
4. 删除不影响 qB 任务;纳管做种文件不可改名/删除/Move(除非 H&R 满足且有授权)。
5. 失败≠不存在:离线/超时不改变任何资产状态、不显示为空。
6. 执行只接受 dryRun 产出的 planId,且源文件事实未变。
7. 不覆盖已存在文件;replace 只在整理流程的 `replace_with_verify` 策略下发生。
### 4.2 测试要求
- **路径逃逸**:`..` 段、URL 编码、符号链接指向根外(文件与目录)、`name` 含 `/`、根为 `/` 的配置被过滤;执行期把文件换成符号链接(TOCTOU)。
- **删除保护**:纳管做种文件 409;已登记资产未勾选退役 409、勾选后 location=removed/Slot→attention;根本身拒绝;回收站还原到原位、原位被占 409;purge 只删过期项;离线根拒绝。
- **rename 同步**:文件改名 → asset_location 与 sidecar 同步;目录改名命中绑定 → 新绑定 active、旧绑定 superseded 且链接;之后新集入库进入新目录;同名存在 409 且原文件未动;做种目录 409。
- **dryRun 一致性**:dryRun 与执行对同一输入产出相同文件对;dryRun 后改动源(size/mtime)执行返回 plan_stale;planId 与请求体不匹配 403/409;幂等键重放不重复执行。
- **列表**:分页稳定性、超大目录截断(生成 25 000 个空文件)、离线 503、状态徽标优先级、批量资产关联只发常数次查询。
- **前端**:键盘导航、多选区间、向导步骤门禁(未决项不能下一步)、移动端布局快照;e2e:下载根散文件 → 识别 → 向导 Copy 入库 → 整理记录出现 → 列表状态变"已登记"。
### 4.3 实施拆分(两路并行,contracts 先合并)
| 路 | 范围 | 估计 |
| --- | --- | --- |
| 0. contracts(后端路先做,半天) | `files.ts` schema、`OrganizeRequest` 扩 `fileOverrides/targetDir/keepNames/registerAssets`、organization action 加 `delete`(已有 rename/relocate)、`trash_item` 迁移 | 0.5 d |
| A. 后端 | guard 下沉到 adapters;roots/list/stat/download(含资产/下载关联与分页);mkdir/rename(同步资产+绑定);move/copy/organize 适配 planImport;回收站 + purgeTrash 任务 + 引用检查;identify(sources-identity 拆 rankForPath);probe;scrape(refresh 包装 + NFO 投影);授权矩阵、审计、幂等;§4.2 后端测试 | 5–6 d |
| B. 前端 | 路由与布局、根/树/面包屑、虚拟列表与筛选、详情抽屉、行/右键/批量操作、重命名/移动/删除对话框(含 dryRun 预览)、4 步整理向导、拖拽与键盘、移动端、空态/错误态;前端测试 + e2e | 5 d |
B 可在 contracts 合并后用 MSW/mock 先行;联调 1 d。NFO 投影若排期紧可拆到 W5,scrape 先只做字段刷新。