Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 逐行读完之后确认过的。断点续传这一版没做,原因写在最后。
Expand Down Expand Up @@ -30,10 +52,10 @@

| 评审提到的问题 | 核对结果 | 这一版 |
|---|---|---|
| 大文件没有断点续传 | 成立,是目前最大的可靠性缺口 | 没做,见最后 |
| 大文件没有断点续传 | 成立,是目前最大的可靠性缺口 | 没做,见最后(v0.2.2 补上) |
| 排除项对文件不生效 | 成立,v0.2.0 的说明里还写过「与同步一致」 | 已修 |
| 空文件悄悄跳过 | 成立;原文说推送是「明确报错」,其实是整批否决 | 已修 |
| CI 没有自动化测试 | 成立 | 单测写好了,等断点续传定了再合 |
| CI 没有自动化测试 | 成立 | 单测写好了,等断点续传定了再合(v0.2.2 已合入) |
| 分片数应该向上取整 | 成立;但举证不成立,22GiB 正好是 2816 × 8MiB,没走到末片超长那条路 | 已修 |
| 每次同步都重列云端目录 | 成立 | 没做,要加本地索引,改动太大 |
| 新建任务没做真实路径校验 | 原文没提,核验时补的 | 已修,编辑任务一起修 |
Expand All @@ -43,6 +65,8 @@

### 还没做的

(前两项在 v0.2.2 已经补上了,这一节保留的是 v0.2.1 当时的记录。)

1. 断点续传。要先测服务端认不认几小时前的上传会话,探针脚本已经写好,等结果出来再定方案。
2. 纯函数单测。分片数、排除规则、路径校验、空文件计数四个点已经写好,等这版收尾时一起合。
3. 三处暂时不动,都是改动面大于收益:engine.py 里那个全局 IPv4 补丁(要真机验证传输路径)、main.py 两处直接写 SQL 的地方、每次跑完都重建一遍调度任务。
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

- **增量同步**:按「文件名 + 体积」比对云端,只传新文件和变过的文件;文件名大小写不敏感比对(联通云盘特性,避免 iOS 导出的 `FullSizeRender.heic` 与 `FULLSIZERENDER.HEIC` 互相覆盖)
- **两级复核**:上传后先比体积,云端返回了 SHA256 且文件不超过 64MB 时再比内容哈希——「云盘说收到了」升级成「内容确认一致」
- **断点续传**:16MB 以上的文件按 8MB 分片上传,传完的片记在本机的会话表里,断了重跑从断点接上,已传的片不重传。会话 24 小时有效,文件改动、换目标目录、换令牌都会作废重开。`TP_RESUME=0` 可整体关掉
- **试运行**:只扫描比对列出待传清单,一个字节都不传。新任务先试运行再真跑
- **定时调度**:每个任务一条 cron 表达式,改完即生效,不用重启
- **推送模式**:密码库导出、虚拟机镜像这类需要先在宿主机打包/加密的任务,由宿主机脚本打包后调 API 上传,支持云端保留 N 份滚动清理
Expand Down Expand Up @@ -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 库 |
Expand Down Expand Up @@ -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` 内的指定目录:
Expand Down
24 changes: 17 additions & 7 deletions app/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""))
Expand Down Expand Up @@ -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):
Expand All @@ -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)
Expand Down
202 changes: 202 additions & 0 deletions tests/test_reporting.py
Original file line number Diff line number Diff line change
@@ -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)
Loading