237 lines
23 KiB
Markdown
237 lines
23 KiB
Markdown
# 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 先只做字段刷新。
|