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

40 KiB
Raw Blame History

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