Files
rond-with-you/.workbuddy/memory/2026-09-29.md
T
tamakyi 3f1b737a78 记忆:把 .workbuddy/memory 纳入版本控制
用户要求这份记忆跟着仓库走,而不是只留在本机。

.gitignore 里挡路的是 `.workbuddy/`。改成反忽略六行,并且**只放行 .md**:
别用 `!.workbuddy/memory/**`——它会把 memory 下任何文件都放行,本项目真发生过
`web.test.exe` 混进暂存。复核用 `git check-ignore -q --no-index` 逐条打靶:
memory/*.png、memory/sub/x.md、memory/MEMORY.md.bak、plans/a.md 全 IGNORED,
17 个 .md 全 included;`git add -An .workbuddy/` 恰好 16 个文件。
其余 .workbuddy 内容(计划、本地设置、临时产物)继续忽略。记忆里没有真凭据
(唯一命中是 2026-09-14.md 里的 `password=rond-admin`,那是项目默认口令)。

顺带做两件整理:

1. 取证了注入上限。以前按「字节数」判断 MEMORY.md 是否超限是错的:
   WorkBuddy 的 `getWorkingMemoryContent()` 用 `MAX_MEMORY_CHARS = 1e4` 比 JS
   `content.length`,即**字符数**(中文 1 字算 1);超了 substring 截断、尾部静默丢失。
   本 details 与每日日志都不参与注入,只有 MEMORY.md 受约束。
   现已从 6604 字符收到上限的 66%,并把这条上限写进索引抬头。
2. 把「备份合并」「导出/恢复三条铁律」两段长文迁进 MEMORY-details.md 的卅一/卅二节,
   索引只留关键词指针。行尾统一为 LF(仓库 .gitattributes 是 `* text=auto eol=lf`)。
2026-09-30 18:31:08 +08:00

485 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-09-29
## 站长手动合并后追问:3 条「地点 X → X」的冲突为什么
用户按默认(全「用实机」)把我的库合并了,差异页报「两边都有但不一样(3)」,三行都是
`地点 中国体育彩票 → 中国体育彩票` 这种**两边显示同名**的形态。复现与定性(用真实代码路径 +
独立探针库 `rond_merge_probe` 恢复快照,不动用户库):
- 合并前:到访冲突 6(2 地点 + 4 活动)→ 与 9-28 的测量一致。
- 合并后(用户已合):**到访冲突 3、地点冲突 2、活动冲突 0**,逐条与用户贴的 3 行**完全一致**。
- 结论:**「地点」这一项比的是坐标(`rond.PlaceCoordKey`,经纬度 6 位小数),不是名称**;
而差异页只把**名称**印出来 ⇒ 名称相同、坐标不同的组合渲染成 "X → X",看着像没变化。
三处的真实坐标差:
| 到访 | 站内地点 | 实机地点 | 直线距离 |
| --- | --- | --- | --- |
| 2025-09-20 22:39 中国体育彩票 | 广东佛山 (22.927572,113.222052) | 广西贺州 (24.408376,111.554626) | **236.8 km** |
| 2025-12-19 09:50 长沙南站 | (28.146535,113.062164) | (28.141772,113.067569) | **750 m** |
| 2025-12-21 14:01 茶颜悦色 | (28.181954,112.986995) | (28.242581,113.037061) | **8.3 km** |
- 成因不是同一个:237km / 8.3km 是**连锁店重名**(实机里「中国体育彩票」3 条、「长沙南站」2 条、
「茶颜悦色」2 条),`DiffPlaces` 坐标配不上就**按名称兜底配**,把不同的店配成了一对;
750m 那条是坐标系/纠偏那类(站内被多做了一次 GCJ→WGS 反向换算)。
**顺带发现:这次默认「用实机」已经动了库里的坐标**(对照 probe 库 vs 现库):
- `中国体育彩票` src_pk=71:贺州 (24.408376,111.554626) → **佛山 (22.927572,113.222052)**,搬了 236.8km(错配导致的误覆盖)
- `茶颜悦色` src_pk=348:移动 665m;`长沙南站` src_pk=344:移动 750m(这两条属于坐标系修正,是对的)
**已修(用户批准「修两处 + 回滚重跑」后落地,`internal/rond/diff.go` 单文件 + 用例)**:
1. `takeByName` 加距离上限 `nameMatchMaxKm = 1.0`(`DiffPlaces` 的按名称兜底):两边都有坐标却超 1km
不再配对,改报「备份新增 + 仅站内有」;任一侧无坐标时保持旧行为。
⚠️ **量纲坑**:`geo.Haversine` 返回**公里**不是米(列名 `distance_km` 可佐证),我第一次写成
`nameMatchMaxM = 1000.0` 导致用例直接抓到 `TestDiffPlacesNameFallbackNeedsNearby` 变红。
2. `visitFieldChanges` 的「地点」在**名称相同时**用 `coordSuffix` 把坐标印出来(`name (lat, lon)`,
无坐标则 `(无坐标)`),否则那一行看着像没变化、站长无从选择。
3. 用例 3 条:`TestDiffPlacesNameFallbackNeedsNearby`、`TestDiffPlacesNameFallbackKeepsNearbyCoordFix`、
`TestDiffVisitsPlaceConflictShowsCoords`;变异验证全过(去掉距离判断 / 去掉坐标显示 → 对应用例变红)。
**复测(回滚 → 按浏览器默认全选「用实机」重跑,49 组冲突)**:地点新增 67→68、仅站内有 3→4;
后台回执「新增 68 地点 / 1 活动 / 134 到访;更新 27 地点 / 16 活动 / 6 到访」;
**「被搬动超过 1km 的地点 = 0 个」**(修复前是 1 个 236.8km);`中国体育彩票` src_pk=71 仍是贺州 ✓;
合并后再比对:到访 0/47/1、地点 0/4/1、活动 0/0/0;953 条 UUID 到访全命中、到达时间 0 偏差。
残余 1 对冲突现在渲染成 `地点 茶颜悦色 (28.181954, 112.986995) → 茶颜悦色 (28.242581, 113.037061)`。
**仍未处理**:`mergePlaces` 的 `placeID` 映射仍是单槽位 byCoord/byName(多条同名备份地点会并到同一个
站内地点;这次被差异层拦住了,但换数据顺序仍可能让到访挂错分店)。
**方法备忘**:临时探针测试 `ROND_PROBE=1` + `ROND_PROBE_DSN`(可指向从 dump 恢复的独立库)
+ `ROND_PROBE_STASH`(备份暂存路径),走 `srv.loadStored*/parseBackupFile` 真实路径打差异,
跑完即删;`psql -c` 里带中文会因 Windows 代码页报 `无效的 "UTF8" 编码字节顺序`,**用 heredoc**。
「按浏览器默认合并」= 从差异页正则抓 `name="(vc_|pc_|ac_)..." value="backup"` 全部提交;
**这一点很关键**:早先的手写测试只提交 `add_new=1`,走的是「冲突默认保留站内」那条路,与站长点一下
「合并」的真实行为(全部「用实机」)完全不同。
## 第二轮(用户要求):修 mergePlaces 的单槽位映射
**病因**:差异层虽然已经不错配了,但 `mergePlaces` 自己又用**单槽位** `byCoord`/`byName` 配了一遍,
多条同名备份地点全映射到同一行站内地点 → 到访挂到错的分店。
**修法(结构性,不是再抄一遍逻辑)**:抽出 `rond.MatchPlaces(stored, backup) PlaceMatch`
(坐标优先 + ≤1km 同名兜底 + 多值索引 take),**差异判定与合并执行共用这一份**;
`mergePlaces` 直接用它的结果,再叠加 `plan.AddPlaces` 的新插入。
`loadStoredPlaces` 与 `mergePlaces` 的地点查询都加 `ORDER BY id`(候选多于一个时靠顺序定谁配谁,
两侧必须一致)。顺手删了两个只写不读的死字段 `PlacePatch.CoordKey`、`ActivityPatch.Key`。
**新用例**:`TestMatchPlacesKeepsDuplicateNameGroupsDistinct`(同一坐标 / 邻近同名都要一条配一条)、
`TestMergePlacesMapsSameNameLocationsOneToOne`(真库+真事务,两条同名备份地点必须映射到两条不同地点);
变异验证:认领不标记已用 → rond 用例红;恢复单槽位映射 → web 用例红(41→1195893、42→1195893)。
**复测(探针库,不动本地真库)**:差异页 到访 134/47/6、地点 68/4/27、活动 1/0/16;49 组「用实机」;
回执「新增 68 地点 / 1 活动 / 134 到访;更新 27 地点 / 16 活动 / 6 到访」→ 1001 / 436 / 32 / 815 / 589。
两条不变量:**地点被搬动 >1km = 0**;**到访挂错地点(>1km)= 0 / 953 条**。
合并后再比对三类全 0(到访 0/47/0、地点 0/4/0、活动 0/0/0),再点合并「没有要写入的改动」。
⇒ **一次按默认点合并即可让差异全部归零**(上一轮残留的那 1 对「茶颜悦色」冲突,根因就是到访挂在
8.3km 外另一家分店)。
## 第三轮:生产导出被 rond 拒收「备份恢复失败,你的当前数据未被更改」
用户在生产导出 `data/20260914_151649-export-...-export-20260929.rondbackup`(2.93MB)后导不回 rond。
**取证顺序(可复用)**:
1. `tools/verify_export_structure.py rondbackup <参照> <导出>` —— 表/索引/`CREATE TABLE`/列定义/zip 条目/
关键不变量全部一致;`Z_METADATA.Z_PLIST`、`Z_MODELCACHE` 也**逐字节相同**(我自己另验的);
关系完整性 0 悬空(对比参照反而有 2 条:那是旧代码的缺陷,本次已修)。
2. 写 `tools/audit_export_nulls.py <参照> <导出>` 做**逐列空值画像 + 全关系列悬空 + Z_MAX**:
参照里 0 个 NULL、导出里却有的列 = 形状违规。当场只抓到两条:
- `ZACTIVITY.ZCOLOR_`:参照 0 → 导出 **1**(33 行里)
- `ZLOCATION.ZISOCOUNTRYCODE`:参照 0 → 导出 23 —— **但它不是违规**(真机自己的 09-14 包里就有 1 个 NULL,
说明该列容忍 NULL;跳过)
**根因链**:站内「地点补全」建活动时写 `color=''`、`icon=''`(`internal/web/fillup.go` 的 INSERT)
→ 导出侧 `ns()` 把**空串也当空** → 图标有 `📍` 兜底、**颜色没有** → 写出 `ZACTIVITY.ZCOLOR_ = NULL`
→ 真机这一列在任何包里都是 0 NULL/0 空串(扫了 data/ + storage/ 全部 15 个包,只有出事的那份有 1 个空)
→ Core Data 判成损坏数据 → 整包拒收。
**修复(`internal/rond/export.go`)**:加 `activityPlaceholderColor = "gray"`(与 `transportPlaceholderColor`
同一套写法),导出活动时 `color` 为空则兜底;并把 `TestExportNeverNullsColumnsTheDeviceAlwaysFills`
(通用守卫:基线里 0 NULL 的列,导出新行也不许 NULL)补上「没选颜色的活动」场景。
变异验证:去掉兜底 → 两条断言都红(提示语正好复刻生产现象)。全量验收 293 PASS / 0 SKIP。
**顺手交付**:`data/...-export-20260929-fixed-activity-color.rondbackup` —— 只把 `ZACTIVITY.Z_PK=33` 那格
改成 `'gray'` 的停gap包(`Z_PLIST`/`Z_MODELCACHE` 逐字节未变、zip 仍 Store、`integrity_check` ok),
用户可不等重新部署直接导入。
⚠️ 遗留(非违规、未改):`ZLOCATION.ZISOCOUNTRYCODE` 有 23 个 NULL(真机同类位置写的是 `''`);
另:包里比实机多一个站内自建活动「一般公共场所」,导入后可能看着多余。
## 第四轮:导出被拒的第二个坑(也是主因)——交通方式编号空间串了
用户把最新源码(含颜色兜底)部署到生产、重新导出 `data/20260929.rondbackup`,**依旧导不回 rond**。
**关键取证方法**:拿**最后一次被真机接受的包** `data/20260920-fixed-A-数据修复.rondbackup`(09-20 记录里
写着「✅ 结案:A 包导入成功」)当参照,与新导出逐项对比:
- SQLite 头(page_size/encoding/user_version/application_id/auto_vacuum)✓、31 表 / 47 索引 / CREATE 语句 /
列定义 ✓、`Z_METADATA.Z_PLIST` 与 `Z_MODELCACHE` **逐字节相同** ✓
- 值类型分布(typeof 集合)✓、文本无 NUL/非法 UTF-8 ✓、BLOB 长度集合 ✓、UUID 全 16 字节且无重复 ✓
- 关系完整性 ✓、`Z_OPT` 全 1 ✓、行级时间不变量 ✓
- **唯一差异:新导出的 `ZTRANSPORT` 里有两条同名「公交」(Z_PK 2 与 4),而真机与 A 包都只有一条**
**根因**:`mergeVisits` 把**实机的 ZTRANSPORT 编号原样写进** `movements.transport_src`。站点没有交通方式表,
方式只活在位移的 transport_* 四列里,而两套编号空间的同一个数字含义不同(实测 src=4:站点=电🐔、实机=公交)。
于是同一个 src 带着两种含义 → 导出按 src 聚合时只能取一个名字 → 另一种含义的位移挂错方式,更糟的是吐出两条
同名方式(真机整包拒收)。本地库实测:src 3/4/5/6 都撞了(3=高铁|地铁、4=电🐔|公交、5=地铁|高铁、6=步行|电🐔)。
**修复(两处)**:
1. 合并侧 `mergeVisits`:按**名称**把实机方式归一到站点编号(已有同名就沿用它的号,没有才 `nextSrcPK(
transportSeq)` 新发一个)——新增 `(*Server).transportsByName`。
2. 导出侧 `queryTransports`:改为**按名称归组**(名称就是身份),用量最多的名称优先认领编号,其余编号的位移
改挂过去;并把「基线里同名但编号已被归并掉」的旧行删掉(否则 `keepMissing` 把它原样带回、又是两条同名)。
⚠️ 这段清理必须放在**所有托管表写完之后**(我最初放在交通方式段里,兜底判据看到的是基线里还没重写的
ZMOVEMENT,误以为还有位移指着旧行就跳过了删除)。
**效果(本地真实数据,同一形状)**:方式恢复成站点原本那套 1火车/2公交/3高铁/4电🐔/5地铁/6步行,无重名;
位移各挂回自己的方式(电🐔 336、地铁 25、高铁 23、公交 19、火车 9、步行 3——修复前只有 6 条能对上)。
**用例**:`TestExportMergesDuplicateTransportNames`(同名归并 + 位移挂回自己那条 + 无悬空)、
`TestMergeKeepsTransportNumberingConsistent`(合并后同一 transport_src 不许有两种名称;⚠️ 这条第一次写成了
**空用例**——夹具里没有新增到访就不会插入位移,必须先按站点方式删一条带方式的位移的端点到访,并加哨兵断言
「位移确实被补回来了」)。变异验证:去掉改挂 → 导出用例红;去掉旧行清理 → 红;合并侧照抄实机编号 → 合并用例红。
⚠️ 教训:**「导出包」里每条位移的方式名已经丢了**(导出时被压成 src 的一个名字),所以「把生产包重新导入本地
再导出」并不能修复它——只有**在生产库上用修好的导出器重新导出**才行(生产库的 `movements.transport_name`
还留着每条位移的真实名称)。给用户的停gap 文件属于「能否导入」的探针,其方式标注是近似值。
## 第五轮:真凶是 zip 的 data descriptor(第九次才找到)
用最新源码在生产导出 `data/20260929v2.rondbackup`,**依旧被拒**。此时 v2 已通过所有机械判据
(空值画像 0 项、悬空 0、Z_MAX、全列重复扫描、与可导入 A 包的完整深度对比**到处一致**)。
**突破口:zip 层的 flag bit 3(data descriptor)。**
| 包 | 本地头 flags | csize/usize | 结果 |
| --- | --- | --- | --- |
| 所有 **Go 写的站点导出**(09-16 起每一份) | **0x0008** | **0/0** | 全部被拒 |
| `20260920-fixed-A-数据修复`(**Python 重打包**) | **0x0000** | 有值 | **真机接受** |
| 真机自己的包 | 0x0800(UTF-8 标志) | 有值 | 正常 |
Go 的 `zip.Writer` 默认走流式写入:本地头里长度/CRC 留 0、把真值挪到条目尾部的描述符里并置上 bit3。
**真机的读取器只认「本地头里就写着长度」的条目**,遇到直接判成损坏 → 「备份恢复失败,你的当前数据未被更改」。
⇒ 09-20 那次「修悬空引用 + 补交通方式颜色」后能导入,其实是因为那份包被 **Python 重新打包**过,
真正的病根(zip 写法)一直没被发现;这解释了为什么 09-29 一整天「修内容」全部无效。
**修复**:`zipBackup` 用 `zw.CreateRaw(&zip.FileHeader{... CRC32/CompressedSize64/UncompressedSize64})`
(先把三文件读进内存,3MB 无所谓),不再用 `CreateHeader`。
新增用例 `TestZipBackupWritesNoDataDescriptor`(直接读**第 0 个条目的本地头**,断言 bit3 为 0 且
长度/CRC 非 0;变异:改回 `CreateHeader` → 立刻变红 ✔)。真实数据验证:新导出的本地头 flags=0x0000 ✓。
**给用户的即时探针**:`data/20260929v2-reframed-zip.rondbackup`——把 v2 的三个条目原样解出、用 Python
重新打包(内容逐字节一致,只修 framing)⇒ 若它能导入,即坐实根因。
## 第六轮:真机对照实验——`ZISOCOUNTRYCODE` 写空串会让整包被拒(我今天引入的回归,已撤销)
用户拿四份包在真机上做对照:
| 包 | 与 v2 的唯一差异 | 结果 |
| --- | --- | --- |
| `probe1-country-null` | 国家码 `''` → **NULL** | **能导入** ✓(但重启后软件里没数据,见下) |
| `probe2-category-a` | 分类列回退到 A 的取值 | 导入失败 |
| `probe3-country-and-category` | 上面两处都回退 | 导入失败 |
| `20260920-fixed-A`(09-20 被接受过) | — | 用户未回报 |
⇒ **`ZLOCATION.ZISOCOUNTRYCODE = ''` 是拦路虎**:真机包里确实有 19 行是 `''`(它自己写得出),
但**导入侧不吃**——同一份内容、同一台设备,只把这一列从 NULL 改成 `''` 就从「能导入」变成
「备份恢复失败」。所以「按真机形状补空串」是有害的回归(`d5e96ed` 已撤销,改成原样写 NULL)。
⚠️ 教训:**真机写得出 ≠ 真机吃得回**;形状美化要拿真机实测,别靠统计推断。
**仍未解决(新问题)**:probe1 能导入,但**重启软件后没有任何数据**。两种可能,需要区分:
(a) 老数据还在、只是导入的东西没出现在界面(例如被当成另一个「库/数据集」导入);
(b) 整个 app 变空(store 被替换但 Core Data 打不开 → 回落到空库)。
待问用户:rond 是否有「库/数据集」切换?上次成功是哪一步?
另一个待验证点:`Z_METADATA.Z_UUID`(我方导出固定为基线链的 `FB9A80D0-...`)。若用户手机当前
store 的 UUID 不同(曾重装/重建过 store),导入可能落到另一个库 ⇒ 表现为「没有数据」。
→ 让用户**现在从 rond 重新导一份**,对比其 Z_UUID 即可判定,同时这份也是「app 自身往返」的对照。
## 第七轮:app 自身「变空后」导出的包(`data/20260929_152324.rondbackup`)与新的行为画像
用户的补充实验把行为画像讲清楚了:
- **app 数据被清空时**:我方包**能导入**(但**有概率**失败——暗示超时/资源或竞态,不是纯内容校验)
- **app 里有数据时**:导入会**先把库清空、然后数据没写进去** → app 变空(用户实测并导出为空库包)
- `probe1`(国家码 NULL)在有数据时也不行;`probe2/3`(含分类回退)在有数据时也失败
**空库包给出的事实**:
- `Z_PLIST`/`Z_MODELCACHE` 与真机 09-28、我方导出**逐字节相同** ⇒ **不是 app 版本/模型变更**
- `Z_UUID` 每份不同(空库 `1760D9F5…` / 真机 09-28 `DCBAE704…` / 我方 `FB9A80D0…`),而 09-20 被接受的
A 包用的正是 `FB9A80D0` ⇒ **库标识不是门禁**(可排除)
- 空库 = 19 个 Z_PRIMARYKEY 行(少 CHANGE/TRANSACTION/TRANSACTIONSTRING 三个历史实体)、各表 0 行、历史 0 行
⇒ 是干净的最小 store,结构没问题
- 我方 vs 真机 Z_MAX 全部 ≥ 各自实际最大 Z_PK;`RawVisit 我方 2246 < 真机 2314`、`CHANGE 4810 < 5171`
只是数据量不同,不构成冲突
**又补查了两处盲区(均干净)**:全表 REAL/INTEGER 列的 NaN/Inf 与范围(无异常)、所有时间列(Apple epoch)
范围(都在合理区间,无哨兵错写)。
**当前最强假设**:app 的导入是「合并式」的——**空库时能落地,有数据时因为与现有行冲突/或清空后落库失败而变空**。
验证路径:把**真机自己的 09-28 包**导进**当前的空 app**:
- 若能导入且**有数据** ⇒ app 的导入机制没问题 ⇒ 差异在我方导出的内容里,接下来按表二分;
- 若也失败/也变空 ⇒ app 导入机制本身坏了(app 侧问题),与站点导出无关。
**新探针**:`data/probe4-no-history.rondbackup`=已有 probe1 的数据 + **清空 ACHANGE/ATRANSACTION\* 与对应 Z_MAX**
(单变量测试「持久化历史表是否让 app 变空」)。
## 第八轮:决定性对照——真机自己的 09-28 包能导入且能展示
用户实测:**`data/20260928_183908.rondbackup`(真机 09-28,955 到访)导入成功,重启后数据正常展示** ✓;
且 rond **没有「库/数据集」切换入口** ✓。
⇒ **app 的导入机制是好的**,问题在**我方导出的内容**里 ✓✓(此前所有「app 侧/版本/库标识」的猜测全部排除)。
**二分探针(交叉包,各只换一半)**:
- `data/probe6-our-skeleton-device-data.rondbackup` = **我方骨架**(ZRAWVISIT 2244 / ACHANGE 4810 / 实体 22)
+ **真机的托管表**(955 到访 / 432 地点 / 32 活动)✓ integrity ok
⇒ 若这份**失败/无数据** ⇒ 病根在**骨架侧**(未托管的原始采样点、持久化历史、元数据、关联表)
- `data/probe5-device-skeleton-our-data.rondbackup` = **真机骨架**(ZRAWVISIT 2314 / ACHANGE 5171)
+ **我方托管表**(994 到访 / 433 地点 / 33 活动)✓ integrity ok
⇒ 若这份**失败/无数据** ⇒ 病根在**我方托管数据**里(下一步按表二分)
⚠️ 造包踩坑记录:真机包的**数据在 `-wal` 里**(主库只有 356KB)。第一次造 probe5 时我只复制了主库文件,
结果造出一份「骨架全空」的坏包(ZRAWVISIT=0 / ACHANGE=0 / 实体 19)——**必须先连 wal 一起解出来并
`PRAGMA wal_checkpoint(TRUNCATE)`,再复制/改动**。核对方法:造完必查关键表行数与实体数。
## 第九轮【结案】:真正的原因是**编号空间(PK)不同**,不是数据缺什么
用户实测:真机自己的 09-28 包导入成功且**展示正常**;我方导出(含各种修好的变体)一律失败或落库为空;
rond 没有「库/数据集」切换。⇒ app 的导入机制没问题,问题在**我方导出的编号**。
**决定性证据(按 UUID 对齐同一批到访,真机 09-28 vs 我方导出)**:
| 列 | 不同的行数 | 说明 |
| --- | --- | --- |
| `ZVISIT.ZLOCATION` | **920 / 920** | 例:真机指向 `#134`「河池市国民体质监测中心」(24.69183,108.03527),我方指向 `#188`——**同名同坐标**;而真机的 `#134` 在我方库里是「穿山景区-观景台」 |
| `ZVISIT.ZACTIVITY_` | **903 / 920** | 例:真机 `#29`「家」(同坐标)vs 我方 `#196`「家」 |
| `ZRAW` / `ZMERGEDTO` / `ZPARENT` | 783 / 52 / 10 | 附属字段 |
⇒ 同一个到访在两边指向**语义相同、编号完全不同**的地点/活动。**地点与活动没有 UUID**(只有到访有),
所以 app 恢复时只能靠编号解引用 ⇒ 我们每个到访都指向错的对象(或不存在) ⇒ **数据落不进去 / 落进去也看不见**。
这也解释了为什么真机自己的包永远没问题(编号自洽),以及为什么前面修内容(颜色/交通方式/历史)全都无效。
**根因**:站点的 `src_pk` 与手机 store 的 `Z_PK` 不是一套编号——站点的基线本身是**导出包**(导入-导出循环),
PK 早已漂移;导出时直接把站点编号写回,app 那边就对不上。这正是项目笔记里「基线不能用导出包」的
**实际后果**(以前只知道会「退化」,现在知道会**编号错位**)。
**可用的修法(无需改代码,用现成功能)**:让**真机包成为数据集基线**:
1. 从 rond 重新导出一份(编号 = 手机当前 store);
2. 在站点把它作为**新数据集**上传(基线就是它,编号即手机编号);
3. 在这个新数据集上,把旧站点导出包当「备份」合并进来(冲突选「用实机」= 保留站点侧的值);
—— 合并走 `mergePlaces`/`mergeVisits`:**更新的是数据集自己那几行**(`UPDATE ... WHERE id=<数据集行 id>`),
新行才用 `nextSrcPK` 取号 ⇒ **合并后编号仍是手机编号空间**(已从代码确认);
4. 从这个数据集导出 → 导入 rond ⇒ 编号能对上,数据可展示。
**持久修法(待批准)**:导出侧做「按身份重新编号」——地点按 (坐标,名称)、活动/到访按 UUID、位移/天气按
所属到访与时间对齐到**基线的 PK**;站点新建的行取基线 Z_MAX 之上的新号。这样任何一次导出都与手机编号对齐。
**探针教训**:probe5/probe6(半真机半我方)都失败——**交叉包会混出新的不一致**(引用与编号错位),
不适合做二分;二分要在**同一编号空间内**做。另外真机包的**数据在 `-wal` 里**(主库只有 356KB),
造包必须先连 wal 解出并 `wal_checkpoint(TRUNCATE)`,造完核对关键表行数。
**本地库已还原**:测试留下的数据集 152 已连同 11 张表的关联行清掉,生效数据集恢复为 9 号
(1001 到访 / 436 地点)。
## 第十轮:实现「编号对齐」的持久修法(用户批准)
新增 `internal/rond/export_align.go`:导出时把托管表的行**按身份重新编号到基线的编号空间**
(基线是这份数据集从手机那儿来的那个 store,所以对齐基线 = 对齐手机):
- 地点:**坐标键优先**(多值索引,同坐标重复行按序认领),退到「同名且 ≤1km」
- 活动 / 到访:UUID(读留档 `entity_raw` 的 `ZUID_` / `ZIDENTIFIER_`)
- 标签 / 交通方式:名称;位移 / 天气:所属到访(已对齐)
- 基线里没有身份对应的(站点新建的)行,取基线最大编号之上的新号,分配顺序固定
导出流程改动:每张表先建映射再写,写回用 `outPK`;引用(`ZVISIT.ZLOCATION`/`ZACTIVITY_`、
`ZMOVEMENT.ZVISITFROM_/TO_/ZTRANSPORT_`、`ZHOURLYWEATHER.ZVISIT`)全部改挂;
`ZVISIT.ZMERGEDTO`/`ZPARENT` 加入托管列并列并**按映射改挂**(留档里是站点编号,不改挂会指错/悬空);
`Z_PRIMARYKEY.Z_MAX` 改用「实际写入的编号」;基线的身份查询都加 `ORDER BY Z_PK` 保证确定性。
**验证(决定性)**:本地数据集 9(编号已漂移)+ 真机 09-28 包当基线导出后,按 UUID 对齐的 953 条
共同到访里,**地点引用一致 933、活动引用一致 951**(修复前是 0 / 7);剩余 20 条是基线自己重名重复行的
1:1 归属歧义(名称与坐标相同)。悬空引用 0。
**用例** `TestExportAlignsPKsToBaseline`(package web):把库里编号整体平移 500000(模拟「数据来自编号
完全不同的备份」)再导出,断言**按 UUID 对齐后地点/活动的编号与基线一致率 ≥95%** + 7 类引用无悬空。
⚠️ 第一版用例只比「名称+坐标」,变异验证时**没变红**(编号不一致但引用自洽,语义检查查不出来)——
必须比**编号本身**。加强后变异(不做地点对齐)→ 地点一致率 0/822,用例变红 ✓。
另外:`byCoord` 一开始写成单槽位,同坐标的重复行只能配一条 ⇒ 一致率只有 797/822;改成多值索引后 822/822。
## 第十一轮:编号对齐的**首次实现已撤回**(未通过往返测试,未入库)
第十轮那版「按身份重新编号」在**真实数据上验证通过**(本地数据集 + 真机 09-28 当基线:
953 条共同到访里地点引用一致 933、活动一致 951;回归用例 `TestExportAlignsPKsToBaseline`
把编号平移 500000 后**地点/活动编号一致率 822/822**,变异验证也会变红),但**两条既有用例被打破**:
1. `TestExportNeverNullsColumnsTheDeviceAlwaysFills`:它按「站点 src_pk」去找新建的活动
(`WHERE Z_PK=950001`),而对齐会把它放到「基线最大编号之上」的新号 ⇒ 找不到。属**用例假设**问题,
改成按名称/UUID 查即可。
2. **`TestExportRoundtrip`(核心不变量)**:往返(基线 = 源包本身)本应保持编号不变,却报
「导出多出到访 825」⇒ 大量到访被当成了新行。**未在预算内查清原因**,因此整版撤回(`git checkout`
+ 删掉 `export_align.go` / `export_align_test.go`),保持仓库全绿(两条用例已复跑通过)。
**下一轮排查线索**(按嫌疑排序):
- `visitsPKs/actsPKs/...` 这类 helper 返回的字段(要用 `SrcPK`,若返回 PG 的 `ID` 就会全体对不上);
- `siteUUIDMap` 的 SQL 在**fixture 数据集**上的返回(我已在 dataset 9 上验证 `encode(decode(...))`
与 web 侧 hex 一致,但没在 fixture 上验过);
- `alignUUIDs` 里「匹配上却拿到新号」的分支条件(`used` 复用/`known` 判定)。
**重要**:对齐只是「让导出的编号回到基线」,而**基线本身在哪**才是关键——所以即便不做代码改动,
**用「以真机包为基线的数据集」照样能让编号对齐**(见第九轮的配方,`mergePlaces`/`mergeVisits`
更新的是数据集自己那几行、新行才另取号,已在代码层面确认)。这条路径**今天就能用**。
## 第十二轮:编号对齐定稿(往返失败的真因 = 那条「没有 UUID 的空到访」)
重做实现后带 `ROND_ALIGN_DEBUG=1` 打印每张表的映射规模,夹具上一切正常
(ZLOCATION 323/323、ZACTIVITY 30/30、ZTAG 8/8、ZTRANSPORT 5/5、ZMOVEMENT 791/791、
ZHOURLYWEATHER 557/557,**只有 ZVISIT 是 822/823**)——落在夹具上跑本来就是对齐的幂等情形。
跑 `TestExportRoundtrip` 才看清:包内计数完全一致(823 到访 / 323 地点 / …),只有一条
「导出多出到访 **825**」⇒ 那**唯一一条没匹配上的到访**(= 没有 UUID 的「全空到访」,真机包里也
在、留档里没有它的身份)被重新发号成了 825。
**修法(关键的一条规则)**:身份匹配不上的行,**若它自己的编号没被别的身份占用,就保持原编号**;
只有被占用时才另发新号。这样:
- 往返导出(基线就是源包)**一行都不动** ⇒ `TestExportRoundtrip` 通过;
- 编号漂移过的库,匹配上的行照旧被拉回基线编号(对齐用例 822/822)。
(这也避免了「把基线的行覆盖成另一条记录」的情况:被占用说明那个编号在基线里属于别的身份。)
配合的另一个改动:`siteUUIDMap` 改为**读出 base64 字符串后在 Go 里解码**(与 web 侧
`loadStoredVisits` 同一手法),不再在 SQL 里 `decode/encode` 拼——第一版就是因为这个
(或与此相关)导致大量行匹配不上。
## 第十三轮:「数据双份」的真相 = 站点数据里的历史重复条目(不是导入 bug)
用户以手机包 `20260928_183908.rondbackup` 为数据集、导入生产导出
`20260928_183908-export-20260929.rondbackup`,**清除 app 数据后导入仍看到「重复」**。
核对两份包:生产导出 **1027 到访 / 433 地点**,其中 `ZMERGEDTO` 56 + `ZPARENT` 10(已并入的隐藏记录);
app 显示 **965 到访 / 388 地点** ⇒ 388 ≈ 包内「有到访的地点」390 ✓、965 ≈ 1027 − 隐藏的那些 ✓
⇒ **导入本身是成功的、app 里就是这份包的数据**(我上一轮「rond 是追加式」的判断错了)。
真正多出来的是**站点侧的历史重复条目**:**72 条**到访与手机包里某条**到达时间 + 地点完全相同**、
但 UUID 不同(手机包自身也有 31 组这种成对条目,那是 app 自己产生的,合法)。合并按 UUID 认人 ⇒
站点那条被判成「备份新增」⇒ app 里出现成对的两条。
**处理**:写脚本(以手机包为参照)删掉这 72 条站点侧副本 + 其附属(位移/天气/原始采样),
并把指向它们的 `ZMERGEDTO`/`ZPARENT` 改指到保留项(否则会把已并入的记录放出来)。自检:
到访 955(= 手机包)、重复 UUID 0、(时间,地点) 重复正好 31 组(= 手机包自身)、悬空引用 0、
zip framing 0x0000 ✓。产物 `data/20260928_183908-export-20260929-dedup.rondbackup` +
清单 `storage/.mergetest/站点侧重复到访清单.csv`。
⚠️ 教训:**去重必须以手机包为参照**(先判「UUID 在不在手机包里」),只按 (时间,地点) 判会把手机包
自身合法的成对条目一起删掉(第一版就这么错了:955 → 924)。
永久修法应在**站点侧**清掉这些副本(否则下次导出还会带出来);可行做法是给 CLI 加 `dedup`,
参照即数据集的基线(手机包)。
## 更正:CLI 名字记混了
本项目(rond-with-you)**没有 `cmd/` 目录、没有子命令 CLI** —— 之前笔记里「管理 CLI(`TamaBox <cmd>`)、
注册在 `cmd/tamabox.go`」是**另一个项目(TamaBox)**的东西,被我串到这边的笔记里了。本项目的维护脚本
都在 `tools/*.py`(verify_export_structure.py / verify_rondbackup.py / photo_coord_audit.py / audit_export_nulls.py)。
新增 `tools/dedup_site_visits.py`:以**手机包为参照**清掉站点侧的历史重复到访(默认只预演,
`--apply` 才删;连带处理位移/天气/留档,并把指向副本的 ZMERGEDTO/ZPARENT 改指到保留项)。
状态:**已完成并在真实数据上演练通过**。踩了两个 PG 坑:
1. `raw->>` 是 text,与数字比较报「text = integer 操作符不存在」→ 工具在动手前整个退出(预演没事、
一旦 --apply 就 exit 1)⇒ 必须按字符串比 `(raw->>'ZMERGEDTO') = '956'`;
2. 同一 UPDATE 里给 raw 两次赋值报「对同一列 raw 进行了多次分配」⇒ jsonb_set 必须嵌套。
演练(导入含 72 条副本的包 → --apply → 核对):到访 1026→954、位移 833→809、天气 621→589
(与手机包一致);(时间,地点) 重复组 101→**31**(= 手机包自身)、悬空 0/0 ✓。
站长也实测:去重后的包导入 rond **正常**了。生产用法(先预演):
`python tools/dedup_site_visits.py --dataset <id> --baseline <手机包.rondbackup> --db/--user/--password/--host ...`,
确认后加 `--apply`。
## 待办:「重复到访」清理做进页面(方案已获批,尚未动代码)
用户要求「不能在页面上直接实现吗」→ 已给出变更清单并获批准(「就按你的默认去做」),**本轮未落地**(上下文预算耗尽,
不愿留半成品在库里)。下一轮直接按这份清单写:
**落点(已侦察)**:
- 判定逻辑:与 `tools/dedup_site_visits.py` 同规则(UUID 不在基线里 + (到达时间, 地点) 与基线某条一致),
Go 侧新建 `internal/web/edit_duplicates.go`;参照 = `s.baselinePath(ds.ID)`(`internal/web/handlers_export.go:196`)。
- ⚠️ `internal/web` **没有引 sqlite 驱动**(`grep modernc.org/sqlite internal/web/*.go` 为空)→ 读基线库需要
加 `_ "modernc.org/sqlite"` 的 import(或复用 `rond` 里现成的读取)。
- 视图:编辑页的数据结构在 `internal/web/view.go:768` 附近(已有 `Activities []EditOption`、`TransportColors`),
加一个 `DupVisits []…` 字段;装配在 `internal/web/handlers_edit.go:25` 的 `editPage` 里(`d.Activities` 在 73 行附近)。
- 模板:`internal/web/templates/admin/edit.html` 用 `<h3>活动</h3>` + `<div class="tw"><table class="tb tb-stack">` 的卡片风格
(活动卡片在 34 行附近)。**该模板只能用 shell 改**(用编辑工具会被注入 `data-page-node-id`)。
- 路由:新增 POST(如 `/admin/edit/duplicate-visits`),照 `editActivityUpdate` 等的注册方式加。
- 删除语义照 `editVisitDelete` + 工具已验证的那套:删副本 + 其 movements/weather/entity_raw,
并把指向副本的留档(`entity_raw.raw` 的 ZMERGEDTO/ZPARENT)**改指到保留项**(jsonb_set 要嵌套,
比较用字符串 `(raw->>'ZMERGEDTO')='956'`——这两个 PG 坑都踩过)。
- 卡片只在检出 >0 时显示;基线不是手机包时显示一行灰字说明;提交要二次确认;做完闪信 + 刷新。
- 用例:夹具里造一对副本 → 断言检出 1 对、清理后到访 −1、未勾选的不动、无悬空;然后全量验收。
## 已落地:编辑页「重复到访」卡片(上一轮清单执行完毕)
新增 `internal/web/edit_duplicates.go`(判定 + `editDuplicateVisitsApply` + `cleanDuplicateVisits`)、
`EditData.DupVisits/DupUnavailable`(view.go)、`editPage` 装配、`POST /admin/edit/dup/clean`、edit.html 卡片(45 行纯新增,编辑工具这次没注入属性)、
用例 `internal/web/dup_visits_test.go`(4 个)。判定照工具语义,UUID 是前提;与工具刻意不同的一处:改指合并标记只动本来就有的键
(工具会凭空补 ZMERGEDTO)。变异验证 3 项全绿(其中「丢 UUID 前提」第一次漏判,补了定义性不变量才抓到)。
顺带修了一个**导出必挂**的洞:`rond/export.go` 的 `queryWeather` 把 `is_daylight` 扫进裸 `bool`,
而站点「新增天气」不写这一列 ⇒ 一补录天气,整包导出 303 +「备份恢复失败」一类报错。改成 `COALESCE(is_daylight,false)`(与 `parse.go` 同口径)。
## 待用户拍板:时间线上的 ZMERGEDTO / ZPARENT 行
用户报「生产+测试时间线上出现重复」,实测是**手机自己标的「已并入 / 子记录」行**(如 09-04 家 20:21 的 #422 `ZMERGEDTO=#562`),
不是本卡片管的站点副本(那两条 UUID 都在手机包里,删了导出会丢合并信息)。全站没有任何查询识别这两个字段 ⇒ 时间线与日停留都重复计入。
数据集 9:54 条 `ZMERGEDTO` + 10 条 `ZPARENT`,26 条并入行与原件到达时间相同。方案(A 加派生列 / B where 里 NOT EXISTS)已报给用户等选。
## 验收:301 PASS / 0 FAIL / 0 SKIP(5m49s)
**⚠️ 环境坑(这次卡了 1 小时,务必先做)**:
- `rond_fog_test` 死元组膨胀到 **3311 MB**(`entity_raw` 2.2GB / places 378MB / visits 233MB),级联
`DELETE FROM users` 变成 IO 爬虫 ⇒ 全量验收跑 30 分钟都跑不完。`VACUUM (FULL, ANALYZE)` 后 **3311MB → 16MB**,
整套 5m49s 跑完。跑过几十轮全量后会再涨,卡了就先 VACUUM。
- 库里**必须有生效数据集**:`SELECT id FROM datasets WHERE is_active LIMIT 1` 取不到时 `internal/stats` 的
6 个对照用例全 **SKIP**(假 skip,看着像「没环境变量」)。本次先 `UPDATE datasets SET is_active=(id=1)` 才 0 skip。
- 验收跑着的时候**别并发跑定点测试**(同一个库,会加剧膨胀/timing);中途 kill 测试进程还会留下孤儿 e2e 数据集
(fixture 只在自己正常退出时才清理)。
- 结果:PASS=301(基线 297 + 本卡 4 个用例)/ FAIL=0 / SKIP=0;第一段纯净往返包 PASS。
- 二进制已重编(`go build -o rond.exe .` + 拷进 `bin/`,旧版存 `bin/rond.exe.old-20260929d`),站点当时没在跑。
## 已落地:已并入 / 子段到访在读的一侧收口(方案 A)
用户报「生产+测试时间线上有重复」,实测是手机自带 `ZMERGEDTO`(已并入别处)/ `ZPARENT`(某个父段的子段)行:
app 不单独显示、不计数,本站当成普通到访 ⇒ 时间线上「同时刻同地点两条」、日停留重复计入。
**不能删**(导出要靠它认这次合并),所以判据只立一份 `stats.SoloVisitSQL`,在读的一侧收口:
`visitsWhere`/`VisitsByPlace`/`Bookmarked`/`placeCols` 子查询、`handlers_public` 两处、`handlers_topics` 四处、
三处聚合(ingest / refreshPlaceAgg / recountPlaces)的 count+dwell、`schema.sql` 一段幂等回填。
**天气与位移不收口**(真实发生过);**导出侧一个字节不动**(rond 不读 visit_count/dwell,只读 last_visit_at,保持全量)。
审阅中发现并修掉的真 bug:**ingest 的地点聚合回填排在 `insertEntityRaw` 之前** ⇒ 判据读不到留档 ⇒ 每行都算独立到访
(多算 62~64 条)。是新写的 `hidden_visit_test.go` 抓出来的。另一个是「0 次却有最近到访」(4 个地点),
展示侧用 `CASE WHEN p.visit_count > 0` 兜住。
实测:places 到访数合计 1001 → 937,停留 249769 → 222399 分(数据集 9,回填已在该库执行)。
时间线查询 0.35ms → 3.5ms(42 次主键探测),Planning 3.6 → 6.4ms。
验收:**301 → 305 PASS / 0 FAIL / 0 SKIP**(推送后复测也绿)(`internal/stats` 三个对照用例先红后绿:天气/位移一开始也继承了判据,
`TestWeatherStatsRespectsFilter`、`TestVisitSideFiltersApplyToMovements` 立刻抓出来)。
环境:一轮全量让测试库涨 ~370MB 死元组,跑前跑后各 `VACUUM (FULL, ANALYZE)`(16MB → 5m49s;385MB → 30 分钟未完)。
## 审阅时又发现一个既有 bug:地图城市圈的「到访次数」差 128 倍
`/api/cities` 把聚合打在 `places ⋈ visits` 上 ⇒ `sum(p.visit_count)` 按到访条数重复累加、`count(*)` 当成地点数。
实测最大城市显示「到访 72661 次 · 568 个地点」,真值 568 次 / 86 个地点。改成「先按地点聚合、再按城市汇总」,
并补上已并入/子段的判据;新增 `city_agg_test.go`(手写口径对账 + 变异验证)。
## 收尾(最后一批)
- `scripts/verify.sh`:库 ≥ 200MB 时先 `VACUUM (ANALYZE)`(只做普通版,FULL 会锁库把验收卡住);
同一夹具用例 13.6s → 10.2s。
- 城市圈首末到访改按可见到访算(`min/max(v.arrival)`),并补了**能区分**的断言:
把某地点最晚那次标成已并入后,城市最近到访必须从 09-13 退到 09-08(原来的同口径比对是假绿)。
- README 补「重复到访卡片」「为什么有些到访在页面上看不到」+ 验收环境说明。
- **明确不做的两项**(有实测理由):派生列 `visits.solo`(LIMIT 下只 42 次探测,存列反而要跟着刷)、
改指语句批量化(会遇到「一行两个键指向不同副本」只改一个键的边界)。