dsh-usage-badge 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/docs/design-notes.md +389 -1
- package/lib/buckets.js +237 -5
- package/lib/cache-store.js +574 -129
- package/lib/client.js +348 -3
- package/lib/fold.js +20 -10
- package/lib/index.js +469 -5
- package/lib/sync.js +1179 -0
- package/lib/webdav.js +532 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -82,18 +82,33 @@ dsh plugin --profile web remove dsh-usage-badge
|
|
|
82
82
|
## 已知限制
|
|
83
83
|
|
|
84
84
|
- **首次启动要冷扫一遍全部会话日志**(200 多份实测约 10 秒;分片让出事件循环,界面不会卡住)。之后走增量,几乎瞬时。
|
|
85
|
-
-
|
|
85
|
+
- **历史依赖日志,但日志变短不再吃掉历史**:日志被删掉后靠缓存继续计入;缓存也丢了,那段历史就没了。同一个会话的日志**变短或还原成更早的备份**时,缓存会**把更完整的那份留着**(折叠结果变小就只作为备份副本存下,统计仍用完整的那份),要等日志真的更全、却不再包含某一天,那天才会被丢掉。**例外是改名**:会话是按「日志所在目录」识别的,把某个会话目录改名会让同一份用量各算一份(见下)。DSH 自己从不裁日志:格式升级时它另写一代新文件、旧代保留,而实测换代后统计结果逐项相同。
|
|
86
86
|
- **日粒度区间仍只覆盖最近一年**:`近 7 日` / `近 30 日` / `近 12 个月` 与它们下面的滚动热力图都基于快照的最近 400 天。要看更早的日子,用 **`按年`**:那一档的月度合计、年度总额与**自然年热力图**都是直接查缓存库的,任何一年都在。
|
|
87
87
|
- **只统计 DSH 记进日志的调用**:某个东西直接请求外部 API 且不向 DSH 上报 usage,这部分统计不到。
|
|
88
|
+
- **云同步已经能用了**:把每天的用量与价格表备份到一台 WebDAV 服务器(Digest 认证),多台机器互相合并 —— 弹窗里多了「同步」页签:填地址、用户名、密码,点「测试连接」再「立即同步」。开启之后它还会**在启动后自己同步一次**,并在你**改完价格表几秒后自动推送**;服务器连不上时本地照常工作,绝不会因为同步失败而丢掉一次编辑。见 [docs/design-notes.md](docs/design-notes.md) 的「云同步(WebDAV + Digest)—— 设计」一节。
|
|
89
|
+
- 每台机器有自己的**设备名**,它只决定「这台机器的新数据写进哪个分区」,**不是**会话身份;远端所有设备的历史都会合并,所以**改设备名不会让用量累增**。但设备名在服务器上必须**唯一**:两台机器用同一个名字会互相覆盖分片,插件会拒绝发布并在面板里说明。
|
|
90
|
+
- **删除不会跨设备传播**:本机删掉日志后,新机器不会再拉到那段历史,但**已经拉过它的机器**库里那份仍然在(远端从不因为本机的改动而删东西)。
|
|
91
|
+
- **会话是按日志目录识别的**:把某个会话目录改名,同一份用量会被当成两个会话、各算一份(实测 ¥1.5 → ¥3.0)。删日志、日志变短、还原旧备份这三种情况都不会丢历史。**这不包括「设备」字段** —— 那只是这台机器往哪个分区写,改设备名不会让用量累增。
|
|
88
92
|
- **数字是估算**:token 口径和官方账单的列数不同,**对账请以金额为准**。
|
|
89
93
|
|
|
90
94
|
## 开发
|
|
91
95
|
|
|
92
96
|
```sh
|
|
93
97
|
npm install # 只为测试装 react / react-dom
|
|
94
|
-
npm test #
|
|
98
|
+
npm test # 11 个套件:包契约、定价、节假日、官方定价解析、缓存存储、同步分片、同步编排、宿主同步接线、自动触发、WebDAV(Digest)、浏览器半渲染
|
|
95
99
|
```
|
|
96
100
|
|
|
101
|
+
云同步的前五步已经在真实的 `bytemark/webdav` 容器上核对通过。要自己核对一遍:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
# PowerShell
|
|
105
|
+
$env:DSH_USAGE_BADGE_WEBDAV_URL='http://nas:19347/'
|
|
106
|
+
$env:DSH_USAGE_BADGE_WEBDAV_USER='…'; $env:DSH_USAGE_BADGE_WEBDAV_PASSWORD='…'
|
|
107
|
+
node test/webdav.live.mjs # Digest 握手与七个方法
|
|
108
|
+
npm run test:sync # 一条完整流水线:发布 → 另一台空库拉取 → 零重传
|
|
109
|
+
```
|
|
110
|
+
两个脚本跑完都会清理自己创建的目录;缺环境变量则跳过。
|
|
111
|
+
|
|
97
112
|
改了东西怎么生效:
|
|
98
113
|
|
|
99
114
|
| 改了哪里 | 要做什么 |
|
|
@@ -103,7 +118,7 @@ npm test # 6 个套件:包契约、定价、节假日、官方定价解
|
|
|
103
118
|
|
|
104
119
|
宿主半和界面半各带一个版本号(`HOST_API_VERSION` / `REQUIRED_HOST_API`),对不上时面板顶部会直接说明该「重启客户端」还是该「刷新页面」—— 不用再靠「按钮点了没反应」去猜:宿主半装载在进程启动时,只刷新页面拿到的是新界面配旧宿主,而旧宿主不认识的新写入会返回 200 并什么都不做。
|
|
105
120
|
|
|
106
|
-
更详细的东西(实现取舍、官方政策原文、路由清单、配置字段语义、当年的踩坑记录)在 [docs/design-notes.md](docs/design-notes.md)
|
|
121
|
+
更详细的东西(实现取舍、官方政策原文、路由清单、配置字段语义、当年的踩坑记录)在 [docs/design-notes.md](docs/design-notes.md) —— 其中「云同步(WebDAV + Digest)—— 设计」一节是**多机器备份与合并的定稿设计(尚未实现)**。
|
|
107
122
|
|
|
108
123
|
## License
|
|
109
124
|
|
package/docs/design-notes.md
CHANGED
|
@@ -361,6 +361,392 @@ npm test
|
|
|
361
361
|
|
|
362
362
|
`test/smoke.mjs` 只读会话日志与价格表,但它挂载的是**真实**宿主半,所以冷缓存或日志有变化时会顺带重建插件自己的缓存(`storages/usage-badge/cache.sqlite`,或回退时的 `cache.json`;都是插件自己的文件,随时可删);其余套件只写临时目录,联网脚本不写任何文件。
|
|
363
363
|
|
|
364
|
+
> **云同步的进度**:第一到第四步已实现 —— Digest 客户端(含联网核对)、存储层行模型、分片格式与导出/导入、以及 owner 锁 + WebDAV 编排(`test/sync.live.mjs` 已在真实容器上端到端通过)。剩下的:价格表同步、`/usage-badge/sync` 路由与面板「同步」页签。
|
|
365
|
+
|
|
366
|
+
## 云同步(WebDAV + Digest)—— 设计
|
|
367
|
+
|
|
368
|
+
> **状态:全部完成。** 五步都已实现:Digest 客户端、存储层行模型、分片格式与导出/导入、owner 锁与 WebDAV 编排、价格表同步 + `/usage-badge/sync` 路由 + 面板「同步」页签;两套联网核对都在真实容器上通过。这一节记录结论与它的依据。
|
|
369
|
+
|
|
370
|
+
### 已经落地的五步
|
|
371
|
+
|
|
372
|
+
**第一步 — `lib/webdav.js`**(同步要骑的那层 HTTP 客户端),通过两套检查:
|
|
373
|
+
|
|
374
|
+
- **`test/webdav.verify.mjs`(60 项)**:Digest 算术(RFC 2617 §3.5 的 `Mufasa` 向量逐字段钉住)、无 `qop` 的 RFC 2069 路径、`stale=true` 重试、`SHA-256`/`MD5-sess`/`auth-int` 的具名拒绝、请求行 `uri` 的原样保留(含 `%XX` 与查询串)、以及七个方法与失败路径**对着一个会真正校验摘要的假服务端**(`test/webdav-server.mjs`)跑通。
|
|
375
|
+
- **`test/webdav.live.mjs`**:对真实容器跑 `OPTIONS` → `MKCOL` → `PUT` → `GET`(逐字节比对)→ `PROPFIND` → `MOVE` → `DELETE`,全部通过,且运行后不在服务器上留下任何东西。
|
|
376
|
+
|
|
377
|
+
**第二步 — 存储层的行模型**(`lib/buckets.js` + `lib/cache-store.js` + `lib/fold.js`):
|
|
378
|
+
|
|
379
|
+
- `days` 表多了 `source` / `kind` / `seq` / `file_size` / `file_mtime_ms` / `at`,主键变成 `(session_id, date, source, kind)`;
|
|
380
|
+
- `SQLITE_SCHEMA_VERSION` 1 → 2,`migrateSchema()` **按列判断**并在同一个事务里把旧行搬过来;
|
|
381
|
+
- `load()` 返回**已 resolve** 的记录(`days` Map 照旧,另附 `rows`),所以 `index.js`、快照与 `按年` 视图**一行都不用改**;
|
|
382
|
+
- `cache.verify.mjs` 新增 section 8/9(两个后端各跑一遍)。
|
|
383
|
+
|
|
384
|
+
**第三步 — 分片格式与两个方向**(`lib/sync.js` + `store.intakeRows()`):
|
|
385
|
+
|
|
386
|
+
- 一个分片是**一个设备的一个自然月**,NDJSON + `zstd`,头行写 `{v, device}`;分片字节**不含时间戳**(否则每次重规划 hash 都变、每月都会重传);
|
|
387
|
+
- 每设备发布**自己的** `index.json`,**没有共享清单**(共享就要锁);
|
|
388
|
+
- `planUpload()` 按内容 sha256 决定哪些月要传;`planDownload()` 用 `windowYears`(默认 5)限制**首次拉取**,不限制保留;
|
|
389
|
+
- `store.intakeRows()`:从不删,只存**严格更完整**的副本。
|
|
390
|
+
|
|
391
|
+
**第四步 — owner 锁与编排**(`lib/sync.js` 的 `SyncEngine`):
|
|
392
|
+
|
|
393
|
+
- **owner 锁**(`host.lock` 记 pid + 进程启动时间 + instanceId):拿不到就**只读降级**、置 `lastResult: 'locked'`、**不动上次同步时间**(面板不该说刚刚同步过);陈旧锁由 `ESRCH` 判定接管,`EPERM` 一律当作「有人」,因为第二个写者比一个只读实例糟得多;
|
|
394
|
+
- **`sync.json`**:配置(url / username / **ha1** / realm / deviceId / label / windowYears / `allowInsecureTls` / instanceId)+ 状态(`held`:已应用的 `<device>/<key>` → sha256;`published`:本设备各月分片);
|
|
395
|
+
- **`run()` 的顺序就是正确性论证**:拿锁 → 发现设备(`PROPFIND usage-badge/`)→ 读各设备清单 → **先拉后推** → 只传变化的月 → 建目录 → 写 marker → **最后**写清单(清单先写会让别的设备去取一个还不存在的分片);
|
|
396
|
+
- **凭据存 HA1 而非明文**:`WebDavClient` 新增 `passwordHash` 参数,把服务端密码文件里那串 `MD5(user:realm:pass)` 直接当凭据用,明文永不落盘(`sync.json` 与断言都钉住这一点);
|
|
397
|
+
- `test/sync-engine.verify.mjs`:两设备对一台假服务端跑完整流程 —— 发布、拉取、相加、不重传、缺分片不记为已应用、锁把第二个宿主挡在外面。
|
|
398
|
+
|
|
399
|
+
**第四步抓到的三个真 bug**(都是「离线假服务端通过、真实容器才暴露」或反之):
|
|
400
|
+
|
|
401
|
+
| bug | 后果 | 修法 |
|
|
402
|
+
|---|---|---|
|
|
403
|
+
| 引擎**先上传分片、后 `MKCOL`** | 父目录还不存在 → 真实 `mod_dav_fs` 回 **403**,第一次发布必然失败 | 建目录移到写任何文件之前(`mkcol` 本来就幂等) |
|
|
404
|
+
| `state.published` 存的是**分片映射**,而 `planUpload` 要 manifest 形状(`.chunks`) | `previous.chunks` 永远为空 → **每次同步都重传全部月份**,而日志看起来完全正常 | 调用处包一层 `{ chunks: ... }` |
|
|
405
|
+
| `discover()` 从根 `PROPFIND` 取设备名 | 根的子项包含 `usage-badge` 自己 → 去找 `usage-badge/usage-badge/index.json` | 直接列 `usage-badge/`,并把 404 当作「还没有设备」 |
|
|
406
|
+
|
|
407
|
+
**第五步 — 价格表、路由与面板**:
|
|
408
|
+
|
|
409
|
+
- **价格表同步**(`planPricing()` + `SyncEngine.syncPricing()`):三态判定 —— 只有远端变了就**取**、只有本地变了就**推**、两边都变了就是**冲突**。冲突时**两个版本都留下**:赢家按「后写者」定,输家写成 `.conflict-pricing-<device>-<时间>.json`,而**本地手写的那份永不被他人的版本覆盖** —— 价格表是人写的,静默覆盖是这里唯一不能出现的结果。共享文件是 `usage-badge/pricing.json`,另有每设备一份 `pricing-<device>.json`。
|
|
410
|
+
- **`/usage-badge/sync` 路由**:`GET` 状态与配置(凭据只回 6 位前缀 + `hasCredential`)、`PUT|POST` 写配置(明文密码**当场**在宿主半里算成 HA1,也可以直接粘贴 HA1)、`POST /sync/test` 只做连接自检(连不上会**失败但不抛**)、`POST /sync/run` 跑一次。三个写入口都要求 loopback。
|
|
411
|
+
- **面板第三个页签「同步」**:地址 / 用户名 / 密码 / 设备(带远端设备下拉 —— 那就是「重装后并入已有设备」的入口)/ 首次拉取年数 / 自签名证书开关,加「保存」「测试连接」「立即同步」,以及一段状态(上次结果、已发布月份、远端设备与各自的月份数、冲突文件提示、本机设备名与凭据状态)。**凭据字段永远回空**:页面能知道「配了」,永远不知道「是什么」。
|
|
412
|
+
- **`HOST_API_VERSION` 6 → 7**,`REQUIRED_HOST_API` 同步跟上(`client.render.mjs` 有一条断言钉住两半相等)。
|
|
413
|
+
- 插件**挂载时不创建任何东西**:`sync.json`、`host.lock` 都是第一次碰「同步」时才出现(拿锁 = 这个进程可以写)。这条有断言钉着,否则每个从不用同步的安装也会多出两个文件。
|
|
414
|
+
|
|
415
|
+
**自动触发**(`lib/index.js`):
|
|
416
|
+
|
|
417
|
+
- **启动后延迟一次**(`STARTUP_SYNC_DELAY_MS`,默认 8 秒):只在**同步已经配置且开启**时才排期,所以从没开过同步的 home 不会因此多出 `sync.json`、锁或网络请求;延迟是为了让冷折叠与首屏先走完 —— 一上来就同步会和「让徽标先显示东西」抢时间;锁不在本实例手上时**跳过并说明**,而不是把锁从别人手里抢过来。
|
|
418
|
+
- **价格表变更后推一次**(`PRICING_PUSH_DELAY_MS`,默认 3 秒,去抖):改完一行再改下一行是**一个**意图,所以等一小会儿;推的是 `pushPricing()` —— **只动价格表,不跑整轮**(价格编辑与分片无关,整轮会把整个远端清单拖进来)。它是 fire-and-forget:**连不上服务器绝不能让本地编辑失败**,留下的状态由面板报告。
|
|
419
|
+
- 两个延迟都可以用环境变量覆盖(`DSH_USAGE_BADGE_STARTUP_SYNC_MS` / `DSH_USAGE_BADGE_PRICING_PUSH_MS`),这是 `test/sync-triggers.verify.mjs` 不必真等几秒的办法。**启动延迟在 `apply()` 里同步读取**,不在 warm 的回调里读 —— warm 是异步的,等它跑完再读,拿到的会是「那之后」的值(测试设的覆盖值那时已经被撤掉,于是它又变回 8 秒,测试只能干等)。
|
|
420
|
+
- 卸载时两个待触发的定时器都会被清掉,**锁也会交还**:宿主正在退出还去推一次,会为一个已经不存在的进程写状态;而留着锁会让下一次挂载(热重载、或同一进程里再挂一次)被自己的锁拒绝。
|
|
421
|
+
|
|
422
|
+
### 第六轮:一轮对抗性审查找到并修掉的问题
|
|
423
|
+
|
|
424
|
+
两个只读审查者分别攻 `lib/sync.js`(数据正确性与失败路径)和宿主接线/面板/凭据/并发,我逐条复现后再修。两人的方法值得记下:都先把 `lib/` 快照到临时目录并核对 SHA256,发现文件在我的编辑中漂移后**主动要求冻结时机**,并以「结论对应修订哈希」标注全部实测。
|
|
425
|
+
|
|
426
|
+
| # | 问题 | 后果 | 修法 |
|
|
427
|
+
|---|---|---|---|
|
|
428
|
+
| A1 | **两台机器填同一个设备名会互相覆盖**(实测:机器 1 的 300 被机器 2 覆盖,第三台只看到 300,应为 600) | 对方的月份被 `MOVE` 覆盖,行永久消失,**没有任何报错** | 清单写入发布者的 `instanceId`;`push()` 发现本设备名目录的 owner 是**另一个 installation** 就拒绝发布、记 `state.collision`、日志与面板都说清楚。owner 字段缺失(旧构建写的目录)按「接管」处理,升级路径不受影响 |
|
|
429
|
+
| A2 | **这个新守卫把「重装后并回自己历史」也拒了**(重装后 `instanceId` 必变),而且面板还建议**删掉远端那份唯一备份** | 一个承诺过的入口变成一条会诱导删数据的路 | 冲突时**照常 pull、只拒绝 push**:那台机器的历史立刻回来,名字的问题慢慢解决;面板文案改成「换个没用过的名字,远端的历史照样会合并进来」,不再建议删远端目录 |
|
|
430
|
+
| A3 | **冲突状态写进 `sync.json` 后,后续每一轮都把自己那份分片当成别人的拉回来**(旧构建写的坏分片 → 面板永久 `partial`) | 每轮都做一次注定失败的拉取 | 读不读自己目录只看**本轮算出来的标志**,不看持久化的 `collision` |
|
|
431
|
+
| A4 | **连 `sync.json` 一起拷到另一台机器能绕过守卫**(`instanceId` 也被拷过去) | 设计文档点名的那个场景恰好检不出,仍然静默覆盖 | 用**字节**判断:某月我们记下的 sha 与服务器现在的不一致、而本机该月又**没有自己的改动**时,判为「有别人在往这个目录写」,整轮拒绝发布(本轮不写清单,所以不会把冲突半记录进去) |
|
|
432
|
+
| A5 | **某月最后一行被 fold 删掉后清单永不撤回**:新机器把已删的用量拉回来(实测 A=3333 而新机器=4444) | 用户删掉的东西会「复活」,数字错 | 发布时对「本机已无任何行」的月份**撤回**:清单删掉该条目、远端的**本设备自己的**分片也删掉(`withdrew N month(s)`);失败则整轮不写清单、下轮重试 |
|
|
433
|
+
| A6 | **价格表读不到被记成 `ok`**(`unreadable` 当成 `action: 'none'`),且自动推送的结果完全不进面板 | 最需要知道「没对上」的时候面板显示一切正常 | `unreadable`/抛错都记 `failed`;推送结果并入 `system.last` |
|
|
434
|
+
| A7 | **`GET /sync` 是黑名单式脱敏**:只摘掉 `ha1`,其余键原样发出 —— 旧构建写过的 `password`(明文)会跟着出去 | 凭据可能进 HTTP 响应 | 配置改成**白名单**逐字段重建(`safeSyncConfig`);测试往 `sync.json` 塞 `password`/`passwordHash`,断言都出不去 |
|
|
435
|
+
| A8 | **重启后面板永远显示「上次同步:从未」**(持久化的 `state.lastResult` 从不被读) | 用户第一眼看的那一行是错的 | `syncSystem()` 用 `document.state` 初始化 `last` |
|
|
436
|
+
| A9 | **`locked`/`skipped` 也把时间戳刷成「刚刚」** | 面板显示刚同步过,其实什么都没做 | 用引擎记录的 `lastSyncAt`;`locked`/`skipped` 保留原值 |
|
|
437
|
+
| A10 | **卸载不释放锁**:`host.lock` 留着指向一个**还活着的** pid(自己),同进程再挂载被自己的锁拒绝 | 热重载、或同一目录第二次挂载直接变成只读 | disposer 里 `engine.release()`;并且**在 `syncSystem()` 里把 `engine.ownsLock` 置位** —— 否则 `release()` 会以为锁不是自己的而拒绝删除 |
|
|
438
|
+
| A11 | **`runSync()` 里 `refreshOnce()` 在 try 之外**,启动路径 `void runSync().then(...)` 没有 `.catch` | 折叠抛错 → 路由 500 + 未处理 rejection(Node 默认终止进程) | 包 try/catch,返回 `failed` |
|
|
439
|
+
| A12 | **面板 `run()` 不检查 `response.ok`**:500 报文只有 `error`,于是 `runNote=''`、状态 `ran` | 一次彻底失败被渲染成「没有消息的成功」 | 检查 `ok`,用 `body.error` |
|
|
440
|
+
| A13 | **`acquireLock` 的 `mkdirSync` 在 try 外** | 只读 home / `storages` 是文件时,`GET /sync` 与 `POST /run` 都 500 | 返回结构化的 `acquired:false` + 原因 |
|
|
441
|
+
| A14 | **面板的「远端的设备」来自 `state.held`**(只在真正导入分片时写入,且跳过自己的设备名与窗口外月份) | 已发布但从未被拉取过的设备不显示 | 分成两行:`held` 是「本机已读到的远端月份」,另用后台 `discover()` 拿「服务器上的设备」;读不到就说读不到 |
|
|
442
|
+
| A15 | **`deviceId = 'local'` 被允许**,而 `source: 'local'` 正是「本机自己的行」这条不变量的标记 | 一台叫 `local` 的机器让所有对端把它人的行当成自己的再转发,本机 fold 还能删掉它们 | `safeDeviceId` 拒绝 `local`(它同时是路径段和归属标记) |
|
|
443
|
+
| A16 | **`run()` 与自动价格推送之间没有互斥**(同进程共用一把锁当串行化用) | 分片 PUT 与价格表 PUT 交错 | 推送排进同一条链(`instance.pushing`),与 `run()` 串行 |
|
|
444
|
+
| A17 | `GET /sync` 无 loopback 检查,且会拿锁建文件 | 局域网内可读到地址/用户名/前缀 | `GET /sync` 也要求 loopback |
|
|
445
|
+
|
|
446
|
+
**已知未修(诚实列出)**:
|
|
447
|
+
|
|
448
|
+
- **已经拉过某月的对端,不会跟着撤回**:本设备撤回某月后,别的机器库里那份仍在(`intakeRows` 永不删)。要彻底解决需要 tombstone 或「减少」语义,代价大,因此本轮只解决「新机器不再复活」。表现是两台机器的数字可能分叉,方向是**别人多、自己少**。
|
|
449
|
+
- **跨机共用同一份 `sync.json`(同 deviceId + 同 instanceId)**:A4 的字节判据能挡住「本机该月无改动」的情形;若两台同时在写同一个月份,仍可能互相覆盖。这是文档里点名的场景,A4 是它的一半防线。
|
|
450
|
+
- **大小写不敏感的 WebDAV 服务器**:`Desk` 与 `desk` 会落到同一物理目录,而冲突判定是字符串比较。目标镜像(Linux Apache)区分大小写,未处理。
|
|
451
|
+
- **`acquireLock` 不是原子的**(`readLock` → `writeFileSync`,没有 `O_EXCL`):两个进程同时接管一把陈旧锁时理论上可双双认为成功。窗口在微秒级,未复现。
|
|
452
|
+
|
|
453
|
+
**每一步抓到真 bug 的清单**(几乎都是「离线假服务端通过、真实容器才暴露」或反之):
|
|
454
|
+
|
|
455
|
+
| 步骤 | bug | 后果 | 修法 |
|
|
456
|
+
|---|---|---|---|
|
|
457
|
+
| 一 | 重试预算把「第一次不带凭据的 401 挑战」也算一次重试 | 容器重启 / 长上传跨过 nonce 寿命时冒出一句莫名其妙的「服务端没标 stale」 | 握手本身不占预算,`stale=true` 可重试两次 |
|
|
458
|
+
| 三 | 分片把 `source: 'local'` 原样带过去 | 接收方把**别台机器的历史当成自己的**:再转发、且本机 fold 有权删它 | 导出时改写成设备 id |
|
|
459
|
+
| 三 | `intakeRows` 只写 `days`、不写会话游标 | `readRecords()` 遍历 `sessions` 再把 days 挂上 —— 导入的历史**完全读不出来** | 导入时按最全 stamp 补/推游标 |
|
|
460
|
+
| 四 | 引擎**先上传分片、后 `MKCOL`** | 父目录还不存在 → 真实 `mod_dav_fs` 回 **403**,第一次发布必然失败 | 建目录移到写任何文件之前(`mkcol` 本来就幂等) |
|
|
461
|
+
| 四 | `state.published` 存的是**分片映射**,而 `planUpload` 要 manifest 形状(`.chunks`) | `previous.chunks` 永远为空 → **每次同步都重传全部月份**,而日志看起来完全正常 | 调用处包一层 `{ chunks: ... }` |
|
|
462
|
+
| 四 | `discover()` 从根 `PROPFIND` 取设备名 | 根的子项包含 `usage-badge` 自己 → 去找 `usage-badge/usage-badge/index.json` | 直接列 `usage-badge/`,并把 404 当作「还没有设备」 |
|
|
463
|
+
| 五 | `runSync()` 又去拿一次**自己已经持有**的锁 | 同进程自锁 → 每次「立即同步」都报「被另一个 DSH 实例占用」(pid 还是本进程) | 已经持有就直接用 |
|
|
464
|
+
| 五 | 远端没有价格表时把基线写成**空缓冲的哈希** | 下一次运行把「本地没有文件」误判成「本地改过」,新装机器第一次同步就报冲突 | 基线只在真有远端文件时记录 |
|
|
465
|
+
| 五 | 保存密码时**要求 realm 已经存在** | 面板根本没有 realm 字段(人也不该知道它),于是「填地址 + 用户名 + 密码,点保存」必然失败:`a username and a realm are needed to hash a password` —— 一个界面从没出现过的词 | realm 默认 `WebDAV`,点「测试连接」会被服务器真正声明的 realm 覆盖;只保留「缺用户名」这一种真错误 |
|
|
466
|
+
| 五 | 面板把失败信息 `state.slice(8)` 从状态名里切出来,且成功提示的条件漏掉了「测试连接」 | 消息为空时那一行渲染成空白(「失败:」后面什么都没有),测试连接成功的提示也被吞掉 | 失败原文单独存 `failNote`;提示按状态渲染 |
|
|
467
|
+
|
|
468
|
+
假服务端也补了两处**只有在真服务器上才会暴露**的行为:`MKCOL` 对不存在的父目录回 409,以及集合路径带前导斜杠(否则发现逻辑会静默失灵)。`test/sync.live.mjs` 则是端到端核对:设备 A 发布一天 → 设备 B 空库拉取 → 数字与归属都对 → 再同步一次不传任何东西 → 清理自己建的两个设备目录(**不删 `usage-badge/` 本身**,它可能装着别人的历史)。
|
|
469
|
+
|
|
470
|
+
联网核对确认了几件只有真服务器才会说的事:
|
|
471
|
+
|
|
472
|
+
| 观测 | 结论 |
|
|
473
|
+
|---|---|
|
|
474
|
+
| `OPTIONS` → `200`,`DAV: 1,2, <http://apache.org/dav/propset/fs/1>`,realm `WebDAV` | 与设计假设一致;realm 来自 challenge,配置里不用猜 |
|
|
475
|
+
| `Allow` 里**没有** `MKCOL` 与 `PUT`(却都能用) | Apache 只列出被自己 handler 拦截的方法。**不能用 `Allow` 判断 DAV 是否可写**,要看 `DAV` 令牌 + 实际调用 |
|
|
476
|
+
| 目录**不带尾斜杠**的 `PROPFIND` → **301**(`Location` 补上斜杠) | 客户端「不跟随重定向」是对的;分片路径必须由构造保证带斜杠 |
|
|
477
|
+
| `MOVE` + `Overwrite: F` 撞名 → 412 | 上传到临时名再 MOVE 的原子落地路径与设计一致 |
|
|
478
|
+
|
|
479
|
+
实现这一步时抓到并修掉了一个真 bug:**重试预算把「第一次不带凭据的 401 挑战」也算作一次重试**,于是真正需要的那次 nonce 轮换重试被预算挡掉。这在真实容器上不会触发(那是正常的首次握手),但容器重启或长上传跨过 nonce 寿命时会现场报「服务端没标 stale」这种莫名其妙的错。现在的规则是:握手本身不占预算,`stale=true` 可重试两次,其余 401 一律当认证失败上报(`MAX_HANDSHAKES`,避免把密码试到锁定)。
|
|
480
|
+
|
|
481
|
+
### 决策台账
|
|
482
|
+
|
|
483
|
+
| 问题 | 结论 | 依据 |
|
|
484
|
+
|---|---|---|
|
|
485
|
+
| 同步的目的 | **保住真正的历史**(不只是「另一台机器也想看」) | 缓存是派生的:日志还在时它只是加速;**日志被删掉之后,缓存里那份就是唯一副本** |
|
|
486
|
+
| 机器数 | 多台,需要合并 | 所以要按设备分区,并且合并键不能含设备 |
|
|
487
|
+
| 价格表 | **一起同步** | 缓存丢了能重新折,`pricing.json` 丢了只能重配 —— 它才是唯一不可再生的资产 |
|
|
488
|
+
| 触发 | 面板「立即同步」+ 启动后延迟一次 + 配置变更后推一次,**无常驻轮询** | 这是个「一天看几次」的插件,不该常驻网络 |
|
|
489
|
+
| 第一次拉取 | 默认只拉**最近 5 年**,可改 | 重用户全量可达数百 MB,见「容量」 |
|
|
490
|
+
| 主视图 430 天窗口 | **不动** | 实测日志很少真的变短(见下),历史入口仍由 `按年` 承担 |
|
|
491
|
+
| 价格冲突 | 后写者赢 + 留 `.conflict-<device>-<时间>` 副本 + 日志写明 | 与项目「宁可说出来,也不要静默丢东西」一致 |
|
|
492
|
+
| 本地折叠的缩水 | 改成**变小才留副本、读时 resolve**(见「合并语义」) | 一处改动同时解决截断、改名双计、跨机覆盖三件事,且正常增长路径零冗余 |
|
|
493
|
+
| **改设备名** | **不重算、不累增**;`session_id` 才是身份,设备名只是「这台机器往哪个分区写」 | `resolveRows()` 按 `(session_id, date)` 归一,同一会话同一天的多个副本**只留一个赢家**,`source` 不参与竞争。实测:一台机器 6666 → 改名 → 第三台机器照样看到 **6666**(翻倍会是 13332)。代价只有一次性的重复:改名后的那一次同步会把旧名下的分片**重新拉回来再以新名发一遍**(旧目录按「远端从不因本机改动而删东西」保留),此后不再移动 |
|
|
494
|
+
|
|
495
|
+
### 服务端事实(`bytemark/webdav`,即 `httpd:alpine` + `conf/conf-available/dav.conf`)
|
|
496
|
+
|
|
497
|
+
客户端按这些事实写,不做「通用 WebDAV 客户端」:
|
|
498
|
+
|
|
499
|
+
| 事实 | 出处 | 对客户端的影响 |
|
|
500
|
+
|---|---|---|
|
|
501
|
+
| **只支持 MD5**(`AuthDigestAlgorithm` 语法就是 `MD5\|MD5-sess`,且文档写明 MD5-sess「not correctly implemented yet」) | [mod_auth_digest](https://httpd.apache.org/docs/2.4/en/mod/mod_auth_digest.html) | 不必写 SHA-256 分支 |
|
|
502
|
+
| **`qop=auth`**(默认),`auth-int` 未实现,`none` 即 RFC 2069 | 同上 | 只需 `qop=auth` + 无 qop 两条路径 |
|
|
503
|
+
| **`AuthDigestDomain` 未配置** | 镜像的 `dav.conf` | 每个请求都带 Authorization;nonce 只在同一 origin 内复用 |
|
|
504
|
+
| **nonce 寿命 300 秒**,过期返回 `401 stale=true` | `AuthDigestNonceLifetime` 默认值 | 优先复用 header 里的 `nextnonce`;`stale` 可重试 |
|
|
505
|
+
| **`REALM` 默认 `WebDAV`**;改它必须重跑 `htdigest` | 镜像 entrypoint + README | 只用 challenge 里返回的 realm,绝不假设 |
|
|
506
|
+
| **密码文件就是 `MD5(user:realm:pass)`** | entrypoint 用 `md5sum` 生成 | 客户端可以存 HA1 而不是明文密码(见「安全」) |
|
|
507
|
+
| **单用户共享账号**;`LOCATION` 默认 `/`;默认明文 80 端口 | README | 每台机器一个分区;建议套 HTTPS |
|
|
508
|
+
| 有 LOCK(`DavLockDB`) | Dockerfile | **但设计不用 LOCK**:乐观锁 + 每次比对 hash 已经够,LOCK 会引入「崩溃 → 残留锁 → 后续 PUT 全部 423」这种要人清理的状态 |
|
|
509
|
+
| `mod_dav_fs` 失败时会在目录里留 `|` 后缀的临时文件 | 模块实现 | **不靠列目录推断状态**,只按已知文件名读 |
|
|
510
|
+
| **`Allow` 不列 `MKCOL` / `PUT`**(实测),`DAV` 令牌是 `1,2` | 联网核对 | 不能用 `Allow` 判断可写性;能力判断靠 `DAV` 令牌 + 实际调用 |
|
|
511
|
+
| **目录不带尾斜杠 → 301**(实测,`Location` 补斜杠) | 联网核对 | 集合路径必须由构造保证带斜杠;客户端不跟随重定向 |
|
|
512
|
+
|
|
513
|
+
**必须显式不支持并报错的**:`qop=auth-int`、SHA-256、`algorithm=MD5-sess`、`Digest` 以外的认证类型。理由不是懒 —— 换镜像(Nextcloud、群晖)时失败要一眼能看懂,而不是静默降级成某个「看起来能用」的路径。
|
|
514
|
+
|
|
515
|
+
### 合并语义:副本槽位(`current` / `fallback`)与读时 resolve ✅ **已实现**
|
|
516
|
+
|
|
517
|
+
原本的 `writeSession` 是**覆盖**语义:某个会话这一轮没折出来的天,会被删掉。同步开启后这条必须改,因为「日志变短」正是你最需要那份缓存的时候。
|
|
518
|
+
|
|
519
|
+
**改动的理由(实测,不是推演)**:
|
|
520
|
+
|
|
521
|
+
```
|
|
522
|
+
185 个会话目录里,34 个同时躺着两代日志(session.jsonl.zstd / v2 / v3 / v4);
|
|
523
|
+
插件按代际只折最新一代(fold.js 的 logRank)。
|
|
524
|
+
|
|
525
|
+
同一份内容换编码后: 1,385,839 B → 493,510 B 而 req/day/in/out **逐项相同**
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
也就是说「文件变小」在正常使用下**不是丢了历史**,而是同一份内容换了更紧凑的编码;`repair.js` 的「truncation-repair」也不是截文件(崩溃后它**追加**合成的 `step/end`、`turn/end` 去配平,文件只会变长)。DSH 从不裁自己的日志。
|
|
529
|
+
|
|
530
|
+
真正会变短的是**事故**:从旧备份还原 `sessions`、拷贝被中断、手动编辑日志、同步盘冲突、清理工具。而这些恰好是「你最需要那份缓存」的时候。
|
|
531
|
+
|
|
532
|
+
**落地后的规则**(一个 `(session, date, source)` 有两个槽位,槽位是主键的一部分):
|
|
533
|
+
|
|
534
|
+
```
|
|
535
|
+
current 这一轮的日志版本折出来的那份(读的时候只认它)
|
|
536
|
+
fallback 被 current 取代、但留着备用的那份
|
|
537
|
+
|
|
538
|
+
写:新折叠 vs 该天的 current
|
|
539
|
+
「至少一样全」(seq 更大,或 seq 相同且文件不小) → 替换 current
|
|
540
|
+
「更短」 → 保留 current,新折叠写进 fallback
|
|
541
|
+
读:只让 current 参与 resolve(maxSeq 更大者胜;相同则 fileSize 更大;再相同则 fold 更晚者胜)
|
|
542
|
+
fallback 永不参与竞争 —— 它是「完整那份没了」时的退路,不是来争的
|
|
543
|
+
删:本设备自己的 current 行,且**这一轮日志版本不比它旧**时,才随「日志不再覆盖这一天」删掉
|
|
544
|
+
另一来源(device)的行、以及 fallback 行,fold 一律不碰
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
**为什么不能「无条件只追加」**:会话是持续增长的,一个活 100 天的会话每天都被重新折叠,而折叠的日期集合是整份覆盖的 —— 无条件追加会让同一 `(session, date)` 累积约 100 行,每行是一整天的桶加逐请求记录(实测 77 B/请求,1,000 请求/日就是每行 75 KB,重复 100 次约 7.7 MB/会话/年)。**「变小」只可能出现在折叠结果真的变小的时候**,正常增长永远变大,所以只在变小的时候留副本,正常路径零冗余。这条有专门的回归断言:连续折叠十次(日志只增长)后,该天必须仍是**一行**。
|
|
548
|
+
|
|
549
|
+
**为什么槽位要进主键**:`INSERT OR REPLACE` 撞主键时会把完整行替换掉,等于后备行根本存不下来(`UNIQUE constraint failed` + 整笔回滚,是第一版实现里真实踩到的)。两条同 `(session, date, source)` 的副本必须能共存,就得让「槽位」成为行身份的一部分,而不是靠读的时候去猜。
|
|
550
|
+
|
|
551
|
+
**为什么「更短的日志不再覆盖某天」要保留而不是删除**:这跟「更全的日志不再覆盖某天」是两件相反的事。日志更全、却没有那一天 → 那天确实从日志里消失了,删。日志更短、也没有那一天 → 和「从旧备份还原」无法区分,保留。两个方向都有测试钉着(section 8),因为这是这套语义唯一容易搞反的地方。
|
|
552
|
+
|
|
553
|
+
> **这条规则还顺手排掉了一个隐患**:老一代编码的 `seq` 是另一套编号空间(实测 `81,276`),如果它成了「更完整的旧行」,`maxSeq` 比较就会被它污染。但代际选取(`logRank`)根本不会去折老编码,那条行从来不会进库 —— 两个机制正好互补。
|
|
554
|
+
|
|
555
|
+
一处改动解决三件事:
|
|
556
|
+
|
|
557
|
+
- **截断不再丢数据**:本地那份变短后,完整的那份仍然在(本地另一行或远端),读时赢回来;
|
|
558
|
+
- **改名不再双计**:改名让 `session_id` 漂移(实测 `¥1.5 → ¥3.0`),resolve 把同一逻辑会话收敛成一份;
|
|
559
|
+
- **本地 fold 不再误删远端导入的行**(见「本地缓存」)。
|
|
560
|
+
|
|
561
|
+
> **「改名」有两个,别混**:上面这条说的是**会话目录改名**(`session_id` 变了,同一份用量换了个身份进来,靠 resolve 收敛)。
|
|
562
|
+
> 面板上那个「设备」字段是**另一回事**:它只是「这台机器把新数据写进哪个分区」,`session_id` 完全不受影响 —— 所以**改设备名不会让用量累增**。
|
|
563
|
+
> 实测(`sync-engine.verify.mjs` 的 rename 一节):一台机器 6666 → 改名 → 一台空库机器拉全量,看到的仍是 **6666**,不是 13332。
|
|
564
|
+
> 改名后的第一次同步会做一次重复劳动:它把旧名下的分片**重新拉回来**(旧名不再是「本机清单」,不再被跳过)、再以新名发一遍;旧目录留在服务器上(远端从不因为本机的改动而删东西),此后再同步就不动了。
|
|
565
|
+
> 代价是库里同一 `(session, date)` 多一行、远端多一份分片 —— **只是存储,不是数字**。
|
|
566
|
+
|
|
567
|
+
> **为什么用 `maxSeq` 而不是 mtime 判新旧**:mtime 跨机器不可比(时钟偏移、备份还原会改它),而 `seq` 是会话内单调递增的事件序号、与文件系统和时钟无关,且已经存在库里没被用起来(`sessions.seq`)。
|
|
568
|
+
>
|
|
569
|
+
> **一个已知的口径妥协**:老一代编码的 `seq` 是另一套编号空间(实测 `81,276` vs `690`),所以 `maxSeq` 在不同编码之间严格来说不可比。我选择接受它,因为(a)代际选取本身就是按代际号做的,不会拿老编码去折;(b)两者折叠出的用量相同的时候,选哪一个都无所谓。如果哪天发现它选错了,代价是面板数字不对,而**数据两份都还在**(本地被取代的行 + 远端分片),删掉缓存重新折即可 —— 这正是「变小才留副本」要买的东西。
|
|
570
|
+
|
|
571
|
+
### 本地缓存
|
|
572
|
+
|
|
573
|
+
```
|
|
574
|
+
storages/usage-badge/cache.sqlite # schema 1 → 2(见下)
|
|
575
|
+
storages/usage-badge/sync.json # 同步配置与状态
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
- **来源维度**:`days` / `sessions` 增加一列来源(本设备 / 某个远端设备)。本地 fold 只能写本设备的行;远端导入的行**只读**,永不被本地 fold 波及 —— 否则「B 下载了 A 的历史,B 本地没有那些日志」会让 B 的下一轮 fold 把它们删光。
|
|
579
|
+
- **导入远端行**时:若已存在同一 `(session, date)` 且那份的 `(maxSeq, fileSize)` 更大,则跳过(**旧机器不能覆盖新数据**);否则写入。
|
|
580
|
+
- **导入之后必须重载聚合器**:`refresh()` 的游标没变,不会自己发现库里的新行,否则同步来的历史要等到重启才可见。
|
|
581
|
+
- **「变短」要说出来**:折叠时若发现某会话的天数或总量变小,记一行日志、并在面板标一次「这段历史已脱离日志,缓存里仍保留」,让使用者能分辨「格式升级」和「真丢了东西」。
|
|
582
|
+
- 读路径不变:`load()` 按窗口返回已 resolve 的记录;`按年` 仍是直接查库([index.js:614-623](../lib/index.js#L614-L623))。
|
|
583
|
+
|
|
584
|
+
### 远端布局
|
|
585
|
+
|
|
586
|
+
```
|
|
587
|
+
<LOCATION 之下>/
|
|
588
|
+
usage-badge/
|
|
589
|
+
├── pricing.json # 共享:最新写入的那一份
|
|
590
|
+
├── pricing-<deviceId>.json # 每个设备自己那份(永不静默覆盖别人)
|
|
591
|
+
└── <deviceId>/
|
|
592
|
+
├── index.json # 本设备清单:分片 sha256 / size / 日期范围 / 折叠时间
|
|
593
|
+
└── <YYYY-MM>.ndjson.zst # 一个月一个分片,每行一个 (session, date, payload)
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
- **清单是每设备一份,不是共享一份**:于是根本不存在 manifest 写冲突,Apache 的临时文件与上传垃圾也污染不了它(只按已知文件名读)。
|
|
597
|
+
- **发现设备**:一次 `PROPFIND Depth:1` 列出 `usage-badge/` 下的子目录(这是 Apache 明确支持的用途)。不用共享的 devices 注册表 —— 那个又要处理并发写。**不靠列目录推断某分片是否存在**,那只用来发现设备。
|
|
598
|
+
- **上传**:`PUT` 到 `<name>.upload-<pid>-<rand>` → `MOVE` 到正式名(`Overwrite: F`),MOVE 是原子的。崩溃只留垃圾文件,不会有半截正式文件;垃圾文件再也不会被读。
|
|
599
|
+
- **分片按月**:按天会变成 365 文件/年 × N 设备、冷拉一年要 365 次 GET;按月则重传整月(重用户约 2.3 MB)。靠 `index.json` 里的 sha256 跳过没变的分片。
|
|
600
|
+
- **只拉最近 5 年**(可配):一个设备的 `index.json` 就是导航,冷拉只取落在窗口里的分片。
|
|
601
|
+
|
|
602
|
+
### 容量(实测)
|
|
603
|
+
|
|
604
|
+
用真实的 `serializeDay` 量过(随机 token 分布):
|
|
605
|
+
|
|
606
|
+
| 请求 | route | JSON | zstd | 压缩比 | 单请求 |
|
|
607
|
+
|---|---|---|---|---|---|
|
|
608
|
+
| 100 | 1 | 9 KB | 2 KB | 4.1x | 95 B |
|
|
609
|
+
| 1,000 | 1 | 75 KB | 17 KB | 4.5x | 77 B |
|
|
610
|
+
| 10,000 | 4 | 2.9 MB | 642 KB | 4.5x | 75 B |
|
|
611
|
+
|
|
612
|
+
逐请求记录是绝对大头(每条就是 `{input, cacheRead, cacheWrite, output, hour}` 五个数字)。年化(压缩后):轻用量(100 req/日)0.6 MB、中(1,000)6 MB、重(10,000)63 MB。所以重用户 5 年约 300 MB,**冷拉是新机器上最大的一次成本** —— 这就是「默认 5 年」的由来。
|
|
613
|
+
|
|
614
|
+
### 价格表同步
|
|
615
|
+
|
|
616
|
+
- 每台设备写 `pricing-<deviceId>.json`(带时间戳),共享的 `pricing.json` 是谁最后写就是谁。
|
|
617
|
+
- 每台机器把「我上次合并进来的那个 hash」记在 `sync.json` 里,于是三种情况可判定:只有远端变了 → 拉;只有本地变了 → 推;都变了 → **冲突**。
|
|
618
|
+
- 冲突时:保留远端那份为 `.conflict-<deviceId>-<时间>`(写在插件自己的目录里),后写者赢,并在日志与面板写明。**不做按行合并** —— 价格表的键不区分大小写、还有前缀行,按行合并既复杂又掩盖「两边都改过同一个键」这件事。
|
|
619
|
+
|
|
620
|
+
### 安全
|
|
621
|
+
|
|
622
|
+
- **存 HA1 而不是明文密码**:`sync.json` 里是 `ha1 = MD5(user:realm:pass)`,正是服务端 `user.passwd` 里的那串;面板里输入的明文**当场算完就丢**,永不落盘、永不进日志。也允许自己粘贴(`printf '%s' "user:REALM:pass" | md5sum`,与容器 entrypoint 的算法相同)。代价:`REALM` 一改就得重配 —— 面板会检出来并说明原因。
|
|
623
|
+
- **但要说清楚**:这意味着「密码文件的保护」等价于「密码的保护」。Digest + 明文 HTTP 下,被动窃听者可以离线爆破 MD5(Apache 自己的文档就这么写),而且数据在传输中也没有完整性保护。**建议给容器套 HTTPS**(镜像支持 `SSL_CERT=selfsigned`,或按官方推荐挂反代)。自签名证书用显式的 `allowInsecureTls` 开关放行,**不静默接受**。
|
|
624
|
+
- **同步是 opt-in**:`enabled: false` 时插件不做任何网络访问。
|
|
625
|
+
- 日志里对 HA1 只显示前 6 位 + `…`。
|
|
626
|
+
|
|
627
|
+
### 设备标识:手动选,不猜
|
|
628
|
+
|
|
629
|
+
`deviceId` 存 `sync.json`,面板给人看的标签默认取主机名、可改。**它从哪来由你选,不由插件猜。**
|
|
630
|
+
|
|
631
|
+
**刻意不用硬件 ID**:Windows `MachineGuid` 重装必变、`SMBIOS UUID` 在 DIY 机器上可能缺失且**磁盘镜像克隆出来的机器会重复**;Linux `/etc/machine-id` 重装必变,DMI `product_uuid` 常需 root 且虚拟机克隆同样重复。在这个用途上,**硬件 ID 会制造它本该防止的事故**(两台克隆机写进同一个设备目录)。而猜错的代价(重装后被当成新设备)本来也接近于零:设备目录只决定**存放位置和出处标签**,**不参与合并键**;远端旧分片永远保留,重装不会丢历史。
|
|
632
|
+
|
|
633
|
+
所以启动时只做短暂探测,给你机器名作为**默认值**;真正的身份在「同步」页签上由你选:
|
|
634
|
+
|
|
635
|
+
| 场景 | 面板上怎么说 | 效果 |
|
|
636
|
+
|---|---|---|
|
|
637
|
+
| 新机器 / 想分开记账 | **「新建一个设备」** → 生成随机 `deviceId`,标签填主机名 | 开一个新分区,远端设备列表里多一行 |
|
|
638
|
+
| 重装后,想接着同一台机器记 | **「并入已有设备」** → 下拉列出远端已有的设备(从 `PROPFIND` 得到) | 复用那个 `deviceId`,本机接着写同一分区;**这个下拉就是「从备份恢复身份」的入口,不需要另外的恢复流程** |
|
|
639
|
+
| 只是想先备份,还没想好 | **「本地只备份,`deviceId` 稍后定」** | 只拉不推,或只推价格表 |
|
|
640
|
+
| 同一台机器想开两个实例各记各的 | 两个实例各选**不同的**设备 | 互不干扰(见下:并发由 owner 锁兜住) |
|
|
641
|
+
|
|
642
|
+
**面板要把合并语义写在旁边**,否则你会以为「恢复哪台设备」等于「恢复哪份历史」:
|
|
643
|
+
|
|
644
|
+
> 远端所有设备的历史**都会合并**,统计的是它们的合计。选设备只决定**这台机器把新数据写进哪个分区**。日粒度仍只显示最近 430 天;更早的在「按年」里。
|
|
645
|
+
|
|
646
|
+
**备份/迁移数据目录时必须连 `sync.json` 一起带走** —— 那是最直接的「身份不变」手段;上面那个「并入已有设备」下拉是它的替代路径(`sync.json` 丢了也能靠它找回身份)。
|
|
647
|
+
|
|
648
|
+
### 同一数据目录的并发:owner 锁
|
|
649
|
+
|
|
650
|
+
**风险分布**(这点容易误判,所以写清楚):本地缓存那一半**基本不是问题** —— SQLite 的 WAL 加 `busy_timeout` 会让两个进程串行写,最坏只是「旧版进程把新 schema 的库认成更新版本、只读打开不写」,而降级路径已经实现且有测试钉着。真正**没有仲裁的是远端分区**:两个进程共用同一个 `deviceId`,都以为 `usage-badge/<deviceId>/<YYYY-MM>.ndjson.zst` 归自己写,各自导出一份 PUT 上去,**后者覆盖前者且双方都不察觉** —— 交错写入还会留下「某一天只有 A 的数据、另一天只有 B 的数据」的半截分片。
|
|
651
|
+
|
|
652
|
+
所以需要一个 owner 锁,它管的是**本机并发**(远端撞名由下一条管):
|
|
653
|
+
|
|
654
|
+
```
|
|
655
|
+
storages/usage-badge/host.lock # { pid, startedAt, instanceId, hostname, at }
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
- 启动时用 `open(..., 'wx')` 独占创建;拿到 = 我是所有者。
|
|
659
|
+
- 拿不到就读出 `pid` + `startedAt`,判断那个进程是否还活着(Windows 开进程句柄、Linux 看 `/proc/<pid>`),**并比对进程启动时间**,这样 PID 复用不会被误判成「有人占用」。
|
|
660
|
+
- 已死 → 接管;活着 → 本实例**只读使用缓存**(走现成的 `writable: false` 路径)+ **禁用同步** + 面板一行说明「这个数据目录正被另一个 DSH 实例使用(pid 1234,本机),本实例只读查看」。
|
|
661
|
+
- **不拒绝启动、不弹错**:让「开了两个客户端」看起来像插件坏了是最糟的结果;只读降级下第二实例仍然能看到全部历史。
|
|
662
|
+
|
|
663
|
+
**跨机器共用同一个 `deviceId`**(有人把整个 `storages/usage-badge/` 连 `sync.json` 一起拷到另一台机器)——本地锁管不到,用一行检测兜住:每个 `sync.json` 另有一个安装级 `instanceId`,写进 `usage-badge/<deviceId>/index.json`;同步时若该文件里的 `instanceId` 与本机不同,就报出来并把本机置为只读同步,提示「这个设备被两处使用,请在面板上重命名或改选一个」。
|
|
664
|
+
**只报警、不自动改** —— 自动换 ID 会凭空多出一个设备分区,让远端设备列表里冒出看不懂的条目。
|
|
665
|
+
|
|
666
|
+
**一个诚实的边界**:如果数据目录放在 **SMB/NFS 网络共享**上、两台机器同时挂载,SQLite 的锁在网络文件系统上本就不可靠(官方不建议在网络上用 WAL),**任何**插件侧的锁都不能保证正确。这种部署列为**不支持**,而不是假装能解决。
|
|
667
|
+
|
|
668
|
+
### 同步状态
|
|
669
|
+
|
|
670
|
+
`sync.json`:
|
|
671
|
+
|
|
672
|
+
```json
|
|
673
|
+
{
|
|
674
|
+
"enabled": false,
|
|
675
|
+
"url": "http://nas:8972/",
|
|
676
|
+
"username": "alice",
|
|
677
|
+
"realm": "WebDAV",
|
|
678
|
+
"ha1": "……",
|
|
679
|
+
"allowInsecureTls": false,
|
|
680
|
+
"deviceId": "……",
|
|
681
|
+
"deviceLabel": "desktop",
|
|
682
|
+
"instanceId": "……",
|
|
683
|
+
"windowYears": 5,
|
|
684
|
+
"state": { "lastSyncAt": 0, "lastResult": "ok", "message": "" },
|
|
685
|
+
"known": {
|
|
686
|
+
"pricing": { "sha256": "……", "baseline": "……" },
|
|
687
|
+
"shards": { "<deviceId>/2026-09": { "sha256": "……", "size": 0 } }
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
面板:弹窗新增第三个页签**「同步」**(配置 + 状态 + 结果 + 「立即同步」)。宿主新增 `GET /usage-badge/sync`(配置与状态)、`PUT|POST /usage-badge/sync`(写配置,仅 loopback)、`POST /usage-badge/sync/run`(触发,仅 loopback)。同步是异步的,浏览器在同步期间每 2 秒读一次状态。用量页签另加一行**「远端设备」**:设备标签、分片数、数据量、最后一次折叠时间。
|
|
693
|
+
|
|
694
|
+
### 失败矩阵
|
|
695
|
+
|
|
696
|
+
原则与缓存的四种失败一致:**不让远端影响本地**、**说出来**。
|
|
697
|
+
|
|
698
|
+
| 情况 | 行为 |
|
|
699
|
+
|---|---|
|
|
700
|
+
| 认证失败(401 且非 stale) | 记状态、面板提示「用户名 / 密码 / realm 不匹配」;不改任何文件 |
|
|
701
|
+
| `realm` 与配置不符(challenge 里带了别的 realm) | 明确报出「challenge 的 realm 是 X,配置里是 Y」,并说明 HA1 必须按 X 重算 |
|
|
702
|
+
| 服务器不支持 Digest / 不是 WebDAV(`DAV` 头缺失) | 报「这个服务端不支持 Digest / 不是 WebDAV」,不降级 |
|
|
703
|
+
| 网络不可达 / TLS 失败 / 超时 | 只写状态;连接 15s、上传 60s;GET 与 PROPFIND 可重试一次,PUT/MOVE 不自动重试 |
|
|
704
|
+
| 无写权限 / `MKCOL` 403 | 状态里说明;能读就读(只拉不推) |
|
|
705
|
+
| `PUT` 中断 | 只留下 `.upload-*` 垃圾,正式文件不受影响;被取代的行仍在本地 |
|
|
706
|
+
| `MOVE` 的 `Overwrite: F` 撞名 | 重新生成上传名重试一次,再不行报错 |
|
|
707
|
+
| 磁盘满 / 证书过期 / 同步盘在背后改文件 | 只写状态;本地缓存绝不受影响 |
|
|
708
|
+
| 数据目录被另一个 DSH 实例占用(owner 锁拿不到) | 本实例**只读使用缓存、禁用同步**,面板写明占用者的 pid 与主机名;不启动失败、不弹错 |
|
|
709
|
+
| `index.json` 里的 `instanceId` 与本机不同(同一个 `deviceId` 被两处使用) | 报出来并把本机置为只读同步,提示改选/重命名设备;**不自动改 ID** |
|
|
710
|
+
|
|
711
|
+
### 明确的非目标
|
|
712
|
+
|
|
713
|
+
- **不做常驻定时轮询**(只在你定的三个时机触发);
|
|
714
|
+
- **不做远端垃圾回收**:退役设备的分片永久保留(删远端就是删那份历史),要清理由你手动删那个设备目录;
|
|
715
|
+
- **不做冲突的自动按行合并**(价格表例外见上);
|
|
716
|
+
- **不做 `auth-int` / SHA-256 / `MD5-sess` / `Basic`**;
|
|
717
|
+
- **不把远端分片当运行数据**:它只是本地缓存的导出副本,合并永远发生在本地。
|
|
718
|
+
|
|
719
|
+
### 实现要点与风险
|
|
720
|
+
|
|
721
|
+
- **`SQLITE_SCHEMA_VERSION` 1 → 2** ✅ 已做(来源维度 + 槽位 + 行内 stamp)。`migrateSchema()` **按列判断**并搬运旧行,不是靠 `user_version` 猜;旧版本插件读新库会走已有的「只读使用、绝不改名」路径(`cache.verify.mjs` 已钉住),所以降级是安全的;**用户若要回退插件版本,请先备份 `cache.sqlite`**。
|
|
722
|
+
- **迁移必须在客户端关闭时进行**:实测运行中的 DSH 持有 `cache.sqlite` 的句柄,`rmSync`/替换会得到 `EPERM`。宿主半自己启动时迁移没有这个问题(它在进程内拿句柄),但**不要试图在客户端运行时从外面换掉这个文件**。
|
|
723
|
+
- **`HOST_API_VERSION` 6 → 7**:新增领域路由,且旧的宿主半不认识 `/sync`。按既有约定,改动宿主半对外行为就要加一。**(第五步做 `/sync` 路由时再加;第一到第四步都没动对外行为,所以现在仍是 6。)**
|
|
724
|
+
- **写入路径的防膨胀断言已加**:同一个会话连续折叠十次(每次日志只增长),该天的行数必须是 **1** 而不是 10 —— 这是「无条件追加」那个坑的回归测试,也是这个改动最容易悄悄退化的地方。
|
|
725
|
+
- **`cache.verify.mjs` 里那条旧断言改过了**:「rewriting a session replaces its day set」现在是「**更全的**重写替换日期集合」(旧写法里两次折叠的 stamp 相同,按新语义那是回滚而不是删除)。「删除日志后历史仍在」照旧成立。
|
|
726
|
+
- **改 `mergeRecords` 的担心解除了**:resolve 落在 store 的 `load()` 里,`mergeRecords` 一行没动,`按年` 视图也就不用知道副本的存在。
|
|
727
|
+
- **不动 430 天窗口**,所以同步来的更早历史只在 `按年` 里可见 —— 这是刻意的取舍,面板的 ⓘ 要写明。
|
|
728
|
+
|
|
729
|
+
### 第二步实测中发现的两件事
|
|
730
|
+
|
|
731
|
+
1. **`INSERT OR REPLACE` + 槽位缺失 = 后备行根本存不下来**。第一版把 `(session, date, source)` 当主键,于是「保留完整行、另写短行」这一步撞主键、整笔事务回滚、短的那份在日志里只留下一句 `UNIQUE constraint failed` —— 表面看是「行为对了」,实际是**两份都没动**。槽位进主键之后才真的共存。
|
|
732
|
+
2. **`kept` 按 `(date, source)` 分组的 JSON 后端会丢掉后备行**:同一键下 `current` 与 `fallback` 互相覆盖,分组键必须含槽位。这个问题在 SQLite 侧被 `INSERT OR REPLACE` 掩盖着,是**两个后端跑同一套断言**才逼出来的 —— 单看一个后端会以为没问题。
|
|
733
|
+
|
|
734
|
+
> **还没解决的一个已知缺口**(第二步的范围之外,别当成已修):`session_id` 是**日志所在目录名**,所以把会话目录改名会让同一份用量出现两个 id,**各算一份**(实测 `¥1.5 → ¥3.0`)。resolve 只能处理「同 id 的多个副本」,管不了 id 漂移。真要修需要一个稳定的会话身份(日志 header 里的 session id),那是另一处改动。
|
|
735
|
+
|
|
736
|
+
### 验证(计划)
|
|
737
|
+
|
|
738
|
+
| 脚本 | 覆盖什么 |
|
|
739
|
+
|---|---|
|
|
740
|
+
| `test/webdav.verify.mjs` ✅ **已实现(60 项)** | 纯函数:RFC 2617 向量(`Mufasa` 例子逐字段)、无 `qop` 的 RFC 2069 写法、`stale=true` 重试、UTF-8 与带逗号的 realm、`uri` 必须是请求行里的原始路径(含 `%XX`);以及对着假服务端的端到端:七个方法、`MOVE` 的 `Overwrite: F` 保护、404 语义、错误密码 / 一直挑战 / `SHA-256` / `Basic`-only 的四种失败各自报对原因 |
|
|
741
|
+
| `test/webdav-server.mjs` ✅ **已实现(harness)** | 一个**假 WebDAV 服务端**(`node:http`,真发 Digest 挑战并按 `MD5(HA1:nonce:nc:cnonce:qop:HA2)` 校验,检查 `nc` 不回退、`uri` 与请求行一致),可切换 `qop: null`、`staleOnce`、`challengeEveryRequest`、`algorithm`、`scheme` 等恶意行为 |
|
|
742
|
+
| `test/sync.verify.mjs` ✅ **已实现** | 分片与两个方向:路径/键的卫生(设备 id 与 `YYYY-MM` 不能带斜杠或穿越)、分片往返(stamp 与桶逐项保留)、**`source` 被改写成发布设备**、版本不符的分片与清单被具名拒绝、只发布本机 `current` 行(`fallback` 与别人的行都不发)、内容 sha256 决定「未变的月不重传」、首次拉取的窗口只限制拉取、`rowsToRecords()` 取最全 stamp、以及一次完整 trip(导出 → 空库导入 → 数字逐项相同、且**再导入一次不写任何东西**)。行模型的语义(截断/回滚/跨来源/从不删)由 `cache.verify.mjs` 的 section 8/9 覆盖,不在这里重测 |
|
|
743
|
+
| `test/sync-engine.verify.mjs` ✅ **已实现** | 端到端两设备:发布只带自己的月份、第二次运行不重传、另一台空库拉取后**数字与归属都对**、同一天两个设备相加、缺分片报 `partial` 且不记为已应用、补上后自动重试、owner 锁挡住第二个宿主、`sync.json` 里是 HA1 而非明文、以及**配置写入不会覆盖上一次的状态** |
|
|
744
|
+
| `test/sync.live.mjs` ✅ **已实现(联网)** | 真实的 `bytemark/webdav` 容器上跑完整流水线:A 发布一天 → B 空库拉取并计数 → 再同步一次零传输 → 清理自己建的两个设备目录(**不删 `usage-badge/` 本身**)。URL / 账号 / 密码从环境变量取,缺失就跳过 |
|
|
745
|
+
| `test/host-sync.verify.mjs` ✅ **已实现** | 宿主半的接线:挂载**不创建任何东西**、读状态不落文件、`POST /sync` 把明文算成 HA1 且**明文永不落盘**、响应只回前缀、patch 保存不丢别的字段、非法 URL / 非法设备 id 被拒、连不上的服务器**失败但不抛**、一次真实 run 走完整条路(发布 + 建目录 + 状态回填)、以及第二个实例拿到同一份配置但**被拒绝同步** |
|
|
746
|
+
| `test/sync-triggers.verify.mjs` ✅ **已实现** | 两条自动触发:没配过同步的 home **什么都不做**(不建 `sync.json`、不拿锁、不发请求);配好并开启的 home 启动后**自己同步一次**(服务器上出现设备目录、状态记下那次运行);改一次价格表会**自动发布**(带上那次编辑、带设备副本、且不拖任何分片工作);服务器连不上时本地编辑**照样成功**、本地表照样生效 |
|
|
747
|
+
| ⏳ **未做**:跨机同 `deviceId` 的 `instanceId` 报警 | 设计里写了、代码里还没接;本机并发的 owner 锁已经实现并测过 |
|
|
748
|
+
| `test/webdav.live.mjs` ✅ **已实现(联网)** | 对着真实的 `bytemark/webdav` 容器跑:`OPTIONS`(打 realm 与 `DAV` 能力)→ `MKCOL` → `PUT` → `GET` 逐字节比对 → `PROPFIND` → `MOVE`(含撞名 412)→ `DELETE`,并在结尾清理自己创建的目录。URL / 账号 / 密码从环境变量取,缺失就跳过并打印怎么起容器 |
|
|
749
|
+
|
|
364
750
|
## 已知限制
|
|
365
751
|
|
|
366
752
|
- **节假日与峰谷按本机时区判定**:官方窗口按北京时间定义,应用官方价格时已把窗口换算到本机。但**日历匹配用的是本地日期**,主机不在 UTC+8 时,跨午夜的请求可能落到错误的本地日期从而判错节假日。中国主机(UTC+8)下完全精确。如需任意时区精确,可加 `timeOfUse.zone` 选项(尚未实现)。
|
|
@@ -369,9 +755,11 @@ npm test
|
|
|
369
755
|
- **定价数字仅供参考**:token 列是**四桶合计**(输入 / 缓存读 / 缓存写 / 输出 —— 代码里没有第五个桶,reasoning token 没有被读取),与官方「三列」天然对不齐,与官方实时数字也存在分钟级时差;**对账请以金额为准**。
|
|
370
756
|
- **只统计 DSH 记进日志的调用**:统计口径是会话日志里的 `assistant/message` + `usage`;某个插件直接请求外部 API 且不向宿主上报 usage,这部分消耗统计不到。
|
|
371
757
|
- **首次启动要冷扫一遍全部日志**(实测 209 份日志约 10 秒,分片让出事件循环,不会卡住宿主)。之后走增量:实测热启动 237ms、折叠 0 份。缓存文件丢失才会再次冷扫。
|
|
372
|
-
-
|
|
758
|
+
- **历史依赖日志仍在盘上,但「变短」不再吃掉历史**:已删除的日志靠缓存里保留的贡献继续计入;缓存也丢了,那段历史就没了。日志**变短或还原成更早的备份**时不再删缓存里的天:折叠结果变小只会写进 `fallback` 槽位,`current`(读的时候认的那份)仍是更完整的那份。只有日志**更全**、却不再包含某一天时,那天才会被丢掉(section 8 有正反两个方向的断言)。**例外是改名**:会话身份是日志所在目录名,改名会让同一份用量各算一份。
|
|
373
759
|
- **缓存只把最近 430 天装进内存**(`CACHE_WINDOW_DAYS`,比快照要的 400 天宽):更早的行留在库里,`按年` 视图能直接查它们,但**日粒度**的区间(`近 N 日`)与热力图仍只覆盖最近一年。
|
|
374
760
|
- **没有 `node:sqlite` 的运行时会退回旧的 JSON 缓存**:功能一样,但保存代价与常驻内存随历史长度增长(就是换 SQLite 之前的实测数字)。面板的 ⓘ 会写明当前用的是哪一种。
|
|
761
|
+
- **云同步是手动触发的**:面板上的「立即同步」+「测试连接」是目前唯一的入口(价格表变更后的自动推送、启动后延迟一次都还没接)。数据仍然只在一台机器上直到你点它 —— 换机器、重装、或删掉缓存目录,都会丢掉「日志里已经没有」的那段历史(日志还在的部分能重新折出来)。设计见上面「云同步」一节。
|
|
762
|
+
- **折叠结果是日志所在目录 × 日期**:缓存按 `(会话目录, 日期)` 存,所以把某个会话目录**改名**会让同一份用量出现两个会话,**各算一份**(实测 `¥1.5 → ¥3.0`)。行模型只能处理「同一个会话的多个副本」,管不了会话 id 本身漂移 —— 要修需要从日志 header 取稳定的会话 id。删日志、截断日志、还原旧备份这三种情况现在都不会丢历史。
|
|
375
763
|
- **不做**:余额查询、订阅额度(Coding Plan)、峰谷切换弹窗/系统通知、中英双语、历史回填 CLI。这些是 [`dsh-cost-meter`](https://github.com/Han-1413141/dsh-cost-meter) 的领域;本插件刻意只做徽标、图表、官方定价同步、覆盖行维护与节假日判定。(**曾经**把「手工维护单价」也列在这里,理由是「单价唯一来源是官方页面」;实际上官方页面只点名它自己列的型号,网关上的其它模型总得有人定价 —— 与其让人去翻 JSON,不如把那张表做成可编辑的。)
|
|
376
764
|
|
|
377
765
|
## 与桌面壳的关系
|