diff --git a/docs/design/storage-alist.md b/docs/design/storage-alist.md new file mode 100644 index 00000000..2942cf97 --- /dev/null +++ b/docs/design/storage-alist.md @@ -0,0 +1,230 @@ +# Alist / OpenList 存储(对齐 MP v3) + +> 对照源(只读,只参考行为):`/tmp/umt-moviepilot-v3-review-20261001`(v3.0.10-1)`app/modules/filemanager/storages/{__init__,alist,alistgo}.py`、`transhandler.py`(`__transfer_command` 约 1423–1630 行)、`app/chain/scraping.py`(`_save_file` / `_download_and_save_image` → `storagechain.upload_file`)、`app/monitor/{monitor,poller,snapshot}.py`。 +> 真机核对:临时 OpenList v4.2.6 容器(Local 驱动 ×2),接口返回形状见 §2.4。 +> 用户决定(2026-10-06):推翻 `moviepilot-filemanager.md` 末节「网盘暂不做」,按 MP 做 Alist / OpenList(115 直连、rclone 仍不做)。 + +## 0. 结论 + +- 引入最小的「存储」概念:`storage_instance` 表登记多个 Alist / OpenList 实例(地址、永久令牌或账号密码、可选路径密码、根路径、限速)。本地仍走现有 fsproxy,远端走新适配器 `packages/adapters/src/alist/`。 +- **远端路径用虚拟前缀表示**:`/@alist/<存储 id>/`。它是合法的 POSIX 绝对路径,`join / dirname / relative` 照常可用,整理计划、整理记录、资产位置、回收站里的路径都不用换结构;不认识前缀的旧代码对它做本地 IO 只会得到 ENOENT(本机没有 `/@alist`),天然失败安全。认识前缀的地方经 `apps/server/src/storage/vfs.ts` 分派到适配器(MP 用 `u115:/x` 形式的 `FileURI` 表达同一件事)。 +- 媒体库目录、整理来源、文件管理都能选存储(对照 MP 目录配置「下载目录 / 媒体库目录各带 storage」):库根的 `local_path` 存虚拟路径;文件管理多一类根 `st:<存储 id>`(整个实例可浏览);整理来源选这些根即可。 +- 整理矩阵按 MP:本地→Alist 上传、Alist→本地 下载、同实例 Alist→Alist 用 API copy / move、跨实例 下载再上传(MP 不支持,我们补上)。 +- **网盘方向不做核验(用户决定,2026-10-06)**:凡涉及 Alist 的上传、下载、复制、移动,成功判定与 MP 一致——接口成功 + 目标存在即算成功,不比对大小、不比对哈希(不同网盘返回的大小 / 哈希不可靠,容易误判失败);移动在这个判定通过后删源。本地 ↔ 本地仍走现有核验(fsproxy、跨卷 copy → 全量 sha256 → 删源)不变。 +- 刮削目标在 Alist:NFO / 图片本地生成后上传到对应目录,已存在策略与本地一致(MP 也是先写临时文件再 `upload_file`)。 +- 目录监控:Alist 目录作为整理来源,按 MP 快照语义(深度上限、可选按目录修改时间跳过未变子树),复用现有来源快照表与「两次观察稳定」规则。 +- ffprobe:远端文件不下载,直接对签名直链(`raw_url` / `/d/...?sign=`)跑 ffprobe,只读文件头(HTTP Range)。MP 核心整理不做 ffprobe(只留事件给插件),我们本地做了「不是视频」核验,远端沿用同一核验但只读头。 +- 相对 MP 的 11 项改进(协调者补充、用户认可)见 §9;其中 3、5、6、7 的「核验」部分被用户随后的决定取消(网盘方向不核验),其余照做。 + +## 1. MP 的 Alist 能力清单 → 我们的落点 + +`StorageBase` 抽象方法逐项(`alist.py`;`alistgo.py` 只是换了 schema 名的子类,接口完全相同): + +| # | MP 能力 | MP 做法 | 我们 | 落点 | +| --- | --- | --- | --- | --- | +| 1 | 配置 | 单实例(`StorageSchema.Alist` / `AlistGo` 各一份):`url`、`token`、`username`、`password` | 多实例;另加根路径、路径密码、限速、超时、目录监控开关 | `storage_instance` 表、设置 →「存储」 | +| 2 | 令牌 | 永久令牌优先;否则 `/api/auth/login` 换临时 token,缓存 2 天提前 5 分钟(`@cached ttl`) | 同;且遇 401 / token 无效自动重登一次再试(§9-2) | `AlistClient.token()` | +| 3 | check | 能拿到 token 即可用 | `/api/me` + 根路径 `/api/fs/get`;同时取版本 `/api/public/settings` | 测试连接、健康检查 | +| 4 | list | `/api/fs/list`,`per_page=500` 自动翻页(`total` / `pages_total` / `has_more`),`refresh` 透传 | 同;refresh 只在用户点刷新或写后确认目标时带(§9-8) | `AlistClient.list()` | +| 5 | get_item | `/api/fs/get`,失败一律 None | 「不存在」与其它失败分开(§9-1) | `AlistClient.get()` → `AlistNotFound` / `AlistError` | +| 6 | get_item_strict | 只有 `not found / not exist` 当不存在,其余抛 StorageQueryError | 同(这就是我们的缺省语义) | 同上 | +| 7 | create_folder / get_folder | `/api/fs/mkdir`,之后 `_delay_get_item`(sleep 2s×2 + refresh) | `mkdir` 幂等(Alist 本身递归建);不 sleep 轮询 | `AlistClient.mkdir()` | +| 8 | delete | `/api/fs/remove {dir, names}` | 同;注意 Alist 删不存在的名字也返回成功 | `AlistClient.remove()` | +| 9 | rename | `/api/fs/rename {path, name}` | 同;目标同名存在时 Alist 返回 403 `file [x] exists`,映射为冲突 | `AlistClient.rename()` | +| 10 | download | `/api/fs/get` 取 `raw_url`,没有就拼 `/d?sign=`;8 KB 块流式写 `TEMP_PATH/`,失败时**返回半截文件** | Range 断点续传、签名过期重取、写临时文件成功后改名(§9-7) | `AlistClient.download()` | +| 11 | upload | `PUT /api/fs/put`,头 `File-Path`(URL 编码)、`As-Task`(缺省 false)、`Content-Length`、`Last-Modified`、`X-File-Md5/Sha1/Sha256`(秒传;先整读一遍算);超时 6000 s;进度回调;之后 `_delay_get_item` 并按需 rename | 同一套头(三种哈希一遍读出);无进度空闲超时;退避重试;As-Task 等任务完成;**先查同名、传临时名再改名**(Alist 的 put 会静默覆盖同名文件,真机核对过);成功判定 = 接口成功 + 目标存在(同 MP,不核验)(§9-4/5) | `AlistClient.upload()` | +| 12 | detail | = get_item | 同 | — | +| 13 | copy / copy_item | `/api/fs/copy {src_dir,dst_dir,names}`,按原名复制后 rename;之后 sleep 2s×2 看目标 | 等异步任务完成(`/api/task/copy/info?tid=`),没有任务 id 时轮询到目标出现;名字不同走暂存目录;成功判定 = 任务成功 + 目标存在(§9-3/4) | `AlistClient.copyFile()` | +| 14 | move / move_item | 先在源目录 rename 成新名,再 `/api/fs/move` | 暂存目录方案,失败尽量回滚(§9-4);跨驱动 move 也是异步任务,同样等待 | `AlistClient.moveFile()` | +| 15 | link / softlink | 不支持(`pass`) | 不支持;整理方式只给复制 / 移动(MP `transtype = {copy, move}`) | 整理弹窗按存储过滤方式 | +| 16 | usage | 未实现(`pass`) | `/api/admin/storage/list` 的 `mount_details`(total/free/used,驱动支持且令牌是管理员时才有);拿不到显示「未知」不报错(§9-10) | `AlistClient.usage()` | +| 17 | snapshot | 基类通用实现:递归 list,`max_depth=5`,可选按目录 mtime 跳过(OpenList 由 `OPENLIST_SNAPSHOT_CHECK_FOLDER_MODTIME` 控制),保留上次快照里未遍历子树 | 同语义;按实例限速;深度、mtime 开关在实例配置(§8) | `storage/snapshot.ts` | +| 18 | 路径密码 | 接口带 `password` 参数,没有配置入口 | 实例配置可填(加密保存)(§9-11) | `storage_instance.path_password_ref` | +| 19 | AlistGo(alistgo.com 文档的 v3 分支) | 独立 schema、同实现 | 不单列:同一个适配器;实例上记 `flavor`(alist / openlist)只用于展示与版本差异(§2.5) | — | +| 20 | 存储管理动作(扫码登录等 `/storage/manage`) | Alist 无 | 不适用 | — | + +## 2. 适配器 `packages/adapters/src/alist/` + +### 2.1 接口 + +```ts +new AlistClient({ baseUrl, auth: {kind:"token",token} | {kind:"password",username,password} | {kind:"guest"}, + pathPassword?, fetch?, timeoutMs, idleTimeoutMs, retries, qps, concurrency, taskTimeoutMs, logger? }) +.me() .version() .list(path,{refresh}) .get(path,{refresh}) .exists(path) .mkdir(path) .rename(path,newName) +.remove(dir,names) .copyFile(src,dstPath,opts) .moveFile(src,dstPath,opts) .upload(localFile,dstPath,opts) +.download(remotePath,localFile,opts) .rawUrl(path) .usage(path) .waitTask(kind,id,opts) +``` + +错误类型:`AlistNotFound`(`object not found` / `not exist` / `404`)、`AlistConflict`(`exists`)、`AlistAuthError`(HTTP 401/403 或 code 401)、`AlistRateLimited`(429 / `too many` / `rate limit` / `风控`)、`AlistUnavailable`(连不上 / 超时 / 5xx)、`AlistError`(其它业务错误)。`fsCode` 映射到现有 `FsErrorCode`:NotFound→ENOENT、Conflict→EEXIST、Auth→EACCES、RateLimited/Unavailable→TIMEOUT/IO(可重试)、其它→IO。 + +### 2.2 令牌 + +- 永久令牌(Alist 后台「设置 → 其他 → 令牌」)优先,原样放 `Authorization` 头(不加 Bearer,与 MP 一致)。 +- 账号密码:`POST /api/auth/login`(明文密码,与 MP 一致;不用 `/api/auth/login/hash`,避免版本差异)。token 缓存在客户端实例里,TTL 47 h 55 m(MP:2 天提前 5 分钟);实例配置变更时客户端重建,缓存随之作废(MP `init_storage` 删缓存)。 +- 任一请求得到 401 / `token is invalidated` / `token is expired`:账号密码模式清缓存重登**一次**再试;永久令牌模式直接抛 `AlistAuthError`,健康检查显示「令牌失效」。 +- 访客模式(无令牌无账号):只用于只读公开目录,写操作 Alist 自己会拒。 + +### 2.3 限速、超时、重试(§9-8) + +- 每实例一个令牌桶(缺省 5 QPS)+ 并发上限(缺省 4);上传 / 下载数据流不计入 QPS,但计入并发。 +- 普通请求超时 30 s;数据流用**空闲超时**(缺省 120 s 无字节前进即中止,同 fsproxy `hangTimeoutMs` 口径)。 +- 幂等读(list/get/me/task info)与可安全重放的写(mkdir)对可重试错误(连不上、5xx、429/风控)指数退避重试,缺省 3 次(0.5 s→1 s→2 s,429 起步 5 s);rename/remove/copy/move 不自动重试(调用方按整理账本重试并先对账)。上传 / 下载在流式中断时按 §2.6/2.7 续传或重传。 +- 日志只记方法、路径、Alist code 与耗时;令牌、密码、`sign`、`raw_url` 查询串一律 redact(`redactAlist`),测试覆盖。 + +### 2.4 真机接口形状(OpenList v4.2.6) + +| 场景 | 返回 | +| --- | --- | +| 不存在 | HTTP 200,`{"code":500,"message":"failed to get obj: object not found"}` | +| 令牌无效 | HTTP 200,`{"code":401,"message":"token is invalidated"}` | +| copy(v4 一律异步,含同驱动) | `{"code":200,"data":{"message":"Successfully created 1 copy task(s)","tasks":[{"id":"…","state":0,…}]}}` | +| copy 目标目录不存在 | `{"code":500,"message":"failed to get dst dir: …object not found"}` → 先 mkdir | +| copy / rename 目标已存在 | `{"code":403,"message":"file [x.bin] exists"}` | +| move 同驱动 | `{"code":200,"data":{"message":"Move operations completed immediately"}}` | +| move 跨驱动 | 同 copy,返回 tasks | +| put(As-Task: false) | `{"code":200,"data":null}`;**目标已存在时静默覆盖** | +| put(As-Task: true) | `data.task {id, state, total_bytes}` | +| 任务查询 | `POST /api/task/{copy\|move\|upload}/info?tid=` → `state` 0 待执行 / 1 执行中 / 2 成功 / 3 取消中 / 4 已取消 / 5 出错 / 6 失败中 / 7 失败 / 8 等待重试 / 9 重试前;`error` 为错误文本;不存在 `{"code":404,"message":"task not found"}` | +| 下载 | `/d/?sign=` 支持 Range(206);签名错 → 401 | +| remove 不存在的名字 | 返回成功(调用方不能据此判断删除了东西) | + +### 2.5 Alist v3 与 OpenList v4 的差异 + +| 项 | Alist v3(≥3.25) | OpenList v4 | 处理 | +| --- | --- | --- | --- | +| copy / move 返回 | 同驱动常为同步、`data:null`;新版本返回 `tasks` | 返回 `tasks`(move 同驱动立即完成) | 有 `tasks` 等任务;没有则按「目标存在且大小与源一致」退避轮询(§9-3) | +| 任务接口 | 旧版在 `/api/admin/task/copy/info`(管理员),新版 `/api/task/…` | `/api/task/…` | 先试 `/api/task/`,404 再试 `/api/admin/task/`,都不行退回大小轮询 | +| `filtered_total` / `pages_total` / `has_more` | 部分版本没有 | 有 | 都按 MP 的判断链兼容 | +| `mount_details`(容量) | 无 | 驱动支持时有 | 无则「未知」 | +| `hash_info` | 有(驱动给才非空) | 同 | 不用于判定(用户决定网盘方向不核验) | +| 版本号 | `/api/public/settings` `version` | 同(如 `v4.2.6 (Commit …)`) | 存进实例 `version_info` 展示 | + +### 2.6 上传(§9-4/5) + +1. 一遍读出本地文件的 md5 / sha1 / sha256(同一个读流),只作秒传头(照 MP 发);sha256 顺带记为整理记录的 `checksum`(来自本地源,不拿来比对远端)。 +2. 目标已存在:按调用方策略(整理是「冲突」或「替换」)——**不依赖 put 的覆盖**。 +3. 传到同目录临时名 `.uhd-partial`(As-Task 与 MP 缺省一致为 false;实例可改 true,此时轮询任务直到完成)。读流带进度回调、空闲超时、AbortSignal。 +4. 成功判定与 MP 一致:接口成功 + `get(临时名, refresh=true)` 能取到即成功,不比对大小 / 哈希(用户决定)。 +5. `rename` 成目标名(替换时先把旧文件移走,见 §6.3)。失败删除临时名。 +6. 失败按可重试错误退避重传(整文件重传:Alist 的 put 不支持续传);重试次数用实例配置。 + +### 2.7 下载(§9-7) + +1. `get(path)` 取 `raw_url`(没有就 `/d?sign=`)。 +2. 写到 `<目标>.uhd-partial`;已有 partial(上次中断)时 `Range: bytes=<已有>-` 续传,服务端不给 206 就从头写。 +3. 401/403/410(签名过期)→ 重新 get 取新链接再续,最多 3 次;其它可重试错误退避续传。 +4. 流正常结束即成功(与 MP 一致,不比对大小 / 哈希,用户决定);边写边算 sha256 只记为 `checksum`(本地文件的事实,不用于判定)。 +5. 成功后 `link + unlink`(不覆盖,同 `commitNoClobber`)改成目标名;中断保留 partial 供续传,24 h 后由现有 partial 清理带走。 + +## 3. 存储抽象(服务端) + +- 表 `storage_instance`(迁移 0090):`id, name, kind('alist'), flavor('alist'|'openlist'), base_url, root_path, auth_kind('token'|'password'|'guest'), username, secret_ref, path_password_ref, enabled, options jsonb, health_status, last_health_at, health_detail, version_info, created_at, updated_at`。凭据走现有 `secret` 表(`SecretPurpose` 加 `storage_credential`),API 只写不读(与下载器 / 媒体服务器同语义:省略或空串不变、null 清除)。 +- `options`:`qps`(5)、`concurrency`(4)、`timeoutMs`(30000)、`idleTimeoutMs`(120000)、`taskTimeoutMs`(21600000)、`retries`(3)、`uploadAsTask`(false)、`snapshotMaxDepth`(5)、`snapshotCheckFolderModtime`(true,MP 本地缺省 true;OpenList 在 MP 里是开关、缺省关——我们缺省开并可关)。 +- `apps/server/src/storage/`: + - `registry.ts`:按 id 取实例 + 解密凭据 → `AlistClient`(按 `updated_at` 缓存,配置一改就重建)。 + - `vpath.ts`:`/@alist//` 编解码;`displayPath()` 给界面「存储名:/路径」。 + - `vfs.ts`:本地 / 远端统一的 `lstat / realpath / readdir / statSafe / checkRoot / mkdirp / readFile / writeFileAtomic / rename / move / remove`;本地分支原样调用 node:fs / fsproxy。 + - `transfer.ts`:整理矩阵(§5)。 +- 不为抽象重写本地:本地路径照旧走 fsproxy(挂死隔离、跨卷 copy → 核验 → 删源全部不变);只在会遇到远端路径的调用点换成 vfs。 + +## 4. 设置与界面 + +- 设置 → 媒体库与目录 →「存储」:列表 + 新增 / 编辑(名称、类型 Alist/OpenList、地址、根路径、认证方式 令牌 / 账号密码 / 访客、令牌或密码、路径密码、高级:限速 / 并发 / 超时 / 上传任务模式 / 监控深度 / 按目录时间跳过)+「测试连接」(显示版本、当前用户、根目录是否可写、容量)。 +- 媒体库目录表单多一个「存储」下拉(本机 / 各 Alist 实例);选 Alist 时路径为 Alist 内路径(可点浏览),不写哨兵文件,健康 = 根目录可访问。 +- 文件管理:左栏「存储」组(对照 MP 存储切换菜单),每个启用实例一个根 `st:`(从实例根路径开始);Alist 库根照常出现在「媒体库」组。操作:浏览、新建目录、改名、删除、移动到 / 复制到、上传、下载、整理、刮削。 +- 整理来源:来源目录可选 Alist 根;触发方式只能「手动 / 目录快照」(qB 标签对远端无意义)。 + +## 5. 整理矩阵 + +| 源 → 目标 | 复制 | 移动 | 成功判定 | MP | +| --- | --- | --- | --- | --- | +| 本地 → 本地 | 现有(fsproxy、跨卷 copy → sha256 → 删源) | 现有 | 现有核验(不变) | 同 | +| 本地 → Alist | 上传(§2.6) | 上传成功后删本地源;删源失败 = 「已整理,源未删」待处理(同跨卷) | 接口成功 + 目标存在 | 同(上传成功即删源) | +| Alist → 本地 | 下载(§2.7) | 下载成功后删远端源;删源失败同上 | 下载流完整结束(临时文件改名成功) | 下载后 `SystemUtils.move`;失败可能留半截 | +| Alist → Alist 同实例 | API copy(等任务) | API move(等任务) | 任务成功 + 目标存在 | 同(不等任务,sleep 后看目标) | +| Alist → Alist 跨实例 | 下载到 `DATA_DIR/cache/storage-relay/` → 上传 → 删暂存 | 同,上传成功后删远端源 | 两段各自的判定 | **不支持**(`不支持 X 到 Y 的文件整理`) | +| 原地整理(Alist 库根) | 只登记,不动文件 | — | 文件存在 | MP 无 | + +- 硬链接 / 软链接:远端不支持(MP 同),弹窗只给复制 / 移动;原地整理不受影响。 +- 跨卷开关 `allowCrossVolumeMove` 只管本地跨卷;涉及远端的移动一律「复制 / 上传 / 下载成功 → 删源」,不受该开关限制(与 MP 一致:网盘移动就是上传后删源)。 +- 网盘方向不核验大小 / 哈希是用户决定(不同网盘返回的大小 / 哈希不可靠);本地 ↔ 本地仍走现有核验。 +- 失败路径(都有测试):上传中断 / 接口失败 / 上传后目标取不到 → 删远端临时文件、**不删源**;下载中断 → 保留 partial、不删源;删源失败 → 记录 attention;可重试错误(连不上、限流、超时)走整理账本现有的「未到上限不入账、任务重试」。 +- 整理记录、失败重试、智能体兜底识别(识别不到作品自动派)、H&R / 下载器引用检查都照旧:它们只看路径字符串与账本;远端源不会有下载器引用,自然放行。 +- 计划阶段:源 / 目标的 stat、目标冲突、同尺寸是否同一文件(本地用 sha256;远端只在两边 hash_info 有同类哈希时判同,否则按冲突)都走 vfs。 +- 撤销:整理记录「删除」可连带删源 / 媒体库文件——Alist 上的文件移进该存储根下的 `.umt-trash/`(同本地布局,回收站可还原);本地→Alist 的移动撤不回本地源(源已删),说明里写清。 + +## 6. 核验、回收站、替换与 fsproxy 的关系 + +### 6.0 核验口径 + +- 本地 ↔ 本地:现有核验不变(复制按 `verifyHash`,跨卷移动删源前全量 sha256)。 +- 涉及 Alist 的方向:不比对大小 / 哈希(用户决定)。判定只看接口成功与目标存在;整理记录里的 `checksum` 在能顺带得到本地文件 sha256 时记上(上传的源 / 下载的结果),只是事实记录。 + +### 6.1 fsproxy + +本地 IO 仍全部经 fsproxy 子进程(挂死隔离)。远端 IO 在主进程里经 fetch:网络挂死由空闲超时兜住,不会卡系统调用。跨存储传输中「读本地 / 写本地」一侧直接用流(上传读本地文件、下载写本地 partial),其挂死同样由空闲超时判定。 + +### 6.2 回收站 + +文件管理删除、整理记录删除连带的文件、替换下来的旧文件:Alist 上与本地同布局 `<根>/.umt-trash///<相对路径>`,用 Alist 同驱动 move(立即完成)。还原 = move 回去;清除 / 过期 = remove。跨根不需要(回收站总在同一存储根内)。MP 无回收站,Alist 驱动自己的回收站(如 Local 的 `recycle_bin_path`)不依赖。 + +### 6.3 替换(目标已存在且选「替换」/ 洗版) + +本地:现有原子替换。Alist:旧文件先 move 到 `<根>/.umt-trash/replacement-staging/…`,新文件按 §2.6 上传 / 或 copy 到位,成功后旧文件登记回收站;失败把旧文件 move 回原处。MP「总是覆盖」是先删目标再传,我们保留旧文件可还原。 + +### 6.4 「不是视频」隔离 + +ffprobe(§7)判定没有视频流时,远端新文件同样 move 进 `.umt-trash` 并登记回收站。 + +## 7. ffprobe + +- 远端文件:`AlistClient.rawUrl()` 取签名直链,`ffprobe -rw_timeout 30000000 `;ffprobe 只读容器头与少量包(HTTP Range),不全量下载。直链进日志前 redact。 +- 本地 → Alist:收尾探测目标(远端 URL);URL 探测失败(驱动不给直链、302 到需要 Cookie 的网盘)时退回探测本地源(复制时还在;移动时在删源前已于执行阶段对源探测并缓存)。都拿不到 → 探测结果「未知」,不阻塞(同本地 ffprobe 失败的口径)。 +- MP:核心整理不做 ffprobe,只有 `TransferRenameEventData` 给插件。我们比 MP 多的「不是视频」核验对远端照样生效。 + +## 8. 目录监控(远端快照) + +- 来源配置选 Alist 根 + 触发方式「目录快照」:`walkSource` 对远端走 `storage/snapshot.ts`:BFS 列目录,深度 ≤ `snapshotMaxDepth`(MP 5);`snapshotCheckFolderModtime` 开时,子目录 `modified` 不晚于上次扫描时间就沿用上次快照里该子树的文件(根目录每轮都列,用于发现删除,同 MP);列表失败的子树保留旧快照(MP「查询异常时保留基线」)。 +- 之后完全复用现有语义:首扫建基线不整理;新文件 / 变化文件需连续两次观察大小与修改时间一致才整理;结果按现有批次识别上限分批;失败写 `organize_source_snapshot.last_error`,保留旧快照。 +- 扫描间隔沿用来源配置 `intervalSec`(MP 按文件数自适应 `adjust_interval`;我们已有逐来源间隔,不再自适应)。 +- 按实例限速:快照的 list 走同一个令牌桶,不会打爆网盘。 + +## 9. 相对 MP 的改进(协调者补充,用户认可) + +| # | MP 缺陷 | 我们 | 测试 | +| --- | --- | --- | --- | +| 1 | 网络错误 / 非 200 / code≠200 都返回 None,调用方当「不存在」,可能重复上传或误判 | 不存在(`object not found`)抛 `AlistNotFound`;连接失败 / 鉴权 / 服务端错误是其它错误类型;vfs 映射为 ENOENT 与 IO/TIMEOUT/EACCES;整理遇到后者按可重试失败,不当不存在 | `alist/client.test.ts`「错误分类」、整理「连不上不当不存在」 | +| 2 | token 固定缓存 2 天,不处理 401 | 401 / token 无效自动重登一次再试;永久令牌失效直接报鉴权错误并进健康检查 | 「令牌失效重登」「永久令牌失效」 | +| 3 | copy/move 跨驱动是异步任务,MP sleep 2s×2 后 get,大文件必误判 | 有任务 id 就轮询任务(指数退避 0.5 s→最大 10 s,超时可配,缺省 6 h),失败拿任务 `error`;无任务 id 轮询到目标出现为止 | 「异步任务成功 / 失败 / 超时」「无任务 id 等目标出现」 | +| 4 | 先按原名复制再改名,目标有同名会冲突 / 覆盖 | 执行前查目标冲突(按整理冲突策略);名字不同或原名在目标目录已被占用时先放暂存目录 `.umt-stage-` 改名再移入,失败清理暂存 | 「改名复制走暂存」「目标冲突不覆盖」 | +| 5 | 上传前整读一遍算三种哈希;PUT 绝对超时 6000 s;无重试 | 三种哈希同一遍读出(只作秒传头,照 MP);空闲超时;退避重试;As-Task 轮询任务到完成 | 「上传进度 / 中断 / 重试 / As-Task」 | +| 6 | 上传后只确认目标存在 | **保持 MP 判定(用户决定)**:接口成功 + 目标存在即成功,不比对大小 / 哈希;只是确认目标时不靠 sleep | 「上传后目标取不到不删源」 | +| 7 | 下载无续传、raw_url 过期不处理、可能留半截文件当成品 | Range 续传、签名过期重取再续、先写临时文件成功后改名(不比对大小 / 哈希,用户决定) | 「下载续传 / 签名过期 / 中断不改名」 | +| 8 | `_delay_get_item` 每次 refresh=true,穿透网盘 API 易触发风控 | 只在写后确认目标与用户手动刷新时 refresh;每实例 QPS + 并发限制(可配,缺省保守),限流 / 风控退避 | 「refresh 只在需要时」「限流退避」 | +| 9 | 快照每次整树递归 | 深度上限、按目录 mtime 跳过未变子树、按实例限速、结果分批 | 「快照深度 / mtime 跳过 / 列表失败保留」 | +| 10 | usage 未实现 | `mount_details` 能拿到就显示,拿不到「未知」不报错 | 「容量未知不报错」 | +| 11 | 路径密码无配置入口 | 实例可配(加密保存),list/get 带上;日志 redact | 「路径密码透传与 redact」 | + +## 10. 不做的部分与理由 + +| MP 有 | 不做 | 理由 | +| --- | --- | --- | +| Alist 的硬链接 / 软链接整理方式 | 不做 | MP 本身也不支持(`link/softlink` 为空实现,`transtype` 只有复制 / 移动) | +| AlistGo 作为独立存储类型 | 合并 | 接口完全相同(MP 子类只改了 schema 名);实例上记 `flavor` 展示即可 | +| 远端目录监控的按文件数自适应间隔(`adjust_interval`) | 不做 | 我们已有逐来源的扫描间隔配置,再自适应会让用户设的间隔失效 | +| 远端监控「启动前已存在的文件不处理」以外的整理存量 | 已有 | 「整理存量」对远端同样可用(这不是不做,列此说明口径一致) | +| 115 / 阿里云盘 / SMB / rclone 存储 | 不做 | 用户只要求 Alist / OpenList;115 等可以挂到 Alist 下用 | +| 下载到浏览器时先落 MP 临时目录再回传 | 改为流式代理 | 不占本地盘;仍只允许管理员会话 | + +## 11. 迁移 + +- `0090_storage_instance`:`storage_instance` 表;`secret_purpose` 枚举加 `storage_credential`。 +- `0091` 未使用(预留;整理来源快照沿用 `organize_source_snapshot`,库根沿用 `library_root.local_path` 存虚拟路径,不需要新列)。 + +## 12. 测试 + +- 适配器:`packages/adapters/src/alist/*.test.ts`,进程内 mock Alist(`mock-server.ts`)覆盖全部接口、错误码、令牌失效重登、异步任务、上传下载进度与中断、续传、签名过期、限流。 +- 服务端:整理矩阵每个方向复制 / 移动各一条端到端(mock 存储),含「上传中断不删源」「上传后目标取不到不删源」「下载中断不改名不删源」;刮削写远端;目录监控发现远端新文件;文件管理各操作路由。 +- 前端:存储设置表单、文件管理存储切换。 +- 真机:`*.real.test.ts`,`UMT_ALIST_REAL_URL` / `UMT_ALIST_REAL_TOKEN` 等环境变量开启,缺省跳过;跑本地 → OpenList → 本地往返。 +- 真机验收(用户不提供真实 Alist,协调者自起 OpenList 容器):步骤见 `docs/runbook/storage-alist.md`;脚本 `scripts/alist-e2e.ts` 可对任意 base URL + 令牌跑往返与 Alist 内移动。