# 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/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 --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` 用 `

活动

` + `
` 的卡片风格 (活动卡片在 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 次探测,存列反而要跟着刷)、 改指语句批量化(会遇到「一行两个键指向不同副本」只改一个键的边界)。