真数据实测(扫描 40 个对象、只挂回 7 张)暴露了第一版的漏洞: 报错「数据集 #1 里没有编号 389 的地点」——来源能读出来,却不是上传那会儿的编号空间。 根因:srcPKCoords 是「第一个能读到的来源就返回」,而**换基线会覆盖 baselines/<id>.sqlite**, 当前那份未必是当年上传时用的编号;旧编号往往还躺在同一份底座的 .bak 留档里。 教训很直白:**「能读出数据」不等于「读对了那一版」**。 改了三处: 1. 来源全部读入合并(先读的优先):库里 places → entity_raw 里 ZLOCATION 的整行留档 (站点上删掉的地点,places 没了、留档可能还在)→ baselines/<id>.sqlite 及其每一份 .bak-*。 读不动的文件跳过换下一个,不再「第一份成功就返回」。 2. 同一个编号保留多组候选坐标,逐个试到能在当前数据集里对上为止 (站点纠过偏、或不同来源算出的坐标不一样,只认第一组会白白认不出)。 3. 认不出要能排查:理由写明「已查来源 …」与试过的坐标;提示条按**数据集归并**上报 (「数据集 #1:31 张(编号 389、390…),已查来源 …」),不再刷「另有 31 张」; 完整清单(前 30 条)进服务端日志。 新增用例:当前底座缺编号、换基线留档里才有(正是用户遇到的形状)⇒ 必须救得回; 以及「真的认不出」时要带出已查来源。全量验收 PASS=332 FAIL=0 SKIP=0。
280 lines
23 KiB
Markdown
280 lines
23 KiB
Markdown
# 2026-09-30
|
||
|
||
## 生产时间线「部分重复部分没有」的根因与修复
|
||
|
||
用户报 https://life.shiroko.one/timeline 上仍有重复。抓页面逐条比对:一屏 180 条里有 **53 对**
|
||
到达时间、离开时间、停留时长**完全相同**的条目——与 09-04 那种「离开时间不同」的已并入行不同,
|
||
`ZMERGEDTO`/`ZPARENT` 判据一条都收不到(43 对里隐藏侧 0)。
|
||
|
||
真正成因:**站点补录的行没有留档 ⇒ 没有 UUID**,导出时被补上 `v5("ZVISIT/<src_pk>")`;这份包
|
||
再合并回站点时站点侧没有 UUID 可对齐,被当成「备份新增」又插一条(41/43 的副本 UUID 正好等于
|
||
配对行的派生 UUID,实测确认)。
|
||
|
||
修:rond 里把派生 UUID 抽出来当**对账键**(`DerivedUUID`/`VisitMatchUUID`),差异与合并都用它;
|
||
卡片新增第二类判定(成因「你补录的那条被合并回来的一份」),清理时把副本名下的位移/天气**改挂**
|
||
到保留项。提交 `8dec973`(+ 格式 `aaf65f6`),已推送;全量验收随后跑。
|
||
|
||
## 「本地测试要十几分钟」的根因:我昨天那个收口把页面拖成了 44~70 秒
|
||
|
||
- 症状:最慢的 4 个用例占全套 55%(309s/274s/226s/115s),全是「渲染若干页面」的布局用例。
|
||
- 定位:临时逐页计时探针(`/` 43.6s、`/places` 70.1s)→ 变异对照(关掉判据 0.28s)→ CPU profile
|
||
(135 秒只用 3 秒 CPU ⇒ 在等,不是算)。孤立的 EXPLAIN 只有 12~38ms,**会骗人**。
|
||
- 根因:判据打在 `entity_raw.raw`(TOAST 过的整行 JSON)上,而 /places 是「每个地点 × 5 个子查询」。
|
||
- 修:派生列 `visits.is_merged` + 刷新(ingest / recountPlaces)+ 部分索引;/places 70084ms → 206ms,
|
||
首页 43633ms → 698ms。新增 `page_render_budget_test.go`(7 个页面卡 10 秒的灾难探测器)。
|
||
- 教训:**别拿单条查询的耗时判断整页性能**;「功能对、就是慢」的退化必须有守卫。
|
||
|
||
## 生产导出 `data/20260930.rondbackup` 被真机拒收 —— 逐项排查(未定位到形状违规)
|
||
|
||
按技能里「备份恢复失败」的排查顺序全跑了一遍,**每一层都与手机包/曾被接受的包同形**:
|
||
1. 打包层(头号真凶):本地头 bit3=0、长度非 0、条目名与顺序与 09-20 被接受的那份一致 ✓
|
||
2. 模型骨架:表 31 / 索引 47 / CREATE TABLE 逐字符 / `Z_PLIST` sha256 / `Z_MODELCACHE` 16073B / `Z_UUID` / `Z_VERSION` —— 与 09-29v2 手机包**完全一致** ✓
|
||
3. 逐列空值画像:唯一差异 `ZLOCATION.ZISOCOUNTRYCODE` 20 个 NULL。**这不是问题**——09-20 被接受过的包也是 23 个 NULL,
|
||
且 09-29 做过**真机对照实验**(同一份包唯一差异这列):`''` 被拒、`NULL` 能导入(见 commit `d5e96ed`)⇒ NULL 是对的,
|
||
**别再去「补空串美化」**(我这次差点又去改它)。
|
||
4. 关系完整性 0 悬空(含 `ZRAWVISIT→ZVISIT`、`ZVISIT.ZLOCATION/ZACTIVITY_`、自引用 `ZMERGEDTO/ZPARENT`)✓
|
||
5. `Z_OPT` 全 ≥1、`Z_ENT` 与参照一致、`ZIDENTIFIER_` 16B 且唯一、值类型分布无新增类别、无非法 UTF-8、
|
||
行级时间不变量 0 违例、SQLite 文件头(页大小/WAL 版本/编码)一致 ✓
|
||
6. 计数画像同形(1001 到访 / 64 隐藏 / 436 地点 / 392 有到访 / 交通方式 6 条无重名)✓
|
||
7. 包里**仍有 43 组站内副本重复对**(就是 dedup 卡片要清的),但不影响导入 ✓
|
||
|
||
**下一步(等用户)**:要报错原文 + 两个对照导入把范围切两半——
|
||
`storage/.verify/acceptance-clean.rondbackup`(纯净往返)与 `data/20260920-fixed-A-数据修复.rondbackup`(曾成功)。
|
||
若指向「站点数据」,就用这份失败包做行级二分(按 UUID 拆「手机原有的行」/「站内新建的行」)。
|
||
|
||
## 拒收排查续:静态判据全过 ⇒ 转真机二分(工具已提交)
|
||
|
||
用户确认:报错就是「备份恢复失败,你的当前数据未被修改」(QQ 传文件,同一路径传的另一份能导入)⇒
|
||
**导出链路与 App 都没问题,问题在这份数据集的数据里**。
|
||
|
||
静态排查全过(打包位标志 / Core Data 骨架逐字节 / 空值画像 / 关系完整性含自引用 / 类型分布 /
|
||
Z_OPT·UUID·BLOB / 行级时间不变量 / 位移窗口不变量 / 取值域与数值范围 / SQLite 文件头)。
|
||
顺手排掉四个「看着像」的:
|
||
- `ZISOCOUNTRYCODE` 20 个 NULL:09-20 被接受过的包也是 23 个 NULL,且 09-29 真机对照实验证明「空串被拒、NULL 能进」⇒ 不是它;
|
||
- 站内新建行的 v5 UUID:手机包自己就有 172 个 v5;
|
||
- 43 组精确重复到访:**手机包自己就有 39 组** ⇒ 真机容忍;
|
||
- 位移窗口越界:这份包 0 条(手机包自己倒有 2 条)。
|
||
|
||
新增 `tools/bisect_export.py`:以被拒包为底生成 V1(去掉站内新建的 22 条到访)、
|
||
V2(+3 个站内新建地点)、V3(手机里也有的行按手机原值重置),都已过审计(悬空 0 / Z_MAX 够 / 打包正常)。
|
||
判读:哪一版开始能进,问题就在上一版删/改掉的那一类里;三版都进不去 ⇒ 问题在「行的集合」(删过/多过的行)。
|
||
|
||
## 二分结果:V1 ✓ / V2 ✓ / V3 ✗ ⇒ 罪在「站内新建的那 22 条到访」(地点无辜)
|
||
|
||
- V1(去掉 22 条)能导入、V2(再多去 3 个地点)也能导入、V3(全保留、但把手机里也有的行按手机原值重置)报错
|
||
⇒ **问题在这 22 条到访本身或其附属记录**里,与地点、与「共享行被改过的值」无关。
|
||
- 这 22 条的构成:`Z_PK=[46,200]+998..1017`。其中 **46/200 其实是基线(09-14 包)自己的行**
|
||
(同一 UUID、同一数据),只是**手机在 09-29 前把它们删掉了**(09-29v2 里没有这两个 UUID)——
|
||
所以「按 UUID 是否在手机包里」分类会把它们误判成站内新建。
|
||
- 下一轮对照(都已过审计、位于 `data/bisect/`):
|
||
`V4` = 保留这 22 条但删掉它们的附属记录(15 位移 / 16 天气 / 2 采样);
|
||
`V5` = 删 `[46,200,998..1007]`;`V6` = 删 `[1008..1017]`。
|
||
- 判读:V4 ✓ ⇒ 罪在附属记录;V5/V6 哪个 ✓ ⇒ 罪在另一半的那 10~12 条里,继续对半拆。
|
||
|
||
## 二分第二轮:V4 ✗ / V5 ✓ / V6 ✗ ⇒ 罪在 [46,200,998~1007],再收紧到 [998~1007]
|
||
|
||
- V4(保留 22 条、只删它们的附属记录)**报错** ⇒ 附属记录(15 位移 / 16 天气 / 2 采样)无罪;
|
||
- V5(删 [46,200,998~1007])**能进**、V6(删 [1008~1017])报错 ⇒ 罪在 V5 删掉的那 12 条里;
|
||
- **46/200 被排除**:它们就是基线自己的行(同 UUID 同数据),而**纯净往返包里也有这两行且能导入** ✓
|
||
⇒ 罪在 **[998~1007] 这 10 条站内新建行本身**(其余一切都已被 V5 证明是兼容的)。
|
||
- 静态差分:这 10 条与无辜的 [1008~1017] **逐列完全同形**(同为 v5 UUID、同标志位、ZRAW 都有值、TZ 都是 NULL)
|
||
—— 看不出任何结构差异,只能继续对半。
|
||
- 第三轮:`V10` = 删 [998~1002]、`V11` = 删 [1003~1007](都在 `data/bisect/`)。
|
||
|
||
## 二分定案:罪魁是 `ZVISIT #1000`(09-22 06:16→10:48,挂在重复地点 416 上)
|
||
|
||
- 五个「只留一条」对照:**只有 P1000 报错**,P998/P999/P1001/P1002 都能导入 ⇒ 单条罪魁 = #1000。
|
||
- 顺带排除的:重复地点本身(手机包自己就有 8 组同名同坐标且都带访,坐标连低位都相同);
|
||
UUID(#1000 的 UUID 就是标准的 v5("ZVISIT/1000"),与兄弟们同形);ZRAW 量级(2305 与手机的 2209 同量级);
|
||
重复组的 ZLASTARRIVALDATE_ 规律(手机各组也是各成员各值)。
|
||
- #1000 与四个无辜兄弟的**唯一差异 = 它的地点是 416**,而 416 是「广西壮族自治区邮政公司」的
|
||
**重复地点行**(与 417 同名同坐标全字段相同,只有 ZLASTARRIVALDATE_ 不同),且**只有 #1000 用它**;
|
||
V1/V2/V10 能导入,正是因为那时 #1000 已删、416 成了没有到访的空壳。
|
||
- 判别包(都在 `data/bisect/`):
|
||
`W1` = 只留 #1000 但把它的地点换成 417;`W2` = 只留 #1000 但把 416 的「最近到访」改成与 417 一致;
|
||
`W3` = 只留 #1000 但把 ZRAW 改成 1421。
|
||
W1 能进 ⇒ 罪在「416 这一行 / 这个对」;W1 报错而 W2/W3 能进 ⇒ 罪在 416 的 ZLASTARRIVALDATE_ 或采样数。
|
||
|
||
## 定案:真机拒收的根因 = `ZVISIT.ZRAW` 超过包内采样行数(已修,commit `d99c63f`)
|
||
|
||
- 判别包结果:**只有 W3 能进**(W1 改地点、W2 改「最近到访」都报错)⇒ 罪魁是 `ZVISIT #1000` 的
|
||
**`ZRAW = 2305`**(「这次到访由多少个原始采样点归并而来」的计数)。
|
||
- 不变量:`ZRAW ≤ 包内 ZRAWVISIT 行数`。接受过的三份包(09-14 基线 / 09-20 A 包 / 验收纯净往返)
|
||
最大值都 ≤ 采样行数;被拒的那份只有 #1000 越界(2305 > 2246)。夹到 2246 以内即可导入
|
||
(W3 用 1421 验证过;被接受的包里 ZRAW 为 NULL/0 的行本来就有 39~139 条 ⇒ 往下夹安全)。
|
||
- 成因链:合并进来的到访带着**源包**的 ZRAW,而源包的 `ZRAWVISIT` 采样行站点不托管(只从基线带)
|
||
⇒ 计数与包内采样行数脱节。导出侧修法:写完行、清完悬空采样点之后,一条 UPDATE 把
|
||
`ZRAW` 夹到 `count(ZRAWVISIT)` 以内。用例 `TestExportClampsRawSampleCount`(变异验证过)。
|
||
- 顺手:`readDupBaseline` 先 os.Stat(sql.Open 默认 READWRITE|CREATE,路径不存在会静默建空库,
|
||
报「no such table」而不是「没有基线」)。
|
||
- 给用户的即时包:`data/20260930-fixed-zraw.rondbackup`(就是那份被拒的包,只把 #1000 的
|
||
ZRAW 2305→2246,其余一字未动,审计干净)。
|
||
- 更彻底的做法(记着):把数据集的**基线换成一份较新的手机包**——采样行来自基线,
|
||
基线越新,新到访的采样行越齐,`ZRAW` 也就天然自洽。
|
||
|
||
## 基线替换:用户按链路操作,差异页显示「完全一致」(= 预期结果,不是失败)
|
||
|
||
用户把 App 导出的 `20260930_180637.rondbackup` 直接导入成新数据集,再用旧的站点导出
|
||
`20260930.rondbackup` 做差异合并 → 差异页说「与当前数据集完全一致:没有新增、没有被删、也没有冲突」。
|
||
|
||
原因:喂给 App 的那份包**就是站点自己的导出**(`20260930-fixed-zraw.rondbackup` = 站点导出只夹了 ZRAW),
|
||
所以 App 再导出来内容当然一样 ⇒ **换基线这一步已经达成**(新数据集的基线 = 一份新鲜手机包 ✓),
|
||
清单里的「把旧站点导出合并进去」在他这条路径上是**多余的**(那一步是为「上传一份全新手机包、
|
||
里面没有站点数据」的路径准备的)。
|
||
|
||
顺带记:新数据集的收益主要是**编号/存储对齐**(App 导入时会重排 Z_PK,新数据集的 src_pk 与它的基线同一套);
|
||
采样行(ZRAWVISIT)**不会**因此变多——那些采样点从来不在我们的包里(真实缺失),
|
||
以后靠导出侧的 ZRAW 夹取兜住即可。
|
||
|
||
## 闭环完成:换基线成功并验证(2026-09-30 18:20)
|
||
|
||
用户流程:App 导出的 `20260930_180637.rondbackup` 直接导入成新数据集 → 网页端显示 **937 条到访**
|
||
(= 包内 1001 − 64 条隐藏行 ✓ 说明 App 那份把数据放在 `-wal`(3.16MB)里也被我们完整读入 ✓)→
|
||
以它为基线在网页端导出 `20260930_180637-export-20260930.rondbackup` → **导入 App 正常** ✓✓。
|
||
|
||
我这边独立验证(新导出 vs 新基线):跨表计数 **全部 ±0**(1001 到访 / 436 地点 / 815 位移 / 589 天气 /
|
||
2246 采样 / 6 方式 / 32 活动 / 8 标签);表 31/31、索引 47/47、`CREATE TABLE` 逐字符、
|
||
`Z_PLIST`/`Z_MODELCACHE` sha256 全部一致;`ZRAW` 最大 2246 ≤ 采样行 2246 ✓;打包层 bit3=0 ✓。
|
||
(App 那份的 zip flags 是 `0x0800`——只有 UTF-8 位,数据在 `-wal` 里,与我们 `0x0000` 的写法不同但都被接受。)
|
||
|
||
技术结论已写进 `rond-backup-parse` 技能:ZRAW 不变量、逐行二分手法(`tools/bisect_export.py`)、
|
||
换基线六步 + 自检、以及排查顺序里新增的「先验纯净往返包」与「值域不变量」两步。
|
||
|
||
## 项目记忆纳入版本控制 + 注入上限取证(2026-09-30 18:30)
|
||
|
||
用户要求「记忆跟着项目走」,把 `.workbuddy/memory/` 从 gitignore 里放出来。
|
||
|
||
- `.gitignore` 挡路的是 `.workbuddy/` 那行。改成「反忽略」六行:`.workbuddy/` → `!.workbuddy/`
|
||
→ `.workbuddy/*` → `!.workbuddy/memory/` → `.workbuddy/memory/*` → `!.workbuddy/memory/*.md`。
|
||
⚠️ 别用 `!.workbuddy/memory/**`——它把 memory 下的**任何**文件都放行。实测 `./web.test.exe` 混进
|
||
暂存过,所以收紧成只认 `.md`。复核用 `git check-ignore -q --no-index <path>` 逐条打靶:
|
||
`memory/*.png`、`memory/sub/x.md`、`memory/MEMORY.md.bak`、`plans/a.md` 全 IGNORED,
|
||
三个 `.md` 全 included,`git add -An .workbuddy/` 恰好 16 个文件。
|
||
- 记忆里**没有真凭据**(唯一命中是 `2026-09-14.md` 里的 `password=rond-admin`,那是项目默认口令)。
|
||
- **注入上限取证**(以前一直靠猜):`app.asar` 里 `getWorkingMemoryContent()` 用
|
||
`var MAX_MEMORY_CHARS = 1e4`,比较的是 JS `content.length`——**字符数(UTF-16 单元),不是字节**;
|
||
中文 1 字算 1。超了就 `substring(0,1e4)` 且尾部静默丢失,只在当轮挂一句「ACTION REQUIRED」。
|
||
⇒ 以前按「15.6KB 字节」判断是否超限是**错的**(中文文本 1 万字符≈1.5 万字节)。
|
||
本 details 与每日日志**都不参与注入**,只有 `MEMORY.md` 一个文件受约束。
|
||
- 索引现状:`MEMORY.md` 6604 字符 / 10526 字节,距上限留 34% 余量;仍有 12 节,34 节正文在 details。
|
||
|
||
本次知识已抽成**跨项目**技能:`~/.workbuddy/skills/workbuddy-memory-versioning/SKILL.md`
|
||
(反忽略六行写法 + 只放行 .md 的理由 + `git check-ignore --no-index` 打靶复核 +
|
||
`MAX_MEMORY_CHARS=1e4` 按字符判定 + 只有 MEMORY.md 参与注入 + CRLF 归一与
|
||
「`grep -c $'\r'` 在 Git Bash 里会全命中」这个假信号)。
|
||
|
||
## 「换基线」做进后台(2026-09-30 18:55)
|
||
|
||
上一轮用户手工走的换基线闭环,这轮做成了后台动作:`/admin/baseline`(后台首页加了入口)。
|
||
**站点数据一行不动,只换导出底稿**——这正是「底座必须是手机包」这条铁律的可操作形态。
|
||
|
||
三道闸,顺序不可挪(写在 `internal/web/baseline.go` 的文件头注释里):
|
||
1. **重合判据**:拿新包里的到访 UUID 与站点侧带 UUID 的到访比(站点补录行本来就没 UUID,不计入分母)。
|
||
`matched==0` ⇒ 硬拒,**force 也不给过**(干跑只验结构,一份不相干的包照样导得出,但换下去站点每行都
|
||
认不出身份 ⇒ 全按新行插到最大编号之后 ⇒ 真机上「这些到访全变成新的」)。`matched*2 < withUUID` ⇒
|
||
要显式确认;判据没过不当场报错了事——包已收好,页面给一个 `?pending=<sha>` 的「强制替换」按钮,不必重传 20MB。
|
||
2. **干跑导出**(`ExportRondbackup(..., tmp, io.Discard)`):底座缺表/缺实体编号在这步就挡住,
|
||
不会出现「换完了才发现导不出、原来那份已经不知去哪」。
|
||
3. **旧底座留档 + 记档**:`datasets.baseline_name/baseline_sha256/baseline_at`。
|
||
|
||
踩到两个坑,都写进了 details 卅三:
|
||
- ⚠️ **留档名不能只用秒级时间戳**:「替换后紧接着回滚」落在同一秒时,回滚那次 rename 会把刚生成的
|
||
留档覆盖掉,于是回滚「回了个寂寞」——`TestRollbackBaselineRestoresPrevious` 当场红给我看。改用
|
||
`baselineBackupName()`(`20060102-150405` + 冲突自增)。
|
||
- ⚠️ **`ensureBaseline` 必须先看记档**:只认 `datasets.sha256`(= 最初那份包)的话,底座一丢就静默
|
||
重建回旧包,换基线白做且毫无提示。共用实现 `ensureBaselineTo(ctx, dsID, dst)`;「重建」入口写临时
|
||
文件再 rename,不做破坏性删除。用例里带了**反证**(清掉记档后同一条路必须走不通),免得被别的兜底弄绿。
|
||
- 另一个小坑:算摘要的落盘要在 `uploads` **之外**,否则 `findUploadBySha` 会把自己认成「已存在」。
|
||
|
||
第一次跑定点用例时还红了一条 `换底座后导出包里的到访数 824 != 站点 823`——那是**我的断言写错了**:
|
||
导出条数与站点条数本来就不严格相等(导入会跳过内容重复的行),既有用例都只做「导出前后相对比较」。
|
||
改成更锋利的判据:换底座前后导出包里 `ZVISIT` 的 **UUID 集合逐条一致**——编号会换、身份不该换。
|
||
|
||
验收:`./scripts/verify.sh` 全量 **PASS=318 FAIL=0 SKIP=0**(10m15s)。
|
||
新增用例 7 条(`internal/web/baseline_swap_test.go`)。
|
||
|
||
### 补记:导出条数 ≠ 站点条数(19:00)
|
||
|
||
收尾时想到一个真实的认知陷阱:站长会以为「换一份更新的手机包当底座 ⇒ 导出的包里就有手机上的新记录」。
|
||
写文案时顺手去核这条,结果**先把自己写错的断言给纠正了**。
|
||
|
||
`tableWriter.finish()` 是**两段**:先删「基线里有、PG 已没有」的行(`known` − `seen`),
|
||
再把「导入时进不了 PG 但留档还在」的行(`w.skipped` × `raws`)按原样补回——否则「导入→导出」一趟
|
||
会凭空少数据。所以实测那份 823 条到访的数据集导出包里有 **824** 条,差值正常。
|
||
(我第一次写成功路径用例时断言「导出 == 站点」,红在 824 != 823,就是没意识到这一段。)
|
||
|
||
同一段代码也定下了换基线的语义:**新底座里站点没有的行会被清掉**(进 `known` 没进 `seen`)。
|
||
所以「换更新的手机包」不会把手机新记录偷偷带进导出包;要带得先走「分析差异」合并。
|
||
这条既写进了页面提示与 flash 文案,也用 `TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave`
|
||
钉住了(站内补一条到访 → 导出成包 → 从站内删掉 → 换底座 → 断言导出里既没有那条的派生 UUID、
|
||
UUID 集合也与换之前逐条一致)。
|
||
|
||
第二轮全量验收:**PASS=319 FAIL=0 SKIP=0**。技能 `rond-backup-parse` 补了「导出条数 ≠ 站点条数」
|
||
与「换基线已做成后台动作」两处。
|
||
|
||
## 图片丢失的根因与恢复(2026-09-30 19:40)
|
||
|
||
用户报「之前上传的照片现在找不到了,应该是因为清了应用数据换基线」。查下来**跟 App 侧无关**,
|
||
是站点自己的外键口径不一致:
|
||
|
||
- `place_images.dataset_id` 带 `ON DELETE CASCADE`,而**读取按 `place_key` 坐标键、跨数据集**。
|
||
- 于是「删掉旧数据集」把图片**行**级联删了,**文件却留在 `<data_dir>/media/<数据集>/…`**。
|
||
- 本机实测:`datasets` 只剩 155 号,`place_images` **0 行**,盘上 4 个 `.webp` 好端端躺着。
|
||
同一批附属数据里备注(17)与结论(3+1)因为早就迁到坐标键,**毫发无损**——只有图片倒霉。
|
||
|
||
做的三件事:
|
||
1. **恢复**:`internal/web/image_recovery.go` + 后台首页「图片挂载恢复 → 扫描并恢复」(`POST /admin/images/recover`)。
|
||
只插行、不动文件、可反复点(按 `object_key` 判重,`apply=false` 是预演)。认地点两处来源:
|
||
① 数据集还在就查它的 `places`;② 数据集已删就读它留下的 `baselines/<id>.sqlite` 还原「地点号 → 坐标」。
|
||
对象键两种历史布局都认(`<ds>/<YYYYMM>/<srcPK>-<hash>.webp` 与 `<ds>/<srcPK>/<hash>.webp`)。
|
||
2. **防复发**:schema 里 `CREATE TABLE place_images` 去掉外键 + 幂等 `DROP CONSTRAINT`;
|
||
删图的接口不再按 `dataset_id` 过滤;后台「删除数据集」的确认文案改成实话(专题也会删、附属数据不受影响)。
|
||
3. **同类陷阱报告**:`topics` 仍带 CASCADE —— 专题随数据集一起删且**没有恢复来源**(本机 0 行,
|
||
之前那次换数据集就没了)。专题内容本来是「时间+区域」派生的,可以做成跨数据集的,但列表/详情都按
|
||
`dataset_id` 过滤,要改就一起改 —— **等用户拍板**,没擅自动。
|
||
|
||
本机预期可恢复 4 张:`上班` ×1(旧地点号 286)、`海之尚酒店` ×3(旧地点号 309),靠 `baselines/9.sqlite` 认。
|
||
|
||
踩到的测试坑(都写进 details 卅四):图片「按坐标键跨数据集读」+「按 object_key 判重」都**不看数据集**,
|
||
用例之间会互相污染两处——盘上文件(「应当 2 张、实际 10 张」)与库里的行
|
||
(`Already:1` 让兜底分支根本没跑到,用例差点假绿)。加了 `isolateImageState`(换空 DataDir + 按 place_key 清行),
|
||
并给夹具补了收尾清理(外键没了,必须自己清)。
|
||
|
||
新增用例 4 条(`image_recovery_test.go`)。
|
||
|
||
### 补记(23:20):对象存储上的图 + 专题一并改
|
||
|
||
用户:「一并做,而且有部分资源在 R2 上,怎么办?」
|
||
|
||
**R2 侧**:行没了就只剩桶里有那些键,不列桶无从得知它们叫什么 ⇒ 给 `internal/media` 加了
|
||
`ListObjects`(ListObjectsV2 + SigV4 + 翻页 + XML)。两个坑:查询串必须**先编码再签**
|
||
(`canonicalQuery` 按原样取 RawQuery,prefix 里的 `/` 得写成 `%2F`,否则签名与发送不是同一份字节);
|
||
R2 的 Token 默认没有 `s3:ListBucket` ⇒ 403。列不动时**不让整个恢复失败**:本地那半照救,
|
||
原因进 `Warnings`(页面红色提示条)。另外「挂回了却没配 `s3_public_base`」会让行有了、页面一片空白,
|
||
比「没恢复」更难懂,也写进 warning。
|
||
|
||
**专题**:按用户拍板做成归人不归数据集。外键摘掉,所有查询 `dataset_id` → `user_id`
|
||
(`loadTopics`/`topicPage`/保存/删除/开关/`HasTopics`);**内容仍照生效数据集算**(`topicContent` 不动)。
|
||
`Dataset` 补 `UserID`(`datasetFor` 多查一列)供前台按属主定位用户级数据。
|
||
用例除「删数据集后专题还在」外,还直接查 `pg_constraint` 确认外键确实不在——只看行为可能被别的原因弄绿。
|
||
|
||
**导出链路没碰**:`place_images` / `topics` / `Dataset.UserID` 都不参与 `.rondbackup` 导出。
|
||
|
||
### 再补(23:50):认不出 33 张 —— 「能读出数据」≠「读对了那一版」
|
||
|
||
用户跑到真数据上:扫描 40 个对象(对象存储 19),只挂回 7 张,**认不出 33 张**,
|
||
报错「数据集 #1 里没有编号 389 的地点」。
|
||
|
||
根因:我的 `srcPKCoords` 是「第一个能读到的来源就返回」,而**换基线会覆盖 `baselines/<id>.sqlite`** ⇒
|
||
当前那份未必是上传那会儿的编号空间,旧编号很可能还在同一份底座的 `.bak` 留档里。
|
||
|
||
改了三处:
|
||
1. 来源**全部读入合并**:库里 places → `entity_raw` 里 ZLOCATION 的整行留档 → `baselines/<id>.sqlite`
|
||
及**每一份 `.bak-*`**(读不动的跳过,不再第一份成功就返回)。
|
||
2. 同一编号**保留多组候选坐标**,逐个试到能在当前数据集里对上(站点纠过偏/来源不一致的情况)。
|
||
3. 认不出要能排查:理由写明「已查来源 …」与试过的坐标;提示条**按数据集归并**上报,
|
||
不再刷「另有 31 张」;完整清单(前 30 条)进服务端日志。
|
||
|
||
可复用的教训:从「可能被覆盖过的留档」里还原编号/坐标时,必须把历史版本一起纳入,
|
||
并在失败信息里列出已查过的来源——否则用户只看到「认不出」,无从下手。
|