mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
637 lines
36 KiB
Markdown
637 lines
36 KiB
Markdown
# 115 云下载 → STRM 同步 → 自动刮削 · 实施方案
|
||
|
||
| 项 | 内容 |
|
||
| :--- | :--- |
|
||
| 状态 | 设计已定稿,待实现(P0 未开工) |
|
||
| 适用版本 | MeBox 当前主线(`internal/service/strm_*`、`internal/service/cloud115/*`) |
|
||
| 关联文档 | `115doc/115开放平台/API列表/云下载/*`(仓库内离线文档) |
|
||
| 最后核对 | 本文所有代码事实均按仓库当前代码逐条核对,行号仅供定位参考 |
|
||
|
||
---
|
||
|
||
## 1. 目标与范围
|
||
|
||
### 1.1 目标
|
||
|
||
在「115 媒体库映射」(即 STRM 管理中的一条 `StrmSyncPath`:115 目录 → 本地目录 .strm)之上提供云下载入口,用户提交磁力 / BT 种子 / ed2k / HTTP 链接后:
|
||
|
||
1. 任务提交到 115 离线(云下载);
|
||
2. 后台轮询 115 任务状态;
|
||
3. 任务完成后**自动触发**该同步目录的 STRM 增量同步;
|
||
4. 同步完成后**自动扫描**对应媒体库,生成 Media 行;
|
||
5. **自动刮削**新增媒体,无需用户再点一次。
|
||
|
||
全程无需离开 MeBox,链路上的每个阶段在任务列表里可见。
|
||
|
||
### 1.2 明确不做(本期范围外)
|
||
|
||
| 项 | 决定 | 原因 |
|
||
| :--- | :--- | :--- |
|
||
| 磁力链接选择部分文件下载 | **不做** | 开放平台 `/open/offline/add_task_urls` 无文件选择参数,且 `/open/offline/torrent` 只接受已上传种子的 `torrent_sha1` + `pick_code`,无法解析 magnet。见第 6 节 |
|
||
| `.torrent` 勾选文件 UI | **不做** | 用户已确认本期不做选文件;种子按全选提交 |
|
||
| 磁力转种子(DHT 抓 metadata) | **不做** | 需引入 torrent 依赖与常驻 DHT 网络行为,本期不引入新依赖 |
|
||
| 非 115 网盘的离线下载 | **不做** | CD2 / OpenList / 本地目录无对应能力 |
|
||
| 普通用户自助使用 | **不做** | 首期仅管理员(沿用 `AdminRequired` + `can_manage_strm` 语义),权限位见 9.3 |
|
||
| 下载完成后自动整理/重命名(Organize) | **不做** | 与 STRM 映射模型冲突,`.strm` 必须与远端路径一一对应 |
|
||
|
||
### 1.3 非目标(但需保证不被破坏)
|
||
|
||
- 现有 STRM 手动同步 / Cron 同步 / 下载队列 / 上传队列行为不变;
|
||
- 115 令牌自动刷新机制不得被第二个客户端实例破坏(见 3.3 关键约束 K1);
|
||
- 刮削不重复刷已 `matched` 的媒体(沿用 `EnqueueLibrary` 的候选过滤)。
|
||
|
||
---
|
||
|
||
## 2. 现状核实
|
||
|
||
### 2.1 已具备
|
||
|
||
| 能力 | 位置 | 说明 |
|
||
| :--- | :--- | :--- |
|
||
| 115 OpenAPI 客户端 | `internal/service/cloud115/client.go`、`const.go:16` | `OpenClient`,`ProAPIBase = https://proapi.115.com`,`doJSON` 内建重试与令牌刷新 |
|
||
| 115 授权(PKCE/扫码)与令牌落库 | `cloud115/oauth.go`、`service/strm_115_oauth.go` | `OnTokenRefreshed` → `persist115Tokens` 写回 `StrmAccount.Config` |
|
||
| 115 文件上传 / 秒传 | `cloud115/upload.go:217` | `Upload(ctx, filePath, parentCID, signKey, signVal)` → `UploadCompleteResult{FileId, PickCode, Sha1}` |
|
||
| 115 目录创建 / 查找 | `cloud115/upload.go:318`(`MkDir`)、`cloud115/open.go:126`(`FindNamedContentInParent`) | 可用于建「云下载/种子文件」暂存目录 |
|
||
| 115 全局限流/熔断 | `cloud115/throttle_manager.go` | `GetGlobalThrottleManager()` |
|
||
| STRM 同步(增量/全量) | `service/strm_sync.go:82` `StartSync` | 已运行时返回「该目录正在同步中」,不排队 |
|
||
| STRM 目录/记录/队列数据层 | `model/strm.go`、`repository/strm_repository.go` | — |
|
||
| 扫描 + 自动刮削入口 | `service/scanner_scan.go`、`scanner_post_scan.go:19` | `startAutoScrape` 受 `scrape.auto_on_scan` 全局开关约束 |
|
||
| 刮削入队 | `service/scraper_queue.go:219` `EnqueueLibrary` | 仅挑 `scrape_status` 为空的 `pending` 行,天然幂等、不会重刷已匹配 |
|
||
| 周期任务框架 | `service/scheduler.go:118` `scheduledJob` | 新增 job 只需在 `Start` 里 append |
|
||
| 服务装配 | `service/service_builder.go:148` | `NewStrmService` 之后才有 `Cloud115`,可作为依赖注入范式 |
|
||
|
||
### 2.2 缺口(本方案要补的)
|
||
|
||
| 缺口 | 影响 |
|
||
| :--- | :--- |
|
||
| 全仓无任何离线/云下载代码(`grep -r "offline" internal/service/cloud115/` 无命中) | 需从零封装 4 个 API |
|
||
| `finishSync` 结束后**没有任何回调**(`strm_sync.go:323`) | 「同步完成 → 扫描」链路是断的,必须新增 hook |
|
||
| 无「本地路径 → 媒体库」反查 | 需新增 repository 查询 |
|
||
| 无 `.torrent` multipart 上传端点(现有仅 reader 书源、DB 恢复用到 `FormFile`) | 需新增端点 + 上传大小限制 |
|
||
| 无云下载任务表 | 需新增模型 + 仓储 + 迁移注册 |
|
||
|
||
---
|
||
|
||
## 3. 总体设计
|
||
|
||
### 3.1 链路时序
|
||
|
||
```
|
||
[前端] 提交(多行链接 / .torrent 文件)+ 选择目标 STRM 同步目录
|
||
│
|
||
▼
|
||
[CloudDownloadService.Submit]
|
||
│ 1) 校验账号可用、同步目录启用
|
||
│ 2) 配额预检 GET /open/offline/get_quota_info
|
||
│ 3) .torrent: 上传到「云下载/种子文件」→ POST /open/offline/torrent 拿 info_hash
|
||
│ 4) POST /open/offline/add_task_urls(批量 ≤115/批)或 /open/offline/add_task_bt
|
||
▼
|
||
[DB] CloudDownloadTask: status=submitted(逐条记录每个链接的受理结果)
|
||
│
|
||
▼
|
||
[Scheduler: cloud_download_poll 每 60s]
|
||
│ GET /open/offline/get_task_list?page=N 逐页
|
||
│ 按 info_hash(优先)/ url(回退)匹配本地任务
|
||
│ status 0/1 → downloading(percentDone) | 2 → succeeded | -1 → failed
|
||
▼
|
||
[Pipeline: succeeded 任务]
|
||
│ A. 收集受影响 syncPathID(30s 去抖合并)
|
||
│ B. StrmService.StartSync(syncPathID, incremental)
|
||
▼
|
||
[StrmService.finishSync] → SetSyncFinishHook(ok)
|
||
│ C. 反查 LocalPath 对应的媒体库 → Scanner.ScanLibrary(libraryID)
|
||
│ D. Scraper.EnqueueLibrary(libraryID, 默认 options)(仅 pending 行)
|
||
▼
|
||
[DB] 回写 Stage=done;前端列表展示各阶段结果
|
||
```
|
||
|
||
### 3.2 模块职责
|
||
|
||
| 模块 | 职责 | 明确不负责 |
|
||
| :--- | :--- | :--- |
|
||
| `cloud115/offline.go` | 纯 API 封装:参数拼装、响应解析、错误码映射 | 不碰数据库、不做编排 |
|
||
| `CloudDownloadService` | 输入解析、校验、提交、状态机、轮询、编排 | 不直接读写 .strm 文件 |
|
||
| `StrmService` | 现有同步逻辑 + 新增完成回调 | 不感知云下载概念 |
|
||
| `ScannerService` / `ScraperService` | 现有扫描/刮削逻辑 | 不感知云下载概念 |
|
||
|
||
### 3.3 关键约束(实现时必须遵守)
|
||
|
||
**K1 — 必须复用 StrmService 已缓存的 115 客户端,禁止自行 `NewOpenClient`。**
|
||
`strm_service.go:524` 的 `newProvider` 里给 `OpenClient` 挂了 `OnTokenRefreshed` → `persist115Tokens`。115 的 `refresh_token` 是一次性轮转的:若云下载服务另建一个客户端,两边各自刷新会互相作废,最终把账号刷成「授权已失效」。
|
||
|
||
落地方式:给 `StrmService` 增加导出方法
|
||
|
||
```go
|
||
// Open115Client 返回账号已缓存的 115 客户端(含令牌自动刷新与落库)。
|
||
func (s *StrmService) Open115Client(ctx context.Context, accountID string) (*cloud115.OpenClient, error)
|
||
```
|
||
|
||
内部复用 `providerFor` + `cloud.OpenAPI115Provider` 断言(与 `strm_sync.go:379` 的用法一致)。
|
||
|
||
**K2 — 去抖。** `walk115Flat`(`strm_sync.go:682`)是整树自适应拉取,不是只看新目录。多个任务在短时间内完成时,同一 syncPath 只应同步一次。
|
||
|
||
**K3 — 阶段失败隔离。** STRM 同步失败不得把 115 下载状态改成失败;反之亦然。下载状态与后处理阶段分别落在不同字段(见 4.1)。
|
||
|
||
**K4 — 同步不排队。** `StartSync` 在运行中直接报错,Pipeline 必须自己实现「等待 + 重投」,不能靠重试打满日志。
|
||
|
||
**K5 — 所有 115 调用走全局 throttle。** 轮询在无活跃任务时不得发起请求。
|
||
|
||
---
|
||
|
||
## 4. 数据模型
|
||
|
||
### 4.1 `model.CloudDownloadTask`(新表 `cloud_download_tasks`)
|
||
|
||
```go
|
||
// internal/model/cloud_download.go
|
||
type CloudDownloadTask struct {
|
||
Base
|
||
AccountID string `gorm:"size:36;index" json:"account_id"`
|
||
SyncPathID string `gorm:"size:36;index" json:"sync_path_id"` // 绑定的 STRM 同步目录
|
||
LibraryID string `gorm:"size:36;index" json:"library_id"` // 由 LocalPath 反查得到,可为空
|
||
Provider string `gorm:"size:32" json:"provider"` // 固定 cloud115
|
||
|
||
SourceType string `gorm:"size:16;index" json:"source_type"` // magnet / torrent / ed2k / http / ftp
|
||
SourceURL string `gorm:"type:text" json:"source_url"` // 原始链接(.torrent 时存文件名)
|
||
SourceKey string `gorm:"size:512;index" json:"source_key"` // 归一化后的匹配键:大写 info_hash 或 trim 后的 url
|
||
InfoHash string `gorm:"size:64;index" json:"info_hash"`
|
||
TorrentName string `gorm:"size:512" json:"torrent_name,omitempty"`
|
||
TorrentSha1 string `gorm:"size:64" json:"torrent_sha1,omitempty"` // .torrent 路径用
|
||
PickCode string `gorm:"size:128" json:"pick_code,omitempty"` // .torrent 路径用
|
||
|
||
WpPathID string `gorm:"size:64" json:"wp_path_id"` // 115 目标目录 ID(= syncPath.RemotePath)
|
||
SavePath string `gorm:"size:512" json:"save_path,omitempty"` // 相对 wp_path_id 的子路径(本期留空)
|
||
|
||
Status string `gorm:"size:16;index" json:"status"` // queued/submitted/downloading/succeeded/failed/canceled
|
||
Percent int `json:"percent"`
|
||
SizeBytes int64 `json:"size_bytes"`
|
||
Error string `gorm:"size:1024" json:"error"`
|
||
|
||
Stage string `gorm:"size:16;index" json:"stage"` // pending/waiting_sync/syncing/scanning/scraping/done/na
|
||
StageError string `gorm:"size:1024" json:"stage_error"`
|
||
StrmRecordID string `gorm:"size:36" json:"strm_record_id,omitempty"`
|
||
ScanAdded int `json:"scan_added"`
|
||
ScrapeQueued int `json:"scrape_queued"`
|
||
|
||
RetryCount int `json:"retry_count"`
|
||
LastPollAt *time.Time `json:"last_poll_at,omitempty"`
|
||
NextPollAt *time.Time `gorm:"index" json:"next_poll_at,omitempty"`
|
||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||
}
|
||
```
|
||
|
||
注册点:`model.AllModels()`(`internal/model/model.go:37`)、`repository.Container`(`internal/repository/repository.go:11`)、`service.Container`(`internal/service/service.go:19`)。
|
||
|
||
> **不复用 `StrmDownloadTask`**:那是「远端元数据 → 本地」的文件搬运队列(`model/strm.go`),语义完全不同,混用会让 `/admin/strm/downloads` 列表出现云下载任务。
|
||
|
||
### 4.2 状态机
|
||
|
||
**Status(115 下载侧)**
|
||
|
||
| 值 | 进入条件 | 终态 |
|
||
| :--- | :--- | :--- |
|
||
| `queued` | 本地已建行,尚未提交 115 | 否 |
|
||
| `submitted` | 115 已受理(`data[].state=true`) | 否 |
|
||
| `downloading` | `get_task_list` 返回 status 0 或 1 | 否 |
|
||
| `succeeded` | status 2 | **是**,进入 Pipeline |
|
||
| `failed` | status -1 / 提交被拒 / 超时(默认 72h) | **是** |
|
||
| `canceled` | 用户取消 | **是** |
|
||
|
||
**Stage(后处理侧)**
|
||
|
||
| 值 | 含义 |
|
||
| :--- | :--- |
|
||
| `pending` | 尚未开始(未成功) |
|
||
| `waiting_sync` | 已成功,等待去抖窗口结束或等待同一目录的同步结束 |
|
||
| `syncing` | STRM 增量同步执行中 |
|
||
| `scanning` | 媒体库扫描中 |
|
||
| `scraping` | 刮削已入队 |
|
||
| `done` | 全链路完成 |
|
||
| `na` | 同步目录已删除/禁用,跳过后续(不算失败) |
|
||
| `failed` | 阶段失败,`StageError` 记录原因 |
|
||
|
||
> Stage 失败**不回滚** Status;用户可对 Stage 失败的任务点「重做后处理」。
|
||
|
||
---
|
||
|
||
## 5. 115 开放平台对接
|
||
|
||
### 5.1 接口清单
|
||
|
||
| 用途 | 方法 | 路径 | 关键参数 | 关键返回 |
|
||
| :--- | :--- | :--- | :--- | :--- |
|
||
| 添加链接任务 | POST | `/open/offline/add_task_urls` | `urls`(换行分隔,≤115 条)、`wp_path_id` | `data[]{state,code,message,info_hash,url}` |
|
||
| 解析 BT 种子 | POST | `/open/offline/torrent` | `torrent_sha1`、`pick_code` | `data{file_size,torrent_name,file_count,info_hash,torrent_filelist[]{size,path,wanted}}` |
|
||
| 添加 BT 任务 | POST | `/open/offline/add_task_bt` | `info_hash`、`wanted`、`save_path`、`torrent_sha1`、`pick_code`、`wp_path_id` | `data[]`(成功为空数组) |
|
||
| 任务列表 | GET | `/open/offline/get_task_list` | `page` | `data{page,page_count,count,tasks[]{...}}` |
|
||
| 配额信息 | GET | `/open/offline/get_quota_info` | 无 | `data{package[]{surplus,used,count,name,expire_info[]},count,surplus,used}` |
|
||
| 删除任务(P2) | POST | `/open/offline/del_task` | `info_hash`、`del_source_file` | `data[]` |
|
||
| 清空任务(P2) | POST | `/open/offline/clear_task` | `flag`(0~5) | `data[]` |
|
||
|
||
请求形态:`add_task_*` / `del_task` / `clear_task` / `torrent` 均为 **multipart/form-data**;`get_*` 为 GET query。全部走 `Authorization: Bearer <access_token>`,即现有 `doAuthJSON` 通道。
|
||
|
||
### 5.2 任务列表字段(轮询用)
|
||
|
||
| 字段 | 类型 | 用途 |
|
||
| :--- | :--- | :--- |
|
||
| `info_hash` | string | 首选匹配键 |
|
||
| `url` | string | 回退匹配键(链接任务) |
|
||
| `status` | int | -1 失败 / 0 分配中 / 1 下载中 / 2 成功 |
|
||
| `percentDone` | int | 进度(**注意驼峰**,见 5.4) |
|
||
| `size` | int | 任务总字节 |
|
||
| `name` | string | 任务名,用于 UI |
|
||
| `file_id` / `delete_file_id` | string | 完成后源文件/文件夹 ID(P2 裁剪用) |
|
||
| `wp_path_id` | string | 落盘父目录,可用于校验目录一致性 |
|
||
| `add_time` / `last_update` | int | 超时判断 |
|
||
|
||
### 5.3 错误码映射(要落到用户可读文案)
|
||
|
||
| 码 | 115 文案 | MeBox 提示 |
|
||
| :--- | :--- | :--- |
|
||
| `1000012` | 云下载配额已用完 | 「115 云下载配额已用尽,请在 115 端购买/领取配额后重试」 |
|
||
| `91006` | 存储空间不足 | 「115 存储空间不足」 |
|
||
| `20018` | 文件不存在或已删除 | 「种子文件找不到(可能已被清理),请重新上传 .torrent」 |
|
||
| `1000011` | 链接数量超过 115 个 | 本地已按 115 条切批,正常不应触发;触发则记 warning 并重切 |
|
||
| `990002` | 参数错误 | 「提交参数不被 115 接受」,附原始 message |
|
||
| `980004` | 操作失败 | 可重试,走退避 |
|
||
| 无权限/未开通 | —— | 「当前 115 应用未开通云下载权限」,并提示到开放平台申请(见风险 R1) |
|
||
|
||
### 5.4 实现陷阱(必须按此实现)
|
||
|
||
1. **`percentDone` 是驼峰**,`get_task_list` 其余字段是 snake_case。Go struct tag 必须写 `json:"percentDone"`,写错会永远 0 进度。
|
||
2. **`data[].info_hash` 仅任务成功受理时返回**,可能为空 → 必须同时保存提交时的原始 `url` 做回退匹配,并对 url 做归一化(去首尾空白、统一小写协议头、去掉 `&` 后跟踪参数?**不做过度归一化**,仅 trim,避免误合并)。
|
||
3. **`wanted` 索引基准未确认**(见 11 节实测项)。本期不做选文件,实现统一传 `wanted="0"`(官方文档唯一明确允许的取值),待真机确认后再决定是否改为全索引拼接。
|
||
4. **`add_task_urls` 单批 ≤115**,超了服务端报 `1000011`;本地必须切批。
|
||
5. **`wp_path_id=0` 表示根目录**,绝不能把「未选择目录」当 0 提交,否则文件落到 115 根目录,STRM 永远扫不到 → 提交前强校验 `WpPathID != "" && != "0"`。
|
||
6. **`get_task_list` 只支持 page,无 page_size**,需按 `page_count` 翻页;轮询设页数上限(默认 10 页)防止账号任务堆积把一轮轮询拖爆。
|
||
|
||
---
|
||
|
||
## 6. 为什么磁力不能选文件(结论留档)
|
||
|
||
| 接口 | 能否选择文件 | 依据 |
|
||
| :--- | :--- | :--- |
|
||
| `add_task_urls`(磁力/ed2k/http) | ❌ 无 wanted 参数 | `115doc/.../添加云下载链接任务.md` 请求参数仅 `urls`、`wp_path_id` |
|
||
| `torrent`(解析种子) | ✅ 返回 `torrent_filelist[].wanted` | `解析BT种子.md` 返回字段表 |
|
||
| `add_task_bt`(提交 BT) | ✅ `wanted` 必填 | `添加云下载BT任务.md` 请求参数表 |
|
||
|
||
`torrent` 的入参是 **`torrent_sha1` + `pick_code`**,只能解析「已上传到本人 115 空间的 .torrent 文件」,不接受 magnet / info_hash。开放平台没有「解析磁力 → 文件列表」这一步,因此磁力链路从入口就没有选文件能力。
|
||
|
||
**若要支持磁力选文件**,唯一路径是服务端自行把 magnet 抓成 .torrent(DHT metadata),属新增外部依赖与常驻网络行为,本期不做,记录为 P3 候选。
|
||
|
||
---
|
||
|
||
## 7. 后端实现
|
||
|
||
### 7.1 文件清单
|
||
|
||
**新增**
|
||
|
||
| 文件 | 内容 |
|
||
| :--- | :--- |
|
||
| `internal/model/cloud_download.go` | `CloudDownloadTask` 模型 + 状态常量 |
|
||
| `internal/repository/cloud_download_repository.go` | CRUD、按状态取活跃任务、按阶段取待推进任务、统计 |
|
||
| `internal/service/cloud115/offline.go` | 4+2 个 API 封装、响应结构体、错误码常量与 `OfflineError` 类型 |
|
||
| `internal/service/cloud_download.go` | `CloudDownloadService` 主体:构造、提交、取消、重试、列表、配额 |
|
||
| `internal/service/cloud_download_input.go` | 输入解析(多行/类型判定/去重/切批 ≤115)、匹配键归一化 |
|
||
| `internal/service/cloud_download_submit.go` | 链接提交 + `.torrent` 上传解析 → `add_task_bt`、暂存目录查找/创建 |
|
||
| `internal/service/cloud_download_poll.go` | 轮询、状态映射、超时熔断、远程任务与本地记录对账 |
|
||
| `internal/service/cloud_download_pipeline.go` | Stage 编排、去抖、等待同步、扫描、刮削、失败隔离 |
|
||
| `internal/service/scheduler_cloud_download.go` | `jobCloudDownloadPoll` |
|
||
| `internal/handler/cloud_download.go` | REST handler + multipart 种子上传 |
|
||
| `internal/handler/routes_cloud_download.go` | 路由注册(在 `registerAdminStrmRoutes` 同级新增 `registerAdminCloudDownloadRoutes`) |
|
||
| `web/src/types/cloudDownload.ts` | 前端类型 |
|
||
| `web/src/api/cloudDownload.ts` | 前端 API |
|
||
| `web/src/pages/CloudDownloadPage.tsx` | 页面(提交面板 + 任务列表 + 配额) |
|
||
|
||
**修改**
|
||
|
||
| 文件 | 改动 | 风险 |
|
||
| :--- | :--- | :--- |
|
||
| `internal/model/model.go` | `AllModels()` 追加 `&CloudDownloadTask{}` | 低(AutoMigrate 新增表) |
|
||
| `internal/repository/repository.go` | `Container` 增加 `CloudDownload *CloudDownloadRepository` | 低 |
|
||
| `internal/service/service.go` | `Container` 增加 `CloudDownload *CloudDownloadService`,`Start/Stop` 联动 | 低 |
|
||
| `internal/service/strm_service.go` | 新增 `SetSyncFinishHook`、`Open115Client` | 中(新增导出方法,不改现有逻辑) |
|
||
| `internal/service/strm_sync.go` | `finishSync` 末尾触发 hook(异步) | **中**:hook 里必须自带 recover + 超时,绝不能阻塞或 panic 影响同步协程 |
|
||
| `internal/service/service_builder.go` | 构造 `CloudDownload`(在 `Strm` 之后),注入 Strm/Scan/Scraper/Tasks/Hub;回调 hook | 低 |
|
||
| `internal/service/scheduler.go` | `Start()` 里 append `cloud_download_poll` job(仅在 `CloudDownload != nil` 时注册) | 低 |
|
||
| `internal/repository/library_repository.go` | 新增 `FindEnabledRootsByLocalPath(ctx, localPath)`(归一化 + 前缀匹配) | 低 |
|
||
| `web/src/appRoutes.tsx`、`web/src/components/layoutNavigation.ts` | 路由 + 侧边栏入口 | 低 |
|
||
|
||
### 7.2 服务对外 API
|
||
|
||
```go
|
||
type CloudDownloadService struct{ /* cfg, log, repos, strm, scanner, scraper, tasks, hub */ }
|
||
|
||
func (s *CloudDownloadService) Submit(ctx context.Context, req CloudDownloadSubmit) (*CloudDownloadSubmitResult, error)
|
||
func (s *CloudDownloadService) SubmitTorrent(ctx context.Context, req CloudDownloadTorrentSubmit) (*CloudDownloadSubmitResult, error)
|
||
func (s *CloudDownloadService) List(ctx context.Context, q CloudDownloadQuery) (*CloudDownloadPage, error)
|
||
func (s *CloudDownloadService) Cancel(ctx context.Context, id string) error
|
||
func (s *CloudDownloadService) Retry(ctx context.Context, id string) error // 失败任务重新提交
|
||
func (s *CloudDownloadService) RetryPipeline(ctx context.Context, id string) error // 仅重做后处理
|
||
func (s *CloudDownloadService) Delete(ctx context.Context, id string) error
|
||
func (s *CloudDownloadService) ClearFinished(ctx context.Context) (int64, error)
|
||
func (s *CloudDownloadService) Quota(ctx context.Context, accountID string) (*OfflineQuota, error)
|
||
func (s *CloudDownloadService) Start(ctx context.Context) // 恢复中断任务
|
||
func (s *CloudDownloadService) Stop()
|
||
```
|
||
|
||
`CloudDownloadSubmit`:`AccountID`、`SyncPathID`、`URLs []string`(一次提交多条)。
|
||
`CloudDownloadSubmitResult`:逐条 `{source, task_id, ok, error}`,**部分成功必须返回 200 + 明细**。
|
||
|
||
### 7.3 提交校验(顺序不可乱)
|
||
|
||
1. `SyncPathID` 存在、`Enabled`、`Provider == cloud115`、`AccountID` 非空、`RemotePath` 为有效目录 ID 且 ≠ `"0"`;
|
||
2. 账号存在且 `Enabled`,`Open115Client` 可取得;
|
||
3. 配额预检:`get_quota_info` → `surplus <= 0` 时直接拒绝(批量多链接时按条数预估,无法精确预估大小,只做「配额是否耗尽」闸门);
|
||
4. 输入解析:按行拆分 → 类型判定 `magnet:?xt=urn:btih:` / `ed2k://` / `http(s)://` / `ftp://` → trim → 去重(同批内 + 与库内 `submitted/downloading` 任务去重);
|
||
5. 切批:每批 ≤115 条,逐批调用 `add_task_urls`;
|
||
6. 逐条落库:`data[].state=true` → `submitted`;失败 → `failed` + 错误码映射文案。
|
||
|
||
### 7.4 轮询实现要点
|
||
|
||
```
|
||
每 tick(默认 60s):
|
||
1. 取所有 status IN (submitted, downloading) 的任务,按 account_id 分组
|
||
2. 无活跃任务 → 直接返回(不打 115 接口)
|
||
3. 对每个账号:
|
||
翻页取 get_task_list(page=1..min(page_count, 10))
|
||
构建索引:info_hash(大写) → task,url(trim) → task
|
||
逐条匹配本地记录:
|
||
- info_hash 命中优先
|
||
- 否则 url 命中
|
||
- 都未命中 → 记 LastPollAt,未超时前不判失败(115 受理有延迟)
|
||
状态映射 + 更新 Percent/SizeBytes/Name
|
||
status=2 → succeeded + CompletedAt + Stage=pending
|
||
status=-1 → failed(Error 用 115 文案)
|
||
4. 超时:submitted/downloading 且 add_time 早于 now-72h → failed「下载超时」
|
||
```
|
||
|
||
### 7.5 Pipeline 实现要点
|
||
|
||
```
|
||
推进循环(可挂在同一 tick 之后,也可独立每 15s):
|
||
A. 取 status=succeeded AND stage IN (pending, waiting_sync, failed) 的任务,按 sync_path_id 分组
|
||
- 每组内若有任务 completed_at 在去抖窗口内(默认 30s)→ 本轮跳过(等凑批)
|
||
B. 对每个 syncPath:
|
||
- 若 IsSyncRunning(pathID) → 全部置 waiting_sync,下一轮再看
|
||
- 否则 StartSync(pathID, incremental),置 syncing,记录 StrmRecordID(取该 path 最新记录)
|
||
C. 同步完成后(hook):
|
||
- 置 scanning → 反查 library(LocalPath 与 LibraryRoot.Path 归一化后相等或为其子目录)
|
||
- 无匹配库 → Stage=na + StageError「本地目录未加入任何媒体库」
|
||
- ScanLibrary(libraryID) → ScanAdded
|
||
- 置 scraping → EnqueueLibrary(libraryID, ScrapeOptions{}) → ScrapeQueued
|
||
- 置 done
|
||
D. 任一步失败:Stage=failed + StageError,不重试(等待用户点「重做后处理」)
|
||
E. 进程重启:Start() 时把 syncing/scanning/scraping 的僵尸阶段重置为 pending(参考 StrmService.recoverInterruptedSyncs 的写法)
|
||
```
|
||
|
||
去抖细节:以 `syncPathID` 为单位,服务内存里维护 `lastTrigger[pathID] = time`;窗口内到达的任务全部 `waiting_sync`,窗口结束时由首个触发者发起同步。
|
||
|
||
### 7.6 STRM 完成回调
|
||
|
||
```go
|
||
// strm_service.go
|
||
func (s *StrmService) SetSyncFinishHook(fn func(ctx context.Context, syncPathID, status string)) { ... }
|
||
```
|
||
|
||
在 `finishSync`(`strm_sync.go:323`)**落库完成之后**调用:
|
||
|
||
```go
|
||
if hook := s.syncFinishHook(); hook != nil {
|
||
helper.Go(s.log, "strm.syncFinishHook", func() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||
defer cancel()
|
||
hook(ctx, p.ID, status)
|
||
})
|
||
}
|
||
```
|
||
|
||
要点:**异步 + 自带 recover + 超时**,绝不能让 hook 的 panic 或阻塞影响同步协程;也不要在 `finishSync` 里同步调用扫描(同步耗时长,会拖住同步收尾与 Pending 记录)。
|
||
|
||
### 7.7 调度与配置项
|
||
|
||
设置项(沿用 `repo.Setting`,键名与现有 `scrape.*` / `strm.*` 风格一致):
|
||
|
||
| 键 | 默认 | 说明 |
|
||
| :--- | :--- | :--- |
|
||
| `cloud_download.poll_interval_sec` | `60` | 轮询间隔(30~600) |
|
||
| `cloud_download.sync_debounce_sec` | `30` | 同一同步目录的去抖窗口(0~300) |
|
||
| `cloud_download.auto_scrape` | `true` | 同步后是否自动刮削 |
|
||
| `cloud_download.task_timeout_hours` | `72` | 下载超时判定 |
|
||
| `cloud_download.torrent_max_mb` | `20` | `.torrent` 上传大小上限 |
|
||
| `cloud_download.max_pages` | `10` | 单轮轮询最大翻页数 |
|
||
|
||
`auto_scrape=true` 时**不受** `scrape.auto_on_scan` 全局开关约束(用户显式提交了下载,期望链路闭环);但若 `Scraper.AnyEnabled()==false`(无任何刮削源),Stage 直接置 `done` 并在 `StageError` 记「未配置刮削源」。
|
||
|
||
### 7.8 HTTP 路由与鉴权
|
||
|
||
挂在 `registerAdminStrmRoutes` 同组(`admin`,已带 `AuthRequired` + `AdminRequired`):
|
||
|
||
```go
|
||
admin.GET("/cloud-downloads", listCloudDownloadsHandler(svc))
|
||
admin.POST("/cloud-downloads", createCloudDownloadsHandler(svc))
|
||
admin.POST("/cloud-downloads/torrent", uploadCloudDownloadTorrentHandler(svc)) // multipart
|
||
admin.GET("/cloud-downloads/quota", cloudDownloadQuotaHandler(svc))
|
||
admin.POST("/cloud-downloads/:id/retry", retryCloudDownloadHandler(svc))
|
||
admin.POST("/cloud-downloads/:id/retry-pipeline", retryCloudDownloadPipelineHandler(svc))
|
||
admin.POST("/cloud-downloads/:id/cancel", cancelCloudDownloadHandler(svc))
|
||
admin.DELETE("/cloud-downloads/:id", deleteCloudDownloadHandler(svc))
|
||
admin.POST("/cloud-downloads/clear-finished", clearFinishedCloudDownloadsHandler(svc))
|
||
```
|
||
|
||
multipart 端点:`file`(.torrent)+ `account_id` + `sync_path_id`;实现需 `c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxMB<<20)`,并校验文件头(BT 种子是 bencode,以 `d` 开头,含 `announce` / `info`)。
|
||
|
||
---
|
||
|
||
## 8. 前端实现
|
||
|
||
### 8.1 页面结构(`CloudDownloadPage.tsx`)
|
||
|
||
**顶部:提交面板**
|
||
- 目标同步目录下拉:复用 `GET /admin/strm/paths`,只列 `provider=cloud115 && enabled`,展示 `name + remote_display_path + local_path`;
|
||
- 账号显示:由所选同步目录自动带出(`account_id`),无需二次选择;
|
||
- 链接文本框:多行粘贴,右下角实时显示「识别到 N 条:磁力 x / ed2k y / http z」;
|
||
- `.torrent` 拖拽/选择:单文件,提交时走 multipart 端点;
|
||
- 配额条:`surplus/count`,耗尽时提交按钮禁用并提示;
|
||
- 提交结果:逐条成功/失败明细(不合并成一句「提交失败」)。
|
||
|
||
**下方:任务列表**
|
||
- 列:来源(截断显示 + tooltip 全量)、目标目录、Size、下载进度、**阶段徽标**(下载中 / 等待同步 / 同步中 / 扫描中 / 刮削中 / 完成 / 失败)、时间、操作;
|
||
- 操作:取消(仅下载中)、重试(失败)、重做后处理(Stage 失败)、删除(终态);
|
||
- 批量:清空已完成;
|
||
- 刷新:复用现有 WS Hub(`hub.Publish("cloud_download", …)`),断线时降级为 5s 轮询。
|
||
|
||
### 8.2 路由与导航
|
||
|
||
- `appRoutes.tsx`:`{ path: 'cloud-downloads', element: <CloudDownloadPage />, adminOnly: true }`
|
||
- `layoutNavigation.ts`:`LAYOUT_NAV_ITEMS` 中 `STRM 管理` 之后加 `{ to: '/cloud-downloads', label: '云下载', icon: CloudDownload, adminOnly: true }`
|
||
- `StrmManagePage` 顶部工具区加一个「云下载」跳转按钮,形成入口闭环。
|
||
|
||
---
|
||
|
||
## 9. 权限与安全
|
||
|
||
### 9.1 鉴权
|
||
|
||
首期仅管理员:路由挂在 `admin` 组,`AdminRequired` 中间件已覆盖。
|
||
|
||
### 9.2 输入安全
|
||
|
||
- 链接只做**字符串拼接转发**,服务端绝不主动发起对这些 URL 的请求(无 SSRF 面);
|
||
- `.torrent` 文件仅落临时目录 → 上传 115 → 删除,不本地解析 bencode(P1 若要预览文件名需加 bencode 解析,届时必须限大小与深度);
|
||
- 上传文件名禁用 `..` / 路径分隔符;
|
||
- `save_path` 本期固定为空(留字段),避免路径穿越争议。
|
||
|
||
### 9.3 后续(P2)
|
||
|
||
`model.UserPermission` 增加 `CanUseCloudDownload bool`,配套 handler 从 `AdminRequired` 换成权限中间件,并给普通用户做配额上限(否则一个用户可以把账号的 115 云下载配额打空)。
|
||
|
||
---
|
||
|
||
## 10. 测试计划
|
||
|
||
### 10.1 单元测试(必须)
|
||
|
||
| 目标 | 用例 |
|
||
| :--- | :--- |
|
||
| 输入解析 | 多行混合、空行、重复链接、首尾空白、大小写协议头、>115 条切批、非法行跳过并计数 |
|
||
| 状态映射 | status -1/0/1/2 → failed/downloading/downloading/succeeded;缺失 info_hash 时 url 回退命中;大小写 info_hash |
|
||
| 错误码映射 | 1000012 / 91006 / 20018 / 990002 文案与是否可重试 |
|
||
| 提交校验 | `RemotePath="0"`、账号禁用、同步目录禁用、配额耗尽 → 全部拒绝且不产生 115 请求 |
|
||
| 去抖 | 30s 内 3 个任务完成 → 只触发 1 次 `StartSync` |
|
||
| 等待同步 | `IsSyncRunning=true` → 全部 waiting_sync;结束后成功发起 |
|
||
| 阶段隔离 | 同步失败不改变 Status;扫描失败不改变 syncing 记录 |
|
||
| 僵尸恢复 | 重启后 syncing/scanning/scraping → pending |
|
||
|
||
测试风格沿用仓库现状:同包 `*_test.go`、`t.Context()`、httptest 模拟 115(参考 `internal/service/cloud115/cloud115_test.go` 的 mock 模式)。
|
||
|
||
### 10.2 集成测试
|
||
|
||
- `httptest` 起伪 115(`/open/offline/*`):验证提交 → 轮询 → 状态流转 → pipeline 触发(Strm 用 fake hook,Scanner/Scraper 用桩)。
|
||
- 现有测试回归:`go test ./internal/service/... ./internal/repository/... ./internal/handler/...`。
|
||
|
||
### 10.3 真机验收(不可省)
|
||
|
||
| # | 步骤 | 期望 |
|
||
| :--- | :--- | :--- |
|
||
| 1 | 提交一条小体积磁力(<200MB)绑定某 115 同步目录 | 任务 5s 内变 `submitted` |
|
||
| 2 | 等 115 完成 | 任务变 `succeeded`,随后 `syncing` |
|
||
| 3 | 同步完成 | 本地目录出现新 `.strm` 文件 |
|
||
| 4 | 自动扫描 | 媒体库新出现对应条目 |
|
||
| 5 | 自动刮削 | 条目 `scrape_status` 变 `matched`,海报正确 |
|
||
| 6 | 提交 `.torrent` 文件 | 同上,且 115 端任务名与种子内任务名一致 |
|
||
| 7 | 提交 ed2k 链接 | 同上(ed2k 无 info_hash,验证 url 回退匹配) |
|
||
| 8 | 提交 120 条链接 | 自动切 2 批,逐条结果完整 |
|
||
|
||
---
|
||
|
||
## 11. 待实测确认项
|
||
|
||
| # | 项 | 影响 | 处理 |
|
||
| :--- | :--- | :--- | :--- |
|
||
| 1 | `add_task_bt` 的 `wanted` 是 0-based 还是 1-based | 后续若开放选文件,索引拼接依赖此语义 | 真机用一个 3 文件种子验证;当前实现统一传 `"0"`,不受此影响 |
|
||
| 2 | `wanted="0"` 是「全部」还是「第 0 个」 | 决定 `.torrent` 任务是否只下一个文件 | **必测**:当前实现依赖它表示「全部」 |
|
||
| 3 | `MkDir` 对已存在目录的返回 | 暂存目录重复创建 | 先 `FindNamedContentInParent` 查找,未命中再 `MkDir`;两者都失败则回退到目标同步目录 |
|
||
| 4 | 当前 115 应用是否已开通云下载权限 | 全功能可用性 | 提交前 `get_quota_info` 探测;无权限时给出「去开放平台申请」指引 |
|
||
| 5 | 磁力整体提交时 115 默认勾选哪些文件 | 是否漏下样片/字幕 | 不影响链路,只在文档说明 |
|
||
| 6 | `get_task_list` 单页实际返回条数 | 翻页策略 | 首轮实测后调整 `max_pages` 默认值 |
|
||
|
||
---
|
||
|
||
## 11.5 实现状态(P0 已落地)
|
||
|
||
已完成(后端 + 前端最小可用):
|
||
|
||
| 层 | 文件 | 说明 |
|
||
| :--- | :--- | :--- |
|
||
| 模型 | `internal/model/cloud_download.go` | `CloudDownloadTask` + 状态/阶段常量,已注册 `AllModels()` |
|
||
| 仓储 | `internal/repository/cloud_download_repository.go` | 分页查询、活跃任务去重、阶段推进、僵尸阶段重置 |
|
||
| 115 协议 | `internal/service/cloud115/offline.go` | 7 个接口封装;`client.go` 新增 multipart 请求构造并复用限流/刷新/错误封装 |
|
||
| 服务 | `internal/service/cloud_download*.go` | 输入解析、提交、轮询、去抖、Pipeline(同步→扫描→刮削) |
|
||
| 调度 | `internal/service/scheduler_cloud_download.go` | `cloud_download_poll` 周期任务 |
|
||
| 打通 | `internal/service/strm_service.go` / `strm_sync.go` | `Open115Client` 复用缓存客户端;`SetSyncFinishHook` 异步回调 |
|
||
| HTTP | `internal/handler/cloud_download.go` | 9 个管理员接口 + multipart 种子上传 |
|
||
| 前端 | `web/src/pages/CloudDownloadPage.tsx` 等 | 提交面板、配额、任务列表、重试/取消/删除、5s 自动刷新 |
|
||
|
||
P0 范围外(保留为 P1/P2):
|
||
|
||
- 设置项 UI(当前通过 `Setting` 表键值生效,默认轮询 60s / 去抖 30s / 超时 72h);
|
||
- WebSocket 实时进度(当前为 5s 轮询);
|
||
- 普通用户权限位与单用户配额上限;
|
||
- 远端任务清理(`del_task` / `clear_task`)在 UI 上无入口(取消任务时会尽力调用 `del_task`)。
|
||
|
||
验证证据:
|
||
|
||
- `go build ./...`、`go vet` 通过;
|
||
- 新增单测:`internal/service/cloud115/offline_test.go`(7 项,含 multipart 格式与 `percentDone` 解析)、`internal/service/cloud_download_test.go`(9 项,含解析/去抖/目录匹配/校验);
|
||
- 前端 `tsc --noEmit`、`eslint`、`vite build` 通过;
|
||
- 3 个既有测试在本机 HEAD 上同样失败(Windows 文件占用与 SQLite `VACUUM INTO` 环境问题),已用独立 worktree 复核,与本次改动无关。
|
||
|
||
## 12. 分期与验收
|
||
|
||
### P0(可跑通,约 1 个迭代)
|
||
|
||
- 模型 + 仓储 + 迁移注册
|
||
- `cloud115/offline.go`(4 个接口:add_task_urls / get_task_list / get_quota_info / torrent + add_task_bt)
|
||
- 提交(磁力/ed2k/http)+ 轮询 + 状态机
|
||
- STRM finish hook + Pipeline(同步 → 扫描 → 刮削)
|
||
- 调度 job + 设置项
|
||
- 前端最小页:提交面板 + 任务列表(阶段徽标、重试、删除)
|
||
- **验收**:10.3 的步骤 1~5、7、8 通过
|
||
|
||
### P1(体验完善)
|
||
|
||
- `.torrent` 上传端点 + 全选提交(10.3 步骤 6)
|
||
- 配额条、提交结果逐条明细
|
||
- 去抖、僵尸恢复、超时熔断
|
||
- WS 实时进度
|
||
- 分页列表 + 筛选(状态/目录)
|
||
|
||
### P2(可选)
|
||
|
||
- 远端任务清理(`del_task` / `clear_task`)
|
||
- 完成后删除暂存 `.torrent`
|
||
- 普通用户权限位 + 单用户配额上限
|
||
- Telegram 完成通知
|
||
|
||
### P3(本期不做,记录候选)
|
||
|
||
- 磁力选文件(服务端 DHT 抓 .torrent)
|
||
- 完成后按 file_id 裁剪不需要的文件
|
||
|
||
---
|
||
|
||
## 13. 风险与回滚
|
||
|
||
| # | 风险 | 影响 | 缓解 |
|
||
| :--- | :--- | :--- | :--- |
|
||
| R1 | 115 应用未开通云下载权限 | 功能不可用 | 提交前探测 + 明确文案;不影响现有 STRM 功能 |
|
||
| R2 | 令牌双实例互相作废 | 账号被标「授权已失效」 | **强约束 K1**:只复用 StrmService 缓存的客户端 |
|
||
| R3 | 频繁同步打爆 CPU/115 限流 | 服务卡顿、风控 | 去抖 30s + 最小间隔 + 同步运行中等待 + throttle_manager |
|
||
| R4 | 目录错配(`wp_path_id` 不在同步目录) | 下了但永远扫不到 | 提交时强制 `RemotePath` 非空非 0;UI 只允许选同步目录,不放开自由选目录 |
|
||
| R5 | finish hook panic 拖垮同步 | 同步记录永远 running | hook 异步 + `helper.Go` + recover + 独立超时 |
|
||
| R6 | 新增表迁移失败 | 启动失败 | `AutoMigrate` 加表无破坏性;出问题回滚只需删表 + 移除注册 |
|
||
| R7 | 单页页数过多导致轮询耗时 | 轮询堆积 | `max_pages` 上限;超限记 warning 并下轮继续 |
|
||
|
||
**回滚方式**:移除路由与侧边栏入口即可(功能面隐藏);表可保留(不影响其它子系统),或手动 `DROP TABLE cloud_download_tasks`。
|
||
|
||
---
|
||
|
||
## 14. 附录:核对依据
|
||
|
||
| 结论 | 依据位置 |
|
||
| :--- | :--- |
|
||
| 115 开放平台离线接口形态与参数 | `115doc/115开放平台/API列表/云下载/*.md` |
|
||
| STRM 同步入口与运行互斥 | `internal/service/strm_sync.go:82-138` |
|
||
| 同步收尾无回调 | `internal/service/strm_sync.go:323`(`finishSync`) |
|
||
| 115 目录整树拉取成本 | `internal/service/strm_sync.go:682`(`walk115Flat`) |
|
||
| 令牌刷新落库与单实例要求 | `internal/service/strm_service.go:524-556`、`strm_115_oauth.go:438` |
|
||
| 115 provider 断言复用范式 | `internal/service/strm_sync.go:378`(`cloud.OpenAPI115Provider`) |
|
||
| 上传种子可拿 PickCode | `internal/service/cloud115/upload.go:95-102,217` |
|
||
| 刮削候选只挑 pending | `internal/service/scraper_library.go:183-207` |
|
||
| 扫描自动刮削开关 | `internal/service/scanner_prune.go:279`、`scanner_post_scan.go:19` |
|
||
| 周期任务注册范式 | `internal/service/scheduler.go:118-210` |
|
||
| 模型迁移注册 | `internal/model/model.go:37`、`internal/database/schema_migration.go:34` |
|
||
| 服务装配顺序 | `internal/service/service_builder.go:148` |
|