diff --git a/CHANGELOG.md b/CHANGELOG.md index e213bad..562cc4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ 版本号用 0.x:界面和接口还在变,暂不承诺兼容;稳定之后再发 1.0。 +## v0.2.2 — 未发布 + +补上两件拖了很久的事:大文件传一半断了不用从头再来,CI 里也终于有了自动化测试。另外修了几处会把报表数字说错的地方。 + +### 新增 + +- 大文件断点续传。16MB 以上的文件按 8MB 分片上传,传完的片记在本地的会话表里,下次重跑从断点接上,已经传过的不再重传。以前 22GB 的文件传到九成断了,下一次只能从第 1 片重来。 +- 自动化测试进了 CI,现在 35 项。以前只有编译检查和静态检查,动一次协议层只能上真机验;现在分片数、排除规则、路径校验、空文件计数、会话判废、复核口径都有测试兜着。 + +### 修好的问题 + +- 推送的哈希复核看错了对象。以前拿「这次要推的全部文件」去和云端比,上传失败的文件也会被拎出来比一遍旧记录,多报几条「内容哈希不符」,一致数还偏高。现在只复核真正传上去的。 +- 推送的哈希复核没有大小上限。注释写着「仅对体积较小的文件」,代码里没有这一条,sync 那边是 64MB。补上,两边一个口径。 +- 推送复核区分文件名大小写。云盘本身大小写不敏感,sync 和孤儿扫描早就按小写比对,只有推送这条漏了:云端回 IMG.JPG、本地叫 img.jpg 就报「云端未找到」,整轮判失败。 +- 目录同步的数字会双计。一个文件上传成功但复核没过时,它同时被算进「成功」和「失败」,报表上两个数加起来比实际文件数还多。现在从成功数里扣掉。 + +### 还没做的 + +1. 每次同步仍然全量重列云端目录。没有本地索引,6000 个文件量级能接受,上万文件后扫描时间会线性涨。改动大,继续排后。 +2. engine.py 里那个全局 IPv4 补丁、main.py 两处直接写 SQL 的地方、每次跑完都重建一遍调度任务。都是改动面大于收益,暂时不动。 +3. 两件还在观察的:令牌轮换后服务端认不认旧会话(现在的做法是保守重开)、云端传了一半的残片怎么回收。都不阻塞使用。 + ## v0.2.1 — 2026-11-02 修了一批问题,都是把 v0.2.0 逐行读完之后确认过的。断点续传这一版没做,原因写在最后。 @@ -30,10 +52,10 @@ | 评审提到的问题 | 核对结果 | 这一版 | |---|---|---| -| 大文件没有断点续传 | 成立,是目前最大的可靠性缺口 | 没做,见最后 | +| 大文件没有断点续传 | 成立,是目前最大的可靠性缺口 | 没做,见最后(v0.2.2 补上) | | 排除项对文件不生效 | 成立,v0.2.0 的说明里还写过「与同步一致」 | 已修 | | 空文件悄悄跳过 | 成立;原文说推送是「明确报错」,其实是整批否决 | 已修 | -| CI 没有自动化测试 | 成立 | 单测写好了,等断点续传定了再合 | +| CI 没有自动化测试 | 成立 | 单测写好了,等断点续传定了再合(v0.2.2 已合入) | | 分片数应该向上取整 | 成立;但举证不成立,22GiB 正好是 2816 × 8MiB,没走到末片超长那条路 | 已修 | | 每次同步都重列云端目录 | 成立 | 没做,要加本地索引,改动太大 | | 新建任务没做真实路径校验 | 原文没提,核验时补的 | 已修,编辑任务一起修 | @@ -43,6 +65,8 @@ ### 还没做的 +(前两项在 v0.2.2 已经补上了,这一节保留的是 v0.2.1 当时的记录。) + 1. 断点续传。要先测服务端认不认几小时前的上传会话,探针脚本已经写好,等结果出来再定方案。 2. 纯函数单测。分片数、排除规则、路径校验、空文件计数四个点已经写好,等这版收尾时一起合。 3. 三处暂时不动,都是改动面大于收益:engine.py 里那个全局 IPv4 补丁(要真机验证传输路径)、main.py 两处直接写 SQL 的地方、每次跑完都重建一遍调度任务。 diff --git a/README.md b/README.md index d0d02e8..8977fc5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - **增量同步**:按「文件名 + 体积」比对云端,只传新文件和变过的文件;文件名大小写不敏感比对(联通云盘特性,避免 iOS 导出的 `FullSizeRender.heic` 与 `FULLSIZERENDER.HEIC` 互相覆盖) - **两级复核**:上传后先比体积,云端返回了 SHA256 且文件不超过 64MB 时再比内容哈希——「云盘说收到了」升级成「内容确认一致」 +- **断点续传**:16MB 以上的文件按 8MB 分片上传,传完的片记在本机的会话表里,断了重跑从断点接上,已传的片不重传。会话 24 小时有效,文件改动、换目标目录、换令牌都会作废重开。`TP_RESUME=0` 可整体关掉 - **试运行**:只扫描比对列出待传清单,一个字节都不传。新任务先试运行再真跑 - **定时调度**:每个任务一条 cron 表达式,改完即生效,不用重启 - **推送模式**:密码库导出、虚拟机镜像这类需要先在宿主机打包/加密的任务,由宿主机脚本打包后调 API 上传,支持云端保留 N 份滚动清理 @@ -124,6 +125,8 @@ cp api.conf.example api.conf | `TP_USER` / `TP_PASS` | admin / tpbackup | Web 与 API 的登录凭据。**默认密码仅为首次体验,务必修改** | | `TP_DATA` | /app/data | 配置、令牌、日志、SQLite 的存放位置 | | `TP_ALLOWED_ROOTS` | /vol1,/vol00 | 目录浏览与任务源目录的允许范围,逗号分隔。这是安全边界,任务源目录不能超出它 | +| `TP_RESUME` | 1 | 是否启用大文件断点续传。设 `0` 关闭,之后每个文件都是一次全新上传(与 v0.2.1 行为一致) | +| `TP_RESUME_MIN_SIZE` | 16777216 | 多大的文件才走断点续传(字节)。默认 16MiB;再小的文件本来就只有一两片,存会话只是多一次写盘 | | `TP_PROTECT_FILES` | 空 | 推送任务滚动清理时永不删除的云端文件名,逗号分隔(如 `README.txt`) | | `TP_OPENLIST_DB` | 空 | OpenList 的 data.db 路径,填了则首次启动从库中导入令牌 | | `TP_SYNC_OPENLIST` | 0 | 刷新令牌后是否回写 OpenList 库 | @@ -163,6 +166,22 @@ python3 taskstat.py --task immich `--keep` 按文件名字典序保留最新 N 份并删除其余(时间戳命名天然有序);`--dry-run` 只校验不传输。 +### 大文件断点续传 + +云盘的分片上传有会话:同一个 `uniqueId` 的片,中间断了还能接着传。本应用把这个会话记在本地(`TP_DATA` 下的 SQLite,表名 `upload_sessions`),所以**进程重启、容器重建都不影响续传**。 + +- 只有 16MiB 以上的文件走这条路(阈值是 `TP_RESUME_MIN_SIZE`)。小文件本来就只有一两片,存会话反而多一次写盘。 +- 每传完一片记一次进度,每 32 片落一次盘(8MiB × 32 = 256MiB,崩了最多重传这么多)。 +- 复核通过才删会话。复核明确判负(体积不对、哈希不符)也删,下次从头来;只有「列目录失败、根本没拿到校验结果」才留着,下次直接续,不用重传。 +- 会话 24 小时后自动作废(懒清理,不用定时器)。文件体积或 mtime 变了、目标目录变了、令牌换过了,也都会判废重开——最坏多传一遍,不赌服务端还认旧会话。 +- 想彻底关掉:`TP_RESUME=0`。 + +```bash +# 看当前攒了多少条会话(正常情况下跑到最后会自己删干净) +docker exec tpbackup python3 -c \ + "from app.store import SessionStore; print(SessionStore('/app/data/tpbackup.db').count())" +``` + ### 恢复 界面上**没有放取回入口**(按需再开),需要取回时直接调接口,落到 `TP_ALLOWED_ROOTS` 内的指定目录: diff --git a/app/engine.py b/app/engine.py index 86539c0..8ab8a13 100644 --- a/app/engine.py +++ b/app/engine.py @@ -1038,7 +1038,10 @@ def _drop_session(relp): _drop_session(relp) all_errs = err_msgs + verify_bad - result.update(ok=(not all_errs), ok_n=done_n, fail=fail + len(verify_bad), + # verify_bad 里的文件已经计进 done_n 了:从成功数里扣掉,否则同一个文件 + # 既是「成功」又是「失败」,报表上 ok_n + fail 会大于实际文件数。 + result.update(ok=(not all_errs), ok_n=done_n - len(verify_bad), + fail=fail + len(verify_bad), bytes=done_b, elapsed=el, reason="" if not all_errs else ("upload" if fail else "verify"), detail=(all_errs[0][:200] if all_errs else "")) @@ -1176,10 +1179,14 @@ def push(store, files, cloud_chain, keep=0, pattern="", dry_run=False, cloud = {} for f in w.ls(root_id): if f.get("type") == 1: - cloud[(f.get("name") or "")] = f + # 联通云盘文件名大小写不敏感:与 sync / 孤儿扫描统一按小写 + # 建索引。以前这里用原始名,云端回的是 IMG.JPG、本地叫 + # img.jpg 时就会误报「云端未找到」,整轮判失败。 + cloud[(f.get("name") or "").lower()] = f + local = {n: p for p, n, _sz in items} bad = [] for n, sz in uploaded: - c = cloud.get(n) + c = cloud.get(n.lower()) if not c: bad.append("%s 云端未找到" % n) elif str(c.get("size")) != str(sz): @@ -1188,12 +1195,15 @@ def push(store, files, cloud_chain, keep=0, pattern="", dry_run=False, errs.extend(bad) log("❌ 复核未通过:" + ";".join(bad)) else: - # 体积都对了,再做一次内容哈希比对(仅对有 sha256 且体积较小的文件) + # 体积都对了,再做一次内容哈希比对。只核对**真正传上去**的文件 + # (以前遍历的是 items,上传失败的也会被拿去和云端旧记录比, + # 多出假的「内容哈希不符」);体积上限与 sync 同口径。 hashed = 0 - for p, n, sz in items: - c = cloud.get(n) or {} + for n, sz in uploaded: + p = local.get(n) + c = cloud.get(n.lower()) or {} remote_hash = (c.get("sha256") or "").lower() - if not remote_hash: + if not p or not remote_hash or sz > VERIFY_SHA256_MAX: continue if sha256_of(p) != remote_hash: bad.append("%s 内容哈希不符(传输损坏)" % n) diff --git a/tests/test_reporting.py b/tests/test_reporting.py new file mode 100644 index 0000000..68bed48 --- /dev/null +++ b/tests/test_reporting.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""上传后复核与统计口径的单测。 + +这里钉的是「报表数字对不对」,不是「文件传没传上去」:三处都是 v0.2.2 修的 +诊断口径问题,改坏不会让备份失败,只会让人看错账—— + + · push 的哈希复核以前遍历 items(含上传失败的),现在只遍历 uploaded; + · push 的哈希复核以前没有体积上限,与 sync 不一致; + · push 的云端索引以前用原始大小写,云盘本身大小写不敏感; + · sync 的一个文件复核失败时会同时计入成功和失败。 + +网络层用一个只记账的假云盘替掉,不建连接、不发请求。 +""" +import hashlib +import os +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if ROOT not in sys.path: + sys.path.insert(0, ROOT) + +from app import engine # noqa: E402 + + +class FakeCloud: + """最小可用的假云盘:一棵两层目录树 + 上传记账。 + + 故意不做任何网络行为,也不做重试——重试逻辑有单独的测试,这里只关心 + 「复核看了哪些文件、拿什么名字去比」。 + """ + + def __init__(self, wrong_size=(), wrong_hash=(), fail_names=(), + lower_names=False, preload=()): + self.tree = {"0": [{"type": 0, "name": "root", "id": "D1"}], "D1": []} + self.wrong_size = set(wrong_size) + self.wrong_hash = set(wrong_hash) + self.fail_names = set(fail_names) + self.lower_names = lower_names + self.at, self.key = "T" * 32, b"0123456789abcdef" + self.uploaded = [] + for e in preload: + self.tree["D1"].append(dict(e)) + + # ── 协议面(只实现用到的那几个)─────────────── + def ensure_family(self): + return None + + def refresh(self): + return None + + def ls(self, cid): + return list(self.tree.get(str(cid), [])) + + def mkdir(self, parent, name): + nid = "D-%s" % name + self.tree[str(parent)].append({"type": 0, "name": name, "id": nid}) + self.tree[nid] = [] + return nid + + def upload(self, path, dirid, name=None, on_part=None): + n = name or os.path.basename(path) + if n in self.fail_names: + raise RuntimeError("模拟上传失败: %s" % n) + sz = os.path.getsize(path) + with open(path, "rb") as f: + h = hashlib.sha256(f.read()).hexdigest() + entry = { + "type": 1, + "name": (n.lower() if self.lower_names else n), + "id": "F-%s" % n, + "size": (sz + 1) if n in self.wrong_size else sz, + "sha256": ("0" * 64) if n in self.wrong_hash else h, + } + self.tree[str(dirid)].append(entry) + self.uploaded.append(n) + return entry["id"] + + +class StubStore: + """TokenStore 的最小替身:只提供 sync/push 会碰的三个方法。""" + + def load(self): + return ("A" * 32, "R" * 32) + + def save(self, at, rt): + return None + + def maybe_renew(self, w, log): + return None + + +def _use_sync_cloud(monkeypatch, cloud): + """sync 里是直接 Wopan(at, rt) 建客户端的,所以替掉类本身。""" + monkeypatch.setattr(engine, "Wopan", + lambda at, rt, on_token=None: cloud) + + +def _use_push_cloud(monkeypatch, cloud): + """push 走 _connect(store, log),替掉连接函数即可。""" + monkeypatch.setattr(engine, "_connect", lambda store, log: (cloud, {})) + + +def _mk(tmp_path, name, data): + p = tmp_path / name + p.write_bytes(data) + return str(p) + + +# ── sync:统计口径 ──────────────────────────────────────── +def test_sync_verify_failure_not_counted_as_success(tmp_path, monkeypatch): + """上传成功但复核不过的文件,只能算失败,不能同时算成功。""" + _mk(tmp_path, "a.txt", b"aaa") + _mk(tmp_path, "b.txt", b"bbbb") + cloud = FakeCloud(wrong_size={"b.txt"}) # b 的云端体积对不上 + _use_sync_cloud(monkeypatch, cloud) + + res = engine.sync(StubStore(), str(tmp_path), ["root"], log=lambda m: None) + + assert res["todo"] == 2 + assert res["ok"] is False + assert res["ok_n"] == 1, "a 成功、b 复核失败,成功数只能有 1" + assert res["fail"] == 1 + assert res["ok_n"] + res["fail"] == res["todo"], "成功 + 失败不能超过实际文件数" + + +def test_sync_all_good_keeps_counts(tmp_path, monkeypatch): + """全传对时口径不变:ok_n == todo,fail == 0(改口径别改坏正常路径)。""" + _mk(tmp_path, "a.txt", b"aaa") + _mk(tmp_path, "b.txt", b"bbbb") + cloud = FakeCloud() + _use_sync_cloud(monkeypatch, cloud) + + res = engine.sync(StubStore(), str(tmp_path), ["root"], log=lambda m: None) + assert res["ok"] is True and res["ok_n"] == 2 and res["fail"] == 0 + + +# ── push:哈希复核的范围 ────────────────────────────────── +def test_push_hash_check_skips_failed_uploads(tmp_path, monkeypatch): + """上传失败的文件不该再被拿去和云端旧记录比哈希。 + + 以前遍历 items:bad.bin 上传失败,云端还留着上一轮的旧条目(旧哈希), + 于是多报一条「内容哈希不符」,hashed 也会虚高。 + """ + good = _mk(tmp_path, "good.bin", b"g" * 32) + bad = _mk(tmp_path, "bad.bin", b"b" * 32) + cloud = FakeCloud(fail_names={"bad.bin"}, + preload=[{"type": 1, "name": "bad.bin", "id": "old", + "size": 32, "sha256": "0" * 64}]) + _use_push_cloud(monkeypatch, cloud) + logs = [] + + res = engine.push(StubStore(), [(good, "good.bin"), (bad, "bad.bin")], + ["root"], log=logs.append) + + assert res["ok"] is False and res["fail"] == 1 + assert not any("内容哈希不符" in m for m in logs), "失败项不该参与哈希复核" + assert any("SHA256 一致 1 个" in m for m in logs), "只该复核真正传上去的 1 个" + + +def test_push_hash_check_respects_size_cap(tmp_path, monkeypatch): + """超过 VERIFY_SHA256_MAX 的文件只比体积,与 sync 同口径。""" + monkeypatch.setattr(engine, "VERIFY_SHA256_MAX", 1) + big = _mk(tmp_path, "big.bin", b"x" * 64) + cloud = FakeCloud(wrong_hash={"big.bin"}) + _use_push_cloud(monkeypatch, cloud) + logs = [] + + res = engine.push(StubStore(), [(big, "big.bin")], ["root"], log=logs.append) + + assert res["ok"] is True, "大文件不该因为云端哈希不同就判失败" + assert not any("内容哈希不符" in m for m in logs) + + +def test_push_hash_mismatch_still_caught(tmp_path, monkeypatch): + """加体积上限之后,小文件的内容哈希仍然要真比、真报错。""" + small = _mk(tmp_path, "s.bin", b"x" * 64) + cloud = FakeCloud(wrong_hash={"s.bin"}) + _use_push_cloud(monkeypatch, cloud) + logs = [] + + res = engine.push(StubStore(), [(small, "s.bin")], ["root"], log=logs.append) + + assert res["ok"] is False + assert any("内容哈希不符" in m for m in logs) + + +# ── push:大小写 ────────────────────────────────────────── +def test_push_verify_is_case_insensitive(tmp_path, monkeypatch): + """云端回小写、本地文件名大写时不能报「云端未找到」。 + + 云盘本身大小写不敏感,sync 和孤儿扫描早就按小写比对,push 这条漏了。 + """ + p = _mk(tmp_path, "A.TXT", b"hello") + cloud = FakeCloud(lower_names=True) + _use_push_cloud(monkeypatch, cloud) + logs = [] + + res = engine.push(StubStore(), [(p, "A.TXT")], ["root"], log=logs.append) + + assert res["ok"] is True, logs + assert not any("云端未找到" in m for m in logs)