Files
rond-with-you/.workbuddy/memory/MEMORY-details.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

537 lines
79 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.
# rond-with-you 项目长期记忆
> 只留「改代码前必须知道的结论」,一句话一条。**细节别在这儿找**:rond 包/迷雾/导出验收见技能 `rond-backup-parse`,量法与探针见技能 `rond-web-verify`,推导过程见 `.workbuddy/memory/YYYY-MM-DD.md`。
## 一、站点侧落库规矩
- **「建地点 + 建到访」必须同一事务**(`Server.withTx` + `execer`):两步写留下孤儿地点,导进 rond 永远「无数据」。到访时段选填,但**校验必须先于落库**。
- **行程派生字段必须与导入路径一致**(`validateMovementForm`):`from_place_id`/`to_place_id`(轨迹靠它画线,**区域黑名单也按两端 places 判定**,留空 = 绕过过滤直接露给访客)、`distance_km`、`duration_min`。
- **新行编号一律走 `nextSrcPK` + `clearStaleRaw`**:取三处最大 +1 —— 库内该列、`coredata_meta.entities.<实体>.max`、`entity_raw.raw->>'Z_PK'`(留档存着 skipped 行,占号但无实体行)。只看库内 `MAX` 会复用 → 新行被当旧行、继承对方 rond 写的列。**绝不再写 `SELECT MAX(src_pk)+1`**;pk 分配在事务内。回归 `internal/web/seq_test.go`。
## 二、rond 备份包导出(细节见技能 `rond-backup-parse`)
真机包 `data/20260914_151649.rondbackup` 是唯一标准答案。
- **两条铁律**:① PG 是真值来源、`entity_raw` 是原值来源——管理列由 PG 覆盖,其余列只能从导入时留的原始整行还原;② 区分**基线已有行**与**站点新建行**(`keepNilCols` + `known[pk]`),前者永远保持真机原值。
- **只给新行补的列**:`ZLOCATION.ZRAWLATITUDE/LONGITUDE` = 原始 WGS-84(`ZLATITUDE/ZLONGITUDE` 是 GCJ-02),用 `geo.GCJ02ToWGS84` 反算;`ZVISIT.ZIDENTIFIER_`/`ZACTIVITY.ZUID_` **必须确定性**(表名 + src_pk),随机则每次导出都变、rond 当新记录;`ZACTIVITY.ZICON_` 缺省 `📍`。
- **派生缓存列** `Z*_.ZLASTARRIVALDATE_` = 该对象最晚一次到访到达时间;没用过写哨兵 `-63114076800` 或 NULL(判空不能只看 `IS NULL`),**别留空**否则 rond 显示「无数据」。
- **ZMOVEMENT**:`ZTYPE_` 791/791 非空、**不能写 NULL**(2 = 无交通方式、5 = 有);进 `keepNilCols` 且**只给新建行**补,**别做成管理列**(否则 23 条 `type=4` 被洗掉)。硬不变量 `ZSTART_ >= 起点离开`、`ZEND_ <= 终点到达`(0 违例)。`ZTRANSPORT` 聚合(`queryTransports`)必须 `GROUP BY transport_src`(`DISTINCT` 会给重复 Z_PK → 撞主键、整次导出失败)。
- **不是缺口**:`ZVISIT.ZRAW`(采样点**数量**)、`ZMERGEDTO`/`ZPARENT`、`ZLOCATION.ZNOTE_`;`place_notes` 仅站长可见,单向从 `ZVISIT.ZREMARK_` 汇总、不回写 rond。
## 三、迷雾(Fog of World).fwss
zip 包,**条目内容本身是 zlib 流**;`Model/*/` = 头 32768B + 每块 515B,**已探索层由 `fog_blocks` 重建**;`Model/~/`、`Model/#/` **不解析只原样写回**(少了快照残缺约 55%)。块尾 3 字节 = 大端 24 位数 = 置位数×2+1(真机 8701/8701 精确满足)、上限 4096 ⇒ **最高字节恒 0**。
- `Decode→Encode` 除压缩级别与块体槽位顺序外完全一致;回归 `internal/fog/fog_test.go`(需 `FOG_SRC`)。**清空迷雾**与**整体替换(cover)**都要连 `fog_raw_files` 一起处理,否则残留「无对应 `Model/*` 瓦片」的图层条目。
## 四、精度陷阱:时间与坐标都不能「原样回贴」
表单精度低于库里真值,**只改备注也会悄悄改掉时间和坐标**:**时间** `datetime-local` 只到分钟、真机带小数秒(`20:03:21.323088`,截断后还会早于起点离开时间被行程窗口拒掉——线上「改方式提示开始时间不能早于离开时间」就是这个);**坐标**控件 `%.6f`、真机长小数(`26.46800422211455`),只改名称就把点挪走。规矩:**与库里已有值落在同一精度上就沿用库里那份**(`keepStoredTime`/`resolveMovementEnd`/`keepStoredCoord`),行程、到访、地点三条编辑路径都要用。
## 五、坐标来源留档(`places` 四列)
`coord_source`(rond/photo/manual,空 = 历史行)、`coord_sys`(wgs84/gcj02)、`src_lat`/`src_lon`(**换算前**)。不变式:**显示坐标 == 按 `coord_sys` 换算 src_lat/src_lon**。
- 写入点:`ingest/coord.go` 的 `BackfillCoordSource`(启动全量跑;判据要 geo 往返换算,**不写进 schema.sql**)、`adminPhotosImport`、`editPlaceCreate`;`editPlaceUpdate` 走 `updatePlaceRow`——表单没提交的列不动、坐标动过才更新留档、走换算时记换算前那份。
- **「有 ZLOCATION 留档」≠「rond 导入」**:`export.go` 给站点新建点**合成** ZLOCATION 行(`ZRAWLATITUDE = GCJ02ToWGS84(导出那一刻的显示坐标)`),与真机行无异。**唯一判据**:`ZRAWLATITUDE == GCJ02ToWGS84(该行自己的 ZLATITUDE)` 位级相等 ⇒ 合成行(基线 321 行零命中);**境外点跳过**(换算是恒等)。
- `ZISPHOTOIMPORT` 由 `coord_source` 推;换算统一走 `GET /api/coords?mode=wgs2gcj|gcj2wgs`,**geo 包是全站唯一实现,前端别另抄**。**高德 POI 就是 GCJ-02**;`/api/regeo` 返回 `street`/`township`(表单里叫 `thoroughfare`,`edit.html` 曾读错键导致街道填不进去)。
- **存量坐标裁决**:同名 POI 对称距离、迷雾栅格两条判据**已否证别再试**;用**批次同一定律**(一批 = 一坐标系 + 一台手机、批内同向,凡几个旧坐标距同名 POI >200m 的点换算后落回地物几十米内即成立;**名称无证据力**)。修库 `tools/photo_coord_audit.py --apply`,修完拉 `/api/points` 复核;`/api/points` 带 `sp`(=src_pk),**基线 sp ≤ 323 用长小数、照片导入 > 323 是 6 位小数**。
## 六、前端 / 模板 / CSS 坑
- **改 Go 必须重启**(旧进程占 8080 时新进程报 `bind: Only one usage`,看着像构建失败);**模板走 `//go:embed`,正式态改 `.html` 要重编二进制**,但 `debug=true` 时每次从磁盘重解析、刷新即生效;CSS/JS 只在 `debug=true` 直读磁盘。**带地图的页面必须 `p.HasMap = true`**,否则 Leaflet 不引入 →「地图容器在、整块不出现」。
- **模板拼查询串 href 必须走返回 `template.URL` 的方法**(`Page.PlaceLink/EditPlaceLink/MapLink`):`?{{.FilterQS}}` 被 URL 上下文转义(`=`→`%3d`)→ 筛选**静默失效**(翻页最易忽略),`{{safe}}` 挡不住;**返回 template.URL 时不要再手工做 HTML 属性转义**(`&amp;` 会被再转成 `&amp;amp;`)。
- **CSS 顺序陷阱**:同特异性靠后的赢,而 `.chip`/`.btn`/`.check input` 的**基础规则在 `app.css` 中部** ⇒ 写在文件前半的 `@media (max-width:620px)` 覆盖是**死代码**(`.check input{width:20px}` 一直没生效)。窄屏尺寸/内边距覆盖**一律写进文件末尾那个块**(已标注「必须放最后」);基础规则里没有的属性(`min-height`/`display`/`--field-fs`)写前面没事。
- **控件字号**:`input:not([type=file]):not([type=color])…,select,textarea{font-size:var(--field-fs,inherit)}`,窄屏 `--field-fs:16px`、表格里 14px。**被 `:not()` 排除的类型(file/color/range/checkbox)吃不到**,要单独给字号——iOS 聚焦 <16px 控件会自放大整页(file/color 已提 16px;**range 是审计误报**,滑块不弹键盘)。窄屏下限:触控 44px(`.btn/.chip/.nav-links a` + 导航 logo `.brand`)、`select{min-height:44px}`(表格里除外)、`checkbox/radio{20px}`、`.tag{12px}`。
- **表格别让 CJK 把列榨干**:`table.tb{width:100%}` + CJK 逐字可断 ⇒ 窄屏列压到 1~2 字宽;靠 `.tw > table.tb{min-width:…}` + `.tw` 横滚。**展开行与主行等宽靠 `colSpan`**(主行 9 列 ⇒ `colSpan=9`),浅底用 `var(--bg-2)` 别用 `--line-soft`。
- **`rond-map.js`**:`full()` 首屏用 `points: []` 再靠 `setPoints` 填,**按需构建的图层必须读 `st.points`**;`preview()` 的「点击进全屏」要传 `opts.clickToFull`(只有主页该有);**同容器二次 `L.map()` 抛 `already initialized`**(被 catch = 假失败),`_state[id]` 已有 `setPoints` 就复用实例;时间轴增量更新判据是**对象引用**、拖动传 `{keepView:true}`;markerCluster 子标记**异步**渲染,动画类要挂 `add`。
- **`map.html` 筛选面板**:三个坑(内边距须挂 `#filter-form` 而非面板、sticky 元素**后面不能还挂内容**、「查询/重置」按钮要 `width:100%`)与量法见技能 `rond-web-verify` 的 `probe_map_panel.js`;`act`/`hw` 两个 hidden **必须留在 `details` 外面**否则收起即丢筛选。
## 七、访客 vs 站长:双身份差异(新 bug 都容易出在这)
**访客页与站长页的 DOM 不一样**——只在访客视角复现、站长自己点一遍一切正常。遇到「某功能只有站长有」先查这类。
- **定性手法**:用**不带 cookie 拉原始 HTML**(urllib)与 CDP 求值后的 DOM 比,能区分「服务端没渲染」与「渲染了被 JS 删掉」,比看截图快。
- **已修**(`96e7631`):详情页画廊 `syncGallery()` 从 `#imgs-grid` 反推地址,而缩略图墙整块在 `{{if .IsAdmin}}` 里 ⇒ 访客页数到 0 张,把服务端渲染好的「图片 N」按钮 `remove` 掉。修法:`PlaceData.ImageURLs` 随页面下发;`cellURLs()` 判据改成**「缩略图墙在不在」而非「墙里有几张」**(站长删光图时墙还在,按张数回退会把删掉的图数回来)。回归 `TestVisitorPlacePageCarriesImageURLs`。
- **同类扫描**(条件块内声明的 `id` 被 JS `getElementById` 读)目前**只有 place.html 命中**,其余是「本页元素被本页脚本读」的正常形态;改模板时别新增这种耦合。
## 八、地点图片与转码
- **对象名 = `<数据集>/<上传月份>/<地点号>-<内容哈希>.webp`**(按月分目录便于备份、带地点号便于认领、哈希天然去重);**不要去地点号**(删图按 key 精确删)。key 存 DB 行,**改规则不影响旧图**。排序 `ORDER BY position, id`。
- **转码在浏览器 canvas 做**(`place-image.js`),服务端只校验文件头、原样写盘、**不解码** ⇒ 任何平台都不需要 libwebp;**`canvas.toBlob` 在不支持 WebP 的浏览器会静默返回 PNG**,必须判 `blob.type`。
- **压缩参数(2026-09-18 重定,别退回旧值)**:长边 **1440**、质量阶梯 `[0.72, 0.58]`、命中 **220KB** 即停,走到底仍超标就整张缩到 **1080** 重编(≤3 次编码)。依据:压质量掉 19% 画面就糊,缩尺寸同 q 掉 55% ⇒ **超标该缩尺寸**。透明 PNG 先铺白底。
- 存哪边由后台设置决定(local/s3),凭据只在 `conf/app.ini` 的 `s3_*`;`internal/media` 手写 SigV4、零新依赖。**`s3_public_base` 必须带协议**(裸域名 = 相对地址 = **图全变破图且不报错**)。**上传不能只放 `<input type=file>`**:用 `<label class="btn primary" for=...>` 包隐藏输入框,文案写明**选中即上传**。
- **照片导入页**:地图卡在「2. 确认与修改」内 `position:sticky`,`.stuck` 时收矮到 132px(移动 110)——收矮后必须 `invalidateSize()`,否则瓦片不全。改坐标/时间要重算 KPI(`updateKpis()` 从 DOM 现算,不能只挂在 `finishLookup`)。
## 九、迷雾遮罩区 / 区域限制
- `fog_masks` 圆心存 **GCJ-02** + 半径(米),判定前换算到网格的 WGS-84;网格横向随纬度乘 cos(φ) 收缩 ⇒「地上的圆」在网格里是**椭圆**。遮罩**只对访客生效**;被遮格子不画填充、也不当「未探索」邻居(否则边缘描出轮廓 = 暴露范围)。
- `stats.Filter` 有**两套 where**:`visitsWhere` 与 `movementsWhere`(按**两端** places 判定),漏一个就「到访数藏了、里程还留着」;`internal/stats/stats_test.go` 对账。被区域规则挡的地点详情**不直接 404**,走 `notice.go` 的 `pausePage()`。
## 十、底图、后端与性能
- 高德免密钥瓦片:`style=8` 街道(带 POI/路名)vs `style=7` **纯路网**(近黑像素 1.46% vs 0.21%),点位密到觉得乱就用 7。默认底图 = 后台 `MapDefaultBase`;`pickBase()` 三级回落:**访客切过的(`localStorage['rond.base']`)→ 后台默认 → 第一张**。
- **`r.ParseForm()` 会毒化后续 multipart 读取**:它把 `r.Form` 先填成「不含 multipart 字段」的那份,而 `FormValue` 只在 `r.Form == nil` 时解析 ⇒ 字段一律读成空(表现为「删图按钮点了没反应」)。**收 multipart 的 handler 直接用 `ParseMultipartForm`**;urlencoded 调用方会收到 `ErrNotMultipart`,用 `errors.Is` 放过。
- **加 `setting` 字段要同时改三处**:`Default()`、`Load()`(逐字段显式覆盖,漏了 = 存进去读出来是默认值)、`settingsSubmit()`;**`conf/app.example.ini` 与解析器同步有测试守着**(键名要允许数字 `s3_*`)。
- **PG 建连接比查询贵两个数量级**(~300ms vs ~2ms)→ `SetMaxIdleConns` **必须等于** `SetMaxOpenConns`(8/8),启动 `db.WarmUp(d, 6)`;页面里的独立查询用 `runJobs` 并发(上限 6)。**`fog.Index.All()` 会复制排序整张块表**,统计别用(面积读 `ix.AreaKm2()`);**保存或删除遮罩必须 `s.masks.invalidate()` + `s.fogTiles.clear()`**。
## 十一、导出包验收(改完代码要跑的那套)
**分两段跑,别指错变量**:`ROND_CLEAN_OUT=… go test ./internal/web/ -run TestMovementCleanExport` 产**纯净包** → `ROND_EXPORT=… go test ./internal/rond/ -run TestExportRoundtrip` 比。指到真库的导出会大批报「导出多出到访」——不是 bug。干净往返应 visits/places/acts/moves/weather/rawvisits 全等、**坐标偏差 0、时间偏差 0s**。`go test -count=1 ./...` 要 **0 skip** 才算真跑过(缺 `ROND_DSN`/`ROND_SRC`/`ROND_TEST_CONF`/`FOG_SRC`/`ROND_EXPORT` 会静默跳过);e2e 会往真库导一份基线再清掉,跑前 `pg_dump`,跑后核 `users like 'e2e-%'`=0 且 `is_active` 没被改。**三处「差异正常」见技能最后一节,别当 bug 修**。
## 十二、404 / 中间页的分工(改路由前先看)
`notice.go` 的 `pausePage` 是站点样式的「停一下」页;三种话术**别混**:`placeHiddenPage` → **403**「这个地点没有对访客开放」(区域隐藏,刻意告知,代价是能反推 ID 存在);`notFoundPage` → **404**「不存在」(未对外展示的专题对访客**走这套、刻意不区分**);`guardPage` → **404**「该页面未对访客开放」。`fog.go`/`tiles.go`/`handlers_images.go` 的 `http.NotFound` **故意保留纯文本**(瓦片/图片是给浏览器爬虫取的)。
## 十三、指标口径与筛选计数(先对齐语义,再怀疑算法)
- **「累计停留」= 各次到访时长之和**,不是「首到达 → 末离开」的跨度:`sum(LEAST(COALESCE(duration_min,0),10080))`(首页/统计页/专题同一个口径)。**全站没有跨度 KPI**;夜间没记录到的到访、在途时间都不进「停留」。改口径前先确认站长要的是哪个。
- **筛选条上的类型计数必须是分面计数**:`pageBase` 里用 `stats.DB.ActivityCounts(ctx, s.filterOf(p))` 覆盖 `Activities(ds.ID)` 那份全量数字,且**必须把 `ActivityIDs` 清空**(否则选中一个类型后其它类型全变 0)。`Activities(ds.ID)` 只适合取「有哪些类型、名字/颜色/图标」。
## 十四、「新增 XX…」两种形态(别照抄)
- **自由文本型**(`类别`,存 `poi_category` 原串):放 `__custom__` 哨兵 + 文本框,**不需要先建实体行**;新值要 `addCategoryOption/promoteCategory` 挂到所有 `select[name^="category_"]`(含隐藏选项源、插在 `__custom__` 之前),否则同一批里第 2 张还得重打。触发点是 **change(失焦)或回车**。
- **实体型**(`活动` → `activities`):必须先 `POST /admin/edit/activity` 建行拿 id。该接口**不去重,同名会真建两行** ⇒ 客户端按名字去重;新选项插在 `__new__` 哨兵之前;并加**提交守卫**(仍停在 `__new__` 就 `preventDefault`),否则解析不出整数、静默当成「未指定」。
- 两者都靠「隐藏 select(`#activity-options`/`#tag-options`)+ `makeSelect` 克隆」分发 ⇒ **往隐藏源里插**,之后新解析出来的行也自动带上。`活动` 的新增入口只在 `admin/edit.html` 与 `admin/photos.html`;`admin/edit.html:273`(行内编辑某条到访)仍没有。
- **2026-09-18 晚补记**:三处活动下拉现在共用同一套委派处理器(`select.activity-id-inline` + 同表单里的 `.visit-act-new` / `.visit-act-add` / `.visit-act-name`,都在 `admin/edit.html`):新建地点卡、到访行内编辑、地点行内编辑。**别再给某个下拉单独挂 `change/click`**——会建出两条活动(顶部原来那对 `#activity-add`/`#new-activity-name` 已因此删掉)。地点行内编辑的「活动」= **批量改该地点名下全部到访**(`place/update` 的 `activity_id`,默认空值「(不修改)」;与地点行同一事务提交;哨兵 `__new__`/不存在的 id/别的数据集一律拒;`is_home`/`is_work` 跟着活动走)。回归 `internal/web/place_activity_test.go`(做了两次变异验证)。
## 十五、`?back=` 贯穿(筛选不丢)
- `Page.Back` **只放行站内路径**(拒 `//host`、非 `/` 开头、引号/尖括号/反斜杠——因为当 `template.URL` 用,放行就是开放重定向/XSS);`Page.BackQS` 是当前地址编码后的参数值,**要剔除 back 自己**否则套娃。**非 href 位置(表单 hidden)用 `BackRaw`(string)**。
- `EditData.PageLink` 翻页要带 back;`/admin/edit` 三个 GET 筛选表单各有 hidden back,三个「重置」走 `EditData.EditLink(clear…)`,别在模板里手拼。`BackLabel` 取路径段**必须先剥 `?`/`#`**。**一次性 flash 不进 back**(`backQS` 要 `Del("ok")`/`Del("err")`);**别把用户输入直接内插进 href**(`/map?q={{.Place.Name}}` 是反例,已改走 `Page.MapLink`)。回归 `internal/web/pagelink_test.go`。
## 十六、改到访时间后必须夹行程(已实现)
真机硬不变量:位移不早于起点到访的离开、不晚于终点到访的到达。`movementWindow` 的 1 分钟容差(表单只到分钟)会让**改到访时间**这条路径漏出越界。现在 `editVisitUpdate` 调 `clampVisitMovements`(纯函数 `clampMovementWindow` 可单测,回归 `clamp_movement_test.go`):① 夹完必须 `end > start`,窗口压没了就 `end = start + 1min`(真机 791/791 条都有值,不能写 NULL);② `execer` 接口为此补了 `QueryContext`。**残留**:行程表单那条路的容差没动,手工把结束填到终点到达的下一分钟仍会产生 ≤60 秒越界。
## 十七、加表格列必看(会把别的列挤换行)
`table.tb` 是 `width:100%`、下限只有 `.tw > table.tb{min-width:640px}`,CJK 逐字可断 ⇒ **加的列会把别人的空间吃掉**(地点库加「图片」列后「河池市」折成两行)。实测:**只有抬表格下限有效**(min-width 700 → 城市回到一行),收窄新列自己的 padding **没用**;给那张表单独一个类(`.tw > table.tb.places-table{min-width:700px}`),别动全局 640。`PlaceCols`/`scanPlace` **成对**,且 `PlaceCols` 还被 geojson/gpx 导出、单地点查询、投票页用着 ⇒ **新列一律追加在最后**,只需同步改 `scanPlace`。
## 十八、重启与操作纪律
- **重编重启**:`go build -o bin/rond.exe .` → 停掉占 8080 的进程 → `./bin/rond.exe -c conf/app.ini`。`taskkill //F` 的双斜杠参数会被 Git Bash 吃掉,改用 PowerShell 按端口找 PID + `Stop-Process`。
- 生产 `life.shiroko.one` **只走 IPv4**(curl 必须 `-4`);会话 cookie `rond_session=uid|token`(HMAC);HTTP 303 **不能跟随**(Location 里带 `ok=`/`err=`)。`/tmpfog/` 不是运行必需,`tmpfog/_ck.txt` 是**死票**(实打接口被 302)⇒ 不必轮换 `secret_key`。
- **`gofmt -l` 会报假警**:仓库存 LF、`core.autocrlf=true` 把工作区换成 CRLF(已加 `.gitattributes` 钉住);**用 python `open(p,'w')` 回写 Go/CSS 会把行尾变 CRLF**,要二进制模式读写。
- **同一文件绝不并行改**:多条 Edit 一起发给同一文件时,各自「读整份-替换-写整份」会互相覆盖——工具**报 success 但磁盘上没落**(实测一次发 3 条给 `map.html`,`map-count` 那条丢了)。**一个文件串行改,改完 `grep` 复核关键标记,再编译。**
- **CDP 探针里的 `click()` 可能静默落空**(落点在 HTML 解析中途,监听还没挂;`errs` 也空):要先触发才成立的断言一律写成轮询。**新写的回归测试要做变异验证**:把修复处临时改坏跑一遍确认它**会失败**,再改回来。
---
## 十九、跨备份:src_pk 不可用,只能认 UUID(2026-09-21 并入)
- rond 每次导出/同步都会**重排 Z_PK**:实测 0914→0919 两份备份 822 条到访 `ZVISIT.ZIDENTIFIER_` **全部相同**、Z_PK **100% 不同**。跨备份唯一稳定键 = 到访 `ZIDENTIFIER_`、活动 `ZUID_`;**地点/标签/交通方式/位移/天气都没有 UUID**,只能「名称+坐标」「名称」「起止到访 UUID+时间」兜底。
- `contentHash` 拿 `SrcPK|Arrival` 当指纹会被 PK 重排骗过 ⇒ 同一批数据重导被判「内容不同」→ 建新数据集 → 备注/投票按 `dataset_id` 隔离后全部失联。**跨备份判重必须用 `ZIDENTIFIER_`**。
- 差异预演(`internal/rond/diff.go`):到访按 `VisitUUID`、活动按 `ActivityUUID`;库侧值在 `entity_raw.raw #>> '{ZIDENTIFIER_,$blob}'`,**留档是 base64,必须 `uuidFromBase64` 转 hex 再比**。地点**只认坐标**(`PlaceCoordKey` 6 位小数),名称不参与(`backfillAfterImport` 会反查改名);配不上再按名称兜底。真机约 **2/824 条到访没有 UUID** ⇒ 必须有「到达时间(到秒)+ 坐标」兜底配对,否则同一条既算「新增」又算「仅站内有」(`timeKey` 到秒即可:苹果时间戳浮点秒 vs PG 微秒,逐位比永远配不上)。预演**只读**,`backup_diff_test.go` 钉「渲染前后三张表行数不变」。
## 二十、附属数据主键 = 坐标键(不是 dataset_id+src_pk)(2026-09-21 并入)
- `place_notes`/`place_votes`/`place_images` 一律挂 **`place_key`**(经纬度各 6 位小数,约 0.1m);`dataset_id`/`src_pk` 只留作「最后写在哪儿」的排查线索。
- **Go 与 SQL 两套实现必须逐位一致**:`rond.PlaceCoordKey`(`%.6f|%.6f`)↔ `to_char(x,'FM999990.000000')||'|'||to_char(y,'FM999990.000000')`,同款表达式散在 `notes`、`votes`、`web/handlers_images.go`(`placeKeyExpr`)、`stats/placeCols` **四处**,改一处搜全仓。实测 368/368 一致。
- 主键:`place_notes`/`place_votes` = `(user_id, place_key)`;`place_visitor_votes` = `(place_key, voter_key)`;`place_images` 用自己的 id + `place_key` 索引。
- **访客票是站点自有数据(rondbackup 里没有)**,表里**没有 dataset_id** ⇒ 迁移时只能按**活跃数据集**认领编号;两边都有同编号时也只认活跃的;认不出留 `orphan:<pk>`,票不丢。
- **凡按这两张表统计的都要改成按坐标键 join `places`**(`voteTotals`、`verdictPlaces`、备注页排序/关联、`stats` 的 `Verdict` 过滤)——**不能按它们自己的 dataset_id 过滤**,那会漏掉从别的数据集写进来的票。
- ⚠️ **迁移块必须收敛**:改主键的两段(老块改回三列 / 新块改 place_key),**老块先判「没有 place_key 列」**,否则每次启动来回翻;**新块判据用「主键列组合」而非「列在不在」**,半迁移的库才自愈。
- ⚠️ **迁移块必须排在依赖表之后**:坐标键迁移块一度插在 `CREATE TABLE place_images` 之前,开发库无症(表早存在),**新库直接起不来**。只被 `TestOpenCreatesMissingDatabase` 抓到 —— **加迁移块后务必跑 `ROND_DSN=… go test ./internal/db/`**。
## 二十一、合并执行 `internal/web/backup_merge.go`(2026-09-21 并入)
- 顺序:地点 → 活动 → 标签 → 到访 → 位移/天气 → 重算聚合,全在**一个 `withTx`**,失败整体回滚。**不做删除**(删地点会级联删掉它的全部到访)。
- **只有库里那套编号有效**:新行走 `nextSrcPK` 发新号 ⇒ `movements.from/to_visit_src`、`weather.visit_src` 必须写**库里的 src_pk**。
- **位移/天气只随「这一轮新插入的到访」走**(`addedVisit`:至少一端是新行);少了判断会把库里已有位移整份复制(实测 791 → 1547)。
- **新行必须 `writeRawRow` 写 `entity_raw`**:下次比对靠那里的 UUID 认人,漏了幂等性立刻破。
- 插天气前先**认领**「同一时刻、`visit_src IS NULL`」的记录(站内删到访就是这么置空的),否则多一份重复天气。
- 冲突逐条选;页面用 `form=` 属性把散落各表的 radio/checkbox 关联到同一个(空)`<form id="merge-form">`。
- **删除勾了才做,默认一个不删**(键是 `src_pk`)。顺序:到访 → 活动 → 地点;删到访要连 `movements`、`entity_raw` 一起清、并把 `weather.visit_src` 置空。**名下有到访的地点直接拒绝**(外键 `ON DELETE CASCADE`,真删会带走到访)。
## 二十二、迷雾导入三段式 + 差异地图(2026-09-21 并入)
- **别退回一步直写**:`POST /admin/fog/preview`(暂存 `<data_dir>/fog-staging/`,令牌 32 位 hex、TTL 2h)→ `GET /admin/fog/preview/{token}`(`fog.DiffBlocks` 现算、**不落库**)→ `POST /admin/fog?stash=…`。**带 stash 的是 urlencoded**(`PostFormValue` 可读 mode),**无 stash 是 multipart**(`readFogSnapshot`);两条路都得留。
- 差异页必须摆出:整体替换「会丢多少块/格/面积」+ 快照缺 `Model/~`、`Model/#` 层 —— 实测替换静默丢过 **1841 块 / 14%**。
- 差异瓦片 `GET /admin/fog/preview/{token}/{z}/{x}/{y}?crs=…` → `fog.RenderDiffTile`(绿=新增 / 灰=两边都有 / 红=会丢)。**两条硬前提**:① 瓦片必须与底图同 `crs`(服务端逐块 WGS→GCJ 纠偏);② 页面必须 `p.HasMap=true`,否则 layout 不引 Leaflet,脚本 `if(!window.L) return` **静默退出、控制台无报错**。精度两级:z≥14 逐格精确;更粗按块内多数类涂色(`dominantKind`,绘制序 both→gain→lost)。两边块集按 token 单槽缓存 10 分钟(`fogDiffSets`)。
- **迷雾测试用 `conf/app-test.ini`(库 `rond_fog_test`)**:`fog_blocks` 是站点级、`LoadAll` 不分用户,跑开发库会污染真实迷雾。`fogIndex()` 只在 `sync.Once` 里跑一次 ⇒ 测试里清库/写库后必须手动 `LoadAll`(`fog_preview_test.go` 的 `reloadIndex`)。
## 二十三、宽表窄屏「字段+值」堆叠(2026-09-21 并入)
- 表加 **`class="tb tb-stack"`(类名成对,缺 `tb` 则 CSS 全不命中)**、非首格加 `data-label`(首格=主角不带;**表头为空的列不用**,如操作格)。
- CSS 写在 `app.css` **末尾**,判据 **`@media (max-width:800px)`**(卡片内容宽≈视口−93、最宽表 min-width 700 ⇒ 793 才放得下;内边距变了要重算)。**别用 `@container`**(iOS<16/Chrome<105 整块失效)**也别退回 620**。
- 堆叠态放开桌面 nowrap/宽度下限:行内表单 `min-width:var(--efw,280px)`(模板 inline `--efw`)、操作格 `td.ops{white-space:nowrap}`(`white-space` 会继承进表单顶破卡片)。**宽度别用行内 `style="min-width"` 写死**。
- **宽表卡片不进 g2**(半宽时 1440 也横滑)。回归 `tb_stack_test.go` + `wide_table_layout_test.go`(通用判据:凡被 `.tw` 包住的表必须带 tb-stack;每条 tb-stack 规则都在 `max-width≥792` 的 @media 里;有 tb-stack 必须有 tb)。
- 横滑是 `.tw` 内部滚的、**页面本身不溢出**,肉眼极难发现(2026-09-20 漏过一次)。
## 二十四、活动 / 类别(分类字典)(2026-09-21 并入)
- **活动是表(`activities`),类别不是**——类别只是 `places.poi_category` 字符串列 ⇒「改/删类别」只能是**按值批量 UPDATE 地点**(`poi_category=$to` / `=NULL`)。
- **类别候选来源**:数据编辑页/照片页 = `SELECT poi_category, count(*) FROM places … GROUP BY 1`(只有真在用的才出现);补全页 = **在用(标「(在用 N)」,按地点数降序)∪ 静态表**(`fillupCategories(inUse)`:真机用过的 12 种标「真机用过」+ 其余 18 种)。**加新页面/改候选照这个来**。
- 「改名」和「合并」是**同一条 UPDATE**;类别**不预置空字典项**,「新增类别」= 地点行内编辑选「自定义…」。
- 活动改名/删除/新增/合并:`/admin/edit/activity{,/update,/delete,/merge}`;类别:`/admin/edit/category/rename`、`/admin/edit/category/clear`。都返回 JSON。合并要把源的到访转过去**并把 `is_home/is_work` 抄成目标的**,删完 `pruneEntityRaw`。测试 `taxonomy_test.go`;页面卡片 `edit.html` 的 `data-card="taxonomy"`(唯一默认展开)。
- ⚠️ **`ZCATEGORY_` 真机只有 `MKPOICategoryXxx` 枚举串**(实测 122 条全枚举、0 条非枚举)。**别说「真机也写中文」**(2026-09-19 误判、09-20 证伪)。库里中文类别(一般道路/彩票店/租车点/美食街)**全是站内写的**。这列导出**原样写回**(`managedCols`)⇒ 改任意串**不坏数据**,但 app 里**可能显示不出类别(未实测)**。想稳用 `MKPOICategoryXxx`。判「这行谁写的」:**站内新建行 `entity_raw.raw IS NULL`**。
- **类别中文名 = 内置 `poiCategoryText`(42 条)+ 站点覆盖 `category_labels(value PK, 站点级)`**。取用一律 `categoryLabel(v, custom)`:自定义 > 内置 > `TrimPrefix(v,"MKPOICategory")`;`categoryLabelMissing` 只对「是 MKPOICategory* 但内置表没有」为真。⚠️ **显示名绝不写进 `places.poi_category`**(`TestCategoryLabelNeverTouchesValue`)。历史两张中文名表已合并、`poiName` 与模板函数 `catName`/`poiname` 已删——**模板里算不出显示名**,一律 Go 侧算好塞数据。
- **`Missing`(类别缺名)必须同时看覆盖**:`categoryLabelMissing(v) && custom[v]==""`。
- **写 `poi_category` 的两个入口都要过 `okEnum()` 提醒**(类别台账「改名」+ 地点行内编辑「自定义类别」):不在内置枚举表就 confirm 并猜「你是不是想写 X」。
- ⚠️ **改模板 JS 别用「shell heredoc + python」写 `\n`**:多转义一层 → 落到文件成真换行 → 单引号字符串含裸换行 → **整块 `<script>` 语法错误、交互全哑**,而模板解析测试照样通过。一律用 Edit 工具或 `chr(92)+'n'`。守卫 `inline_script_test.go`。**改完模板仍要用 CDP 确认「JS 报错」为空**。
## 二十五、补全页交互(2026-09-21 并入)
- 活动和类别**同款**:`select.fill-sel` + 末尾「其它(自己写)…」,选中后露出同名 `__custom` 文本框(后端 `act_`/`cat_<id>==__custom__` 时读 `<key>__custom`)。共用一套 CSS 类与脚本(`select.fill-sel` 一次遍历、`input[name$="__custom"]` 自动配对)——**加新字段照这个模板抄**。
- **下拉必须留「其它(自己写)」出口**:纯 select 会把「列表里没有的新名字」堵死。**默认一律「不动」,绝不预选建议**(预选后点「用高德分析」整页重载,像被锁死);批量采纳走「采纳本页建议」按钮。
- 点「用高德分析」前把本页填的存 sessionStorage,回来恢复;提交时清掉。草稿恢复时对下拉**派发 `change`**,让「其它」文本框跟着露出。
- 手工填的值补进同类下拉(标「新填:X」、带 `data-local`、插在建议之后真候选之前),存 `sessionStorage['rond-fillup-newopts']`。**重画候选前先留住本格已选中的新填值**。文本框监听**直接挂**(`input` 不冒泡)。
## 二十六、导出包导不回真机时的排除顺序(2026-09-21 并入)
① `tools/verify_export_structure.py rondbackup <原版> <导出>`(结构/不变量);② `tools/verify_rondbackup.py`(逐字段);③ **工具没覆盖、必须自己补三项**:**NOT NULL 违例**(`integrity_check`/`foreign_key_check` **都不查**,真出就是 Core Data 打不开)、`Z_METADATA.Z_PLIST`/`Z_UUID`/`Z_MODELCACHE.Z_CONTENT` 的**字节哈希**、**所有列**逐值比对;④ 时间戳末位差 ~0.1µs 是 **PG `timestamptz` 只有微秒精度**,属 ≤1ms 容差,**别当 bug 修**。
- ⚠️ **新建行的「真机从不留空的列」不能写 NULL**:判据 =「基线里 100% 有值的列,新行也不许为空」。已知 `ZTRANSPORT.ZCOLOR_/ZICON_`(站点不维护方式表、从 `movements` 聚合 ⇒ 用户没选就空)。修的时候**只补新建行**(`!known[tbl][src_pk]`)。守卫 `export_placeholder_test.go`。
- ⚠️ **`PRAGMA foreign_key_check` 在 rond 库里毫无意义**(SQLite 表**没有外键约束**),关系完整性必须自己按「关系列 → 目标表 Z_PK」扫悬空。已知 8 组:`ZRAWVISIT.{ZVISIT,ZLOCATION,ZACTIVITY}`、`ZSTAYSEGMENT.ZLOCATION`、`ZUSERPHOTO.ZVISIT_`、`ZVISITPHOTOFAVORITE.ZVISIT_`、`ZKEYWORD.ZACTIVITY_`、`ZPOICATEGORY.ZACTIVITY_`。典型场景:**站点删掉到访/地点/活动后,不托管的子表留悬空引用 ⇒ 真机拒收**(「备份恢复失败,你的当前数据未被更改」=先校验、整体回滚)。`ZRAWVISIT` 失去到访时**删掉该行**。已进 `tools/verify_export_structure.py` 1.45 节 + `TestExportDropsOrphanedRawVisits`。
## 二十七、其余散条(2026-09-21 并入)
- 补全(`internal/web/fillup.go`):补 `visits.activity_id`(活动挂在**到访**上)+ `places.poi_category`;**按地点批量**填该地点名下所有 `activity_id IS NULL` 的到访,否则同一地点在统计里分裂成两种活动。**只填空**(两条 UPDATE 各带 `AND … IS NULL` / `AND poi_category=`)。来源三层:本地词表(`fillup_rules.go`)→ 高德周边搜索(按坐标 `place/around`)→ 人工。规则「**词长优先 + 先掐掉括号里的地址**」;**单字关键词一律不要**(「山」「站」)。高德结果缓存 `fillupCache`,**查过不再重试**(含没查着的),批次跑几轮自然收敛。
- `coords` 留档与判据见第五节;`/api/regeo` 返回 `street`/`township`。
- 口径:**「累计停留」= Σ 各次到访时长**(`LEAST(COALESCE(duration_min,0),10080)`),不是首到末跨;筛选条类型计数必须用分面 `ActivityCounts` 且**清空 `ActivityIDs`**。
- 404 三种别混:`placeHiddenPage`=403、`notFoundPage`=404、`guardPage`=404;瓦片/图片**故意纯文本 404**。
- 导航横向溢出:`.nav-links` 可滑,脚本按 `scrollWidth-clientWidth-scrollLeft>2` 加/去 `.overflow-right`,CSS 用 `mask-image` 渐隐。
- 窄屏折叠筛选卡(`layout.html` 的 `filters` 片段):挂载点 `form.card.filters-card`+`#filters-fold`;CSS 只在末尾窄屏块;**脚本必须紧跟表单**(放末尾会闪一帧);状态键 `rond-filters-fold`;收起只是 CSS 隐藏,控件照旧提交。回归 `filters_fold_test.go`。
- 验收两段:`ROND_CLEAN_OUT=… go test ./internal/web/ -run TestMovementCleanExport` → `ROND_EXPORT=… go test ./internal/rond/ -run TestExportRoundtrip`;`go test -count=1 ./...` 要 **0 skip**。⚠️ 跑 `-p 1 ./internal/...` **别用新的 `ROND_CLEAN_OUT` 文件名**(`internal/rond` 字母序在 `web` 前 ⇒ 导出文件还没生成 ⇒ 4 个测试一起红,假警报)。
- 纪律:生产只走 IPv4(`curl -4`)、303 不跟随;`gofmt -l` 会被 CRLF 假警(python 回写用二进制模式);**同一文件绝不并行发多条 Edit**;CDP 探针点击写成轮询,新回归测试做变异验证。
- `verify_*` 工具与验收脚本见技能 `rond-backup-parse`;量法探针见技能 `rond-web-verify`。
## 二十八、安全 / 性能 / 验收补充规矩(2026-09-21~22 并入)
> 这一节是原 `MEMORY.md` 索引里「违反会静默出 bug」那批判据的完整表述(索引为控制注入长度已压缩,正文放这)。
### A. 落库与导出补充
- 建地点+到访必须**同一事务**,校验先于落库。
- 新行号只走 `nextSrcPK`+`clearStaleRaw`(取「库列 / `coredata_meta` / `entity_raw.raw->>'Z_PK'`」三处最大 +1);**禁 `MAX(src_pk)+1`**(撞号会继承对方 rond 列)。守卫 `seq_test.go`。
- 行程派生列必须与导入一致(`validateMovementForm`):`from/to_place_id`(**留空=绕过区域过滤直接露给访客**)、`distance_km`、`duration_min`。
- 表单精度低于真值(分钟 / `%.6f`)⇒ 只改备注也会**静默改时间坐标**;同精度就沿用库里那份(`keepStoredTime`/`resolveMovementEnd`/`keepStoredCoord`,行程/到访/地点三条路)。改到访时间要 `clampVisitMovements` 夹行程(窗口空则 `end=start+1min`,不能 NULL)。
- `src_pk` 只在单数据集内有效(重导 Z_PK 100% 变);跨备份稳定键=到访 `ZIDENTIFIER_`/活动 `ZUID_`,其余靠名称+坐标。`contentHash` **别用** `SrcPK|Arrival`(重导即建新数据集 ⇒ 备注投票失联)。
- 坐标留档 `coord_source/coord_sys/src_lat/src_lon`;不变式「显示坐标 == 按 coord_sys 换算 src」;换算只走 geo 包与 `/api/coords`;高德 POI 即 GCJ-02。
- **脏备份守卫在 `parse`**:`appleTime` 对 NaN/±Inf 或秒数超 `[1970,2100)` 返 nil——**Go 超范围浮点转 int64 是回绕不是饱和**(`NaN/±Inf/1e30→2001`、`1e12→2123`,回绕还可能又落合法区间 ⇒ **先卡输入秒数再乘**);nil 接「装不下就 skip+留档」。`validCoord` 把非法坐标归 `(0,0)`。`rond/dirty_test.go`+`ingest/dirty_import_test.go`。
- 聚合刷新(`saveVisitTags`/`refreshPlaceAgg`/`refreshDatasetAgg`/`activityFlags`)**必须返回 error**:事务内上抛、事务外 `logAggFailure`(只记日志、不放错给用户)。`agg_error_test.go`。
- 收藏切换走 `UPDATE … RETURNING bookmarked` 一条语句(分两步回读,失败会误报「已取消」)。`visit_bookmark_test.go`。
- `writeRawRow` 留档写入**必须上抛错误**让 `withTx` 回滚;只 log 后 `return nil` ⇒ 合并照常提交、原始列永久缺失。
- rows 循环收尾必须 `drainRows(rows, what)`(先 `Err()` 再 `Close()`):只 Close ⇒ 半张表当完整交出。`parse_rows_test.go`。
### B. 安全 / 跳转
- **跳转唯一判据 `safeInternalPath`**(`view.go`):挡 `//evil.com`、`/\evil.com`、控制字符、引号尖括号。`?back=`/表单 `next`/投票后/登录后四处共用。`redirect_target_test.go`(含禁弱判据的静态扫描)。
- cookie 的 `Secure` 必须**按请求判断**(`cookieSecure`:`r.TLS` 或 `X-Forwarded-Proto` 首段):无条件打会让本机 http 调试与 CDP 谁都登不进。
- **CSRF 只加同源 Origin/Referer 守卫**(`csrf.go`,不动模板):仅写方法、`Origin: null` 拒、两头都缺则放行。前提是反代传原始 Host(改写需带 `X-Forwarded-Host`),否则本站表单也 403。
- `clientIP` 只认 `X-Forwarded-For` **最后一段**(首段可伪造;nginx 把真对端补在最后),须 `netip.ParseAddr` 通过否则退 `RemoteAddr`。⚠️ 投票匿名身份 = `hash(cookie,IP)` ⇒ 改取法即改身份。
- **GPX 按属主收口**:`/api/gpx`(无鉴权)先取生效数据集 `user_id`(`is_active AND status='done'` 最近一份)取不到返空,再 `WHERE user_id=$1`;`adminGpxDelete` 用 `DELETE … WHERE id=$1 AND user_id=$2` **且查 `RowsAffected()==0`**。`gpx_owner_test.go`(静态扫描**只认反引号里的 SQL**)。
- **下载名走 `downloadDisposition`**:`filename=` ASCII 兜底(`asciiSafe`)+ `filename*=UTF-8''`(`rfc5987Escape` 只放行 `A-Za-z0-9.-_~`)。四个出口(geojson/gpx/rondbackup/fwss)都经此。`download_name_test.go`。
- **两步验证两闸都要**:`login2FA` 接 `loginGuard`(键 `"2fa:"+用户名`,与口令环分开计数);`CodeReplay.Consume(uid,code)` 保证同码在 ±1 步窗口(`replayTTL=90s`)内只换一会话;重放不计入爆破计数。`twofa_guard_test.go`+`code_replay_test.go`。
### C. 后端 / 性能
- `findSourceArchive` **先按 `size_bytes` 筛再算 sha256**(`pickArchiveBySha`);`sha=''` 退化路径不筛。
- PG 建连接贵 ⇒ 池 8/8 + `db.WarmUp(d,6)`;页面内独立查询走 `runJobs`(≤6);别用 `fog.Index.All()`;改遮罩必须 `masks.invalidate()`+`fogTiles.clear()`。
- `placeCols` 6 个相关子查询**必须带 `vN.dataset_id=p.dataset_id`**(索引 `(dataset_id,place_id)`;实测 Index Searches 2371→366、53→31ms)。`stats/placecols_test.go`(**按子查询分段**判,v2 出现 3 次)。
- 收 multipart 的 handler 直接 `ParseMultipartForm`(`r.ParseForm()` 毒化字段);urlencoded 调用方 `errors.Is(err, ErrNotMultipart)` 放过。**之前必须 `r.Body = http.MaxBytesReader(...)`**(`maxMemory` 只限内存,超出无上限落临时文件)。五个上传口:备份/GPX/迷雾/照片/**地点图片**。
- 包了 `ResponseWriter` 必须实现 **`Unwrap()`**(`gzipWriter`、`headerSent` 已补):否则 `NewResponseController` 拿不到 `SetWriteDeadline`/`Flush`/`Hijack`。导出路由靠它放宽写死线到 10 分钟。
- **`recover` 写错误页前须看「首部是否已提交」**(`headerSent`):写了半页才 panic 再 `http.Error` 会二次 WriteHeader 且错误体接半页后。`server_error_test.go`。
- **上传 handler「先落盘再解析」时,落盘后每条失败路径都要删半成品**(`adminUpload`:`keepStored`+`defer os.Remove`,只「新导入成功」保留——上传件是导出基线源,`findSourceArchive` 按 `datasets.sha256` 在 uploads 找它)。`upload_cleanup_test.go`(断言前先从 Location 确认真走到写盘后失败分支,否则空过)。
- **批量勾选操作必须走 `*Bulk` + `p.src_pk = ANY($3::bigint[])`**(pgx stdlib 直接吃 `[]int64`,不必 `pq.Array`,也不用手写 `IN` 占位符):逐条循环是 N 次往返,1C1G 上体感明显。**全局内存缓存必须带上限**(`fillupCache` 用 FIFO 淘汰最旧 1/4,覆盖写同一键不重复记账)。守卫 `admin_notes_bulk_test.go`(静态扫描「不许退回逐条循环」+端到端「每步断言全部生效」)+`fillup_cache_test.go`。
- **同一查询别在一个 handler 里查两遍**(统计页 `Weekday` 曾在 `runJobs` 的 job 之外又同步查一次,白跑一次);守卫 `stats_weekday_once_test.go`。
- 加 `setting` 字段改三处:`Default()`/`Load()`/`settingsSubmit()`。
- `fogIndexV` 是 `atomic.Pointer[fog.Index]`(`fogIndex()` Load / `fogResetIndex()` Store):普通指针会成真 data race。`fog_race_test.go`(需 `-race`)。
- 瓦片编号走 `tileDigits`(补前导 0):`tileName` 的 M2 尾缀起点 `len(s)-2` 在单数字编号时为 -1 ⇒ panic;解码守卫 `len(name)<8`(旧 `<7` 放行 7 字节伪造名,md5 可算 ⇒ 上传 .fwss 即触发)。
### D. 前端补充
- **异步回包一律发序号作废**:勾图层→请求在飞→取消→回包照画 ⇒ 开关关着图层还在画(CDP 实测少守卫多画 756 条、`errs=0`)。`var seq=++xxxSeq;` 须在 `if(!on) return;` **之前**,回包首行 `if(seq!==xxxSeq) return;`,`.then`/`.catch` 都挡。`map_async_cancel_test.go`。
- ⚠️ 改模板 JS 别用「heredoc+python」写 `\n`(多转义→真换行→整块 `<script>` 语法错、交互全哑而模板测试照过);一律用 Edit。`inline_script_test.go`;改完 CDP 确认 JS 报错为空。
- 窄屏覆盖必须写 `app.css` 末尾那块(靠后赢);被 `:not()` 排除的 file/color 单独给字号;触控 44px、checkbox 20px。
- 宽表窄屏堆叠:`class="tb tb-stack"`(**类名成对,缺 `tb` 全不命中**)、非首格 `data-label`;`@media (max-width:800px)`;别用 `@container` / 别退回 620。`tb_stack_test.go`+`wide_table_layout_test.go`。
- 加表格列:`PlaceCols`/`scanPlace` **成对**、新列**追加最后**;CJK 挤换行只抬该表 `min-width`。
- 访客页与站长页 DOM 不同:只在访客复现的 bug 先「无 cookie 原始 HTML vs CDP 后 DOM」定性;别让 JS 从 `{{if .IsAdmin}}` 块读 `id`。
- ES5 风格 JS 里访问 `window.localStorage` / `sessionStorage` 一律包 try(可走 `lsGet`/`lsSet` 包装)——隐私模式下裸访问会抛异常,异常冒出去会打断整段脚本(地图白屏 / 下拉哑掉),而模板测试照过。守卫 `TestMapLocalStorageGoesThroughGuard`、`TestFillupSessionStorageIsGuarded`;`previewQueue[id]` 必须在 `if (live)` **之前**排队,回调里消费后删除,不能只在「已初始化」分支保留 points。
### E. 口径 / 404 / 验收
- **区域判定必须「两端各过一遍再 AND」**,与 `regionSQL` 同款:`apiTrack`=`regionSQL("pf")+regionSQL("pt")`;`movementsWhere` 也必须两端各判(**不能** `EXISTS(... IN (from,to) ...)` 任一端命中——黑名单下等价、白名单下分叉 ⇒「图层画不出、总里程却算进去」)。比较列一律 `COALESCE(col,'')=$n`(`district` 常 NULL ⇒ `NULL IN (...)` 得 NULL、黑名单 `NOT` 跟着 NULL ⇒ 整行误删)。对账 `internal/web/region_consistency_test.go`。
- 「累计停留」= **Σ 各次到访时长**(`LEAST(COALESCE(duration_min,0),10080)`),非首到末跨;筛选条类型计数用分面 `ActivityCounts` 且**清空 `ActivityIDs`**。
- **`Filter.Verdict` 全域一致**:verdict 条件下沉到 `visitsWhere`(`place_key` 坐标键 `EXISTS(place_votes…)`),`Cities`/`ActivityCounts`/`ActivityStats` 跟着走;`hasVisitConds()` 要加 `|| f.Verdict != 0`。`place_key` 抽 `placeKeyExpr(alias)`。`stats/verdict_scope_test.go`(fixture 自建封闭数据集、**不设 is_active** 免串味)。
- 404 三种别混:`placeHiddenPage`=403、`notFoundPage`=404、`guardPage`=404;瓦片/图片故意纯文本 404。
- 验收四件套环境变量:`ROND_TEST_CONF`+`ROND_SRC`(绝对路径)+`FOG_SRC`+`ROND_DSN`(**须指 `rond_fog_test`**;指 `postgres` ⇒ `TestChangeUsername` 报「users 不存在」+6 个 stats skip)。`go test -count=1 -v ./...` 要 **0 fail / 0 skip**。
- ⚠️ **「全绿」的假信号规模**(2026-09-24 实测):不设四件套跑 `./internal/web/ ./internal/rond/ ./internal/stats/` ⇒ 三个包全 `ok`、`exit=0`,但 **95 PASS / 86 SKIP**(原始报告基线是 62 SKIP,随回归守卫增多而变大)。**永远 `-v` 数 SKIP**。
- 📊 稳定性基线(2026-09-24 更正,见 **§二十九**):**口径必须用 `-coverpkg=./internal/...`**。此前记的「加权 ≈51%;rond 38.8% / stats 62.2%」是 `-cover` 假读数——它只统计「测试文件所在那个包」自己的执行,而 rond 的导出、geo/notes/votes 都由 `internal/web` 驱动。真值:`rond 82.2%`、`geo 92.9%`、`stats 83.7%`、`web 53.2%`、`db 53.4%`、`fog 65.9%`、`auth 73.6%`、`exif 69.7%`、`setting 92.5%`、`captcha 90.6%`、**项目合计 62.3%**;`gpx` 曾真报 0%(整包零测试,已补到 96.0%)。**无 CI / Makefile / linter**,0-skip 验收靠 `scripts/verify.sh`(别再靠人记环境变量)。
- ⚠️ 两个「假绿」坑:①`newMovementFixture` 缺 `ROND_SRC` 就 `t.Skip` 而结果仍 `ok`;②往共用测试库 `Import`(切 `is_active`)会让 `internal/stats` 用例假 skip(夹具 `SELECT id FROM datasets WHERE is_active LIMIT 1`,**无 user 过滤**)⇒ 必须用**专用库**(`url.Parse(ROND_DSN)` 加后缀、`db.Open` 自建),且 `t.Cleanup` 在测试自己的 `defer` **之后**才跑(`defer d.Close()`+Cleanup 删数据 = 连接已关、静默失败)。
- 静态守卫不要用相对路径读文件,走 `moduleRoot(t)`/`pkgFile(t,name)`;`?` 占位符按 SQL 出现顺序绑定;`-race` 需 `~/scoop/apps/mingw/current/bin` 在 PATH。
- 纪律:生产只走 IPv4(`curl -4`)、303 不跟随;`gofmt -l` 会被 CRLF 假警;**同一文件绝不并行发多条 Edit**;新回归测试要做**变异验证**(注入 bug→确认红且理由正确→还原→确认绿)。
## 二十九、覆盖率口径 / 死代码 / 关键页冒烟(2026-09-24 并入)
### A. 覆盖率:先确认口径,再看数字
- **`go test -cover` 只统计「测试文件所在那个包」自己的执行。** 本项目大量代码被别的包驱动
(`rond` 的 `export.go` 由 `internal/web` 的 `TestMovementCleanExport` 跑;`geo`/`notes`/`votes` 由 `web`/`stats`),
按 `-cover` 量这些包一律**假报 0%**。判据:**换个口径数字会翻倍以上 ⇒ 先怀疑口径,别急着补测试**。
正确用法:`go test -coverpkg=./internal/... -coverprofile=… ./...`;`verify.sh --cover` 已固化。
- ⚠️ **解析 profile 必须按 `(文件,行段)` 取 max,不能求和**:`-coverpkg` 下每个测试二进制
都给同一段代码写一条记录(一条 `count=0`、一条 `count>0`),求和 = 语句数乘份数 ⇒ 每包都像只有 `1/N`
(踩过:`web` 印成 4.4%,真值 53.2%)。`go tool cover -func` 会自己合并,优先用它。
- 补测试只补「**线上会走到、且一条用例都没有**」的路径。`nil` 分支与
`if err != nil { return err }` 一律不追——那是覆盖率灌水。抓到的真缺口:`BuildMergePlan` 的
地点/活动冲突(`placePick`/`activityPick` 长期 0%)、`internal/gpx` 整包 0/61。
### B. 死代码:删,别给它写测试
- 删前**必须全仓核查、且要连模板一起搜**——Go 模板能调方法(`{{.Counts.Total}}`),只搜 `.go` 会漏。
- 已删:`rond.Counts.Total()`(模板里的 `.Total` 都是各页 view 自己的字段)、`gpx.Track.BBox()`
(`handlers_topics.go` 的 `fitBBox` 是无关变量)。**给死代码补用例 = 把死代码固化,比不管更糟。**
- 「实测否证」也要写下来:`gpx.haversine` 缺 `geo.Haversine` 那道 `math.Min(1,…)` 夹子,
本以为会出 NaN;实测对跖点 `h` 最多到 `1+2^-52`,`sqrt(1+2^-52)` 回舍成正好 `1.0`,
`asin(1.0)=π/2` ⇒ **够不着,不改代码**,只留一条用例钉住该性质并在注释里写明是实测结论。
另有三条 haversine 公式、两个半径(6371.0 / 6371.0088),差 0.20 m/146 km,属口径不统一、不影响功能。
### C. 关键页冒烟 `scripts/smoke.py`(模板测试过了 ≠ 页面能看)
- 模板守卫只证明「模板里有那段文字」;白屏、JS 抛错、内容区被压成 0 高,**模板测试全是绿的**。
- 三种口径:**站长浏览器**(200 + 落点路径 + 内容区尺寸非 0 + 零 JS 报错)、**访客纯 HTTP**
(公开页 200;后台页必须 **303 跳登录**,`urllib` 不跟随跳转即可,比开 Chrome 快一个数量级)、
**访客浏览器抽检**(只在访客侧复现的 bug 藏这里)。`verify.sh --smoke`(只跑)/`--all`(全量+冒烟)。
- ⚠️ **关键区块不能取 `nav.nav`**:它在 `layout.html` 里,内容块整个没渲染它照样在,等于没断言。
地图页取地图容器(`#home-map`/`#map`/`#place-map`),其余取首个 `.card`。
- ⚠️ **用 `.card` 不要 `div.card`**:专题卡片是 `<a class="card topic-card">`,写 `div.card` 一个都选不中,
而 **`querySelector` 返回 null 时尺寸也是 0**,打印出来跟「元素被压成 0 宽」一模一样(踩过,差点去查布局)。
日志必须把「没选中」与「选中了但 0×0」分开印。
- ⚠️ **`--min-body` 只能兜「整页空白」**:专题列表正文天生 166 字(就一张卡片),阈值定 200 会假红。
公开页受总开关控制 ⇒ 先探测 `/places` 的访客状态码再定同类页期望值。
- ⚠️ `verify.sh` 给 Windows 版 python 传脚本路径要用 **`$ABS`(`C:\...`)**,传 `$ROOT`(`/c/...`)
会被拼成 `C:\c\...` 报 `No such file`。`site_cdp.py` 不报 HTTP 状态 ⇒ 用
`performance.getEntriesByType('navigation')[0].responseStatus`(给的是**最终文档**状态,
所以「访客访问后台必须跳登录」要看 `location.pathname` 或直接用 `urllib`)。
- 变异验证手法:往公开模板塞 `<script>throw new Error('…')</script>`,`debug=true` 下模板热加载
**不用重启**,冒烟立刻红在「JS 报错」而**状态码/内容区尺寸全正常** ⇒ 证明没这条断言就会放过它。
### D. 一键验收的完整形态(`scripts/verify.sh`)
`--fast`(gofmt/vet/build)|`--race`|`--cover`(`-coverpkg` + 按包表)|`--smoke`(只跑冒烟)|
`--all`(全量+冒烟)|`--pkg`/`--run` 定点。默认全量要 **0 FAIL / 0 SKIP**(SKIP 计数含子测试)。
2026-09-24 收尾实测:**284 PASS / 0 FAIL / 0 SKIP**,冒烟 16 页全绿,项目覆盖率 62.3%。
## 三十、「重复到访」清理做进编辑页(2026-09-29)
### A. 判定与落点
- 实现:`internal/web/edit_duplicates.go`(读基线库 + 检出 + `cleanDuplicateVisits`)、视图字段 `view.go` 的 `EditData.DupVisits/DupUnavailable`、
装配在 `handlers_edit.go` 的 `editPage`(`ensureBaseline` 之后)、路由 `POST /admin/edit/dup/clean`、卡片在 `templates/admin/edit.html` 到访记录卡片之前。
- 判定与 `tools/dedup_site_visits.py` **同一套语义**:参照 = `s.baselinePath(ds.ID)`(就是那份手机包,实测 9.sqlite 与 20260914_151649.rondbackup 的 ZVISIT 完全一致);
**UUID 是前提**——站点到访的 UUID 不在基线里、且基线里有一条「到达时间 + 地点」完全相同的才算副本。只按时间+地点判会把手机包自身合法的成对条目一起删掉。
- 「时间 + 地点」折成 `round(apple秒*1000) + "|" + 名称/坐标(%.6f|%.6f)`;两侧用同一个 `dupKey`,先到先得(Python 的 `setdefault`),顺序一致结果才一致。
- 读基线库:`sql.Open("sqlite", "file:…?_pragma=query_only(1)")`。驱动**不支持 mode=ro**,但基线是 VACUUM INTO 的产物(rollback journal 模式),只 SELECT 不落旁写文件;`internal/web` 不必自己 import 驱动(`rond` 里已 `_ "modernc.org/sqlite"`,blank import 随包 init)。
- 基线不可用 / **跟本站 UUID 一条都对不上**(`matched == 0`)⇒ 只给灰字说明、不给按钮。缺了这道闸,拿别人的手机包当参照会把全站到访都算成副本。
### B. 与工具刻意不同的一处:改指合并标记只动「本来就有的那个键」
- 工具用 `jsonb_set(..., '{ZMERGEDTO}', …, true)` 把两个键都写成保留项 ⇒ 「只有 ZPARENT 指向副本」的行会**凭空长出一个 ZMERGEDTO**
(真机里那是「这条已并入别处」,会白丢一条记录)。实测基线 956 条 ZVISIT 留档都带着这两个键,但只有 54 / 10 条**有值**,凭空补键很容易踩到。
- 所以 Go 侧逐键更新、`create_if_missing=false`;`raw->>$4 = $2` 必须**按字符串比**(拿数字比报「text = integer 操作符不存在」,整批清理会失败)。`TestDupCleanKeepsMergeKeysAsIs` 静态守这两条。
### C. 顺带修掉:站点补录天气会让**整包导出失败**(`rond/export.go` 的 `queryWeather`)
- `is_daylight` 被扫进裸 `bool`,而站点「新增天气」(`editWeatherCreate`) 不写这一列 ⇒ NULL ⇒ `sql: Scan error … couldn't convert <nil> into type bool`,导出整包 303 + err。
- 修法:`COALESCE(is_daylight,false)`,与导入侧同口径(`parse.go` 读真机包用的就是 `COALESCE(ZISDAYLIGHT,0)`)。真机 557/557 都有值 ⇒ 兜 0 而不是 NULL。
### D. ⚠️ 时间线上「看着重复」还有**另一类**:手机自带的 ZMERGEDTO / ZPARENT 行(**尚未处理,等方案**)
- 现象(2026-09-04 家 20:21 两条):`#422`(20:21→01:52, 331min, `ZMERGEDTO=#562`) 与 `#562`(20:21→15:37, 1156min)。两行 UUID **都在手机包里**,是手机自己「已并入」的碎片行——
真机 UI 只显示 `#562`,本站两条都显示,日停留也被重复计入(那天「1天12小时」里 5h31m 是重复的:`TimelineDay.Dwell` 就是当天各行 `DurationMin` 之和)。
- **不能删**:导出要靠它告诉 rond「这次合并发生过」;本卡片的 UUID 前提也天然把它们排除在外(删了就是毁手机自己的记录)。
- 规模(数据集 9):54 条 `ZMERGEDTO` 非空、10 条 `ZPARENT` 非空,其中 26 条并入行与原件**到达时间完全相同**。
`ZPARENT` 的形状是「子段拼成父段」:`#64`(11:35→14:21) + `#133`(09:01→11:35) = 父 `#374`(09:01→14:21)。
- 洞在哪:全站(`internal/web`、`internal/stats`)**只有 edit_duplicates.go 提到过这两个字段**,没有任何查询识别它们。
- 收口点(真修的话):`stats.Filter.visitsWhere`(时间线/首页/统计/城市/省份/活动/星期/地点列的公共出口)+ `movementsWhere`(memory 铁律:两套 where 都要加)
+ 三处材料化聚合(`ingest.Import`、`refreshPlaceAgg`/`refreshDatasetAgg`、`backup_merge`),否则列表与计数会不一致。两条路:加派生列(快,要迁移)或 where 里 `NOT EXISTS` 子查询(无迁移,稍慢)。
- 先例:`parse.go` 读真机包时对这两列没有任何处理 ⇒ 导入侧也没过滤,所以这不是「某一处漏判」,是口径从没建过。
### E. 测试/取证手法(这一轮踩到的新坑)
- ⚠️ **相对计数断言会假绿**:`len(pairs) != len(before)+N` 在「把前提删掉导致 before 一起膨胀」的变异下自洽(实测 0 → 822,断言照样过)。
必须补**定义性不变量**:副本的 UUID 一定不在基线里、保留项的 UUID 一定在。变异验证要专门挑这种「让基准值一起变」的改法。
- ⚠️ **核对导出包不能按留档里的 UUID 找行**:站点侧新建的行(副本就是)导出时会被补上本站派生的确定性 UUID(`coreDataUUID`/uuidV5 表名+src_pk),
留档那个 UUID 根本不会写进包。要按「时间 + 地点」或整包条数差分来断言。
- 夹具复用 `newMovementFixture`(真实导入 + 真实基线),造副本时**照抄原件那一行**再换 `Z_PK`/`ZIDENTIFIER_`,列形状才和真实副本一致。
- ⚠️ **`render` 是流式直写**:模板执行到一半出错也只记日志、状态码照样 200、body 截断 ⇒ 断言只看 `code==200` 等于没测。
卡片之后的字(`全部保留` 在 `{{.EditLink}}` 之后)、以及末尾 `</html>` 都要断。
### F. 全量验收的环境前置(2026-09-29 卡了一小时,先做这两件事)
- **先 `VACUUM (FULL, ANALYZE)`**:`rond_fog_test` 跑几十轮全量后死元组能到 **3.3GB**(`entity_raw` 2.2GB),
级联 `DELETE FROM users` 秒变 IO 爬虫 ⇒ 全量跑 30 分钟跑不完(看着像 hang:测试进程 CPU 20 秒纹丝不动)。
清完 **3311MB → 16MB**,全量 **5m49s** 跑完。
- **先确保库里有生效数据集**(`is_active`):`internal/stats` 的 `setup()` 取不到就 `t.Skipf("没有生效数据集")`,
6 个对照用例一起假 skip。本次 `UPDATE datasets SET is_active=(id=1)` 之后才 0 skip。
- ⚠️ 验收跑着时别并发跑定点测试(同一个库);中途 kill 测试进程会留下孤儿 e2e 数据集(fixture 只在正常退出时清理),
下次 `purgeE2EUsers` 删除它时又赶上膨胀 ⇒ 更慢。
- 结果口径:**PASS=305 / FAIL=0 / SKIP=0**(基线 297 + 重复到访 4 个 + 收口 3 个 + 城市聚合 1 个用例);三个提交 `152af81` / `4597047` / `3d03aae` 均已推到 origin/master,推送后复测过。
### G. 已并入 / 子段到访在读的一侧收口(2026-09-29 当天完成)
用户报「时间线上出现重复」(09-04 家 20:21 两条):其中一条的留档带 `ZMERGEDTO`——那是**手机自己标了
「已并入别处」的碎片行**,app 只显示被并入的那条。**不能删**(导出要靠它认这次合并发生过),
所以只在读的一侧收口。判据唯一一份:`stats.SoloVisitSQL(alias)`。
- **收口的落点**(改一处要连带改的清单,漏一个就是「数字自相矛盾」):
`stats.visitConds(off, solo)` ← `visitsWhere` 是 solo 版,覆盖时间线/首页/统计/城市/省份/活动/星期/地点列;
`stats.VisitsByPlace`、`stats.Bookmarked`(这两处自建 where,不走 visitsWhere);`stats.placeCols` 的 v2/v3/v4 子查询;
`handlers_public.go` 的 firstArrivalCities + extremePlace;`handlers_topics.go` 的专题(外层 + 2 处子查询 + 天数);
三处聚合的 `visit_count` / `dwell_minutes`(`ingest` 回填、`refreshPlaceAgg`、`recountPlaces`);`schema.sql` 一段幂等回填。
- ⚠️ **天气与位移不收口**(`solo=false`):那几小时、那段路真实发生过(数据集 9 有 12 条天气、5 条位移挂在被并入的行上)。
`TestWeatherStatsRespectsFilter` / `TestVisitSideFiltersApplyToMovements` 钉的就是这条——一开始我让它俩也继承了判据,两个用例立刻红。
- **导出侧一个字节不动**:rond 只读 `places.last_visit_at`(保持全量),压根不读 visit_count / dwell_minutes;
静态守卫 `TestSoloVisitCondSharedAndOffTheExport` 钉住「export.go / backup_diff.go 不许出现 SoloVisitSQL」,
行为上由 `TestHiddenVisitStaysInExportButLeavesReads` 钉住(标记后导出,包里那行还在、编号与 ZMERGEDTO 都对)。
- 展示侧兜底:`placeCols` 把 first/last_visit_at 包在 `CASE WHEN p.visit_count > 0 THEN ... END` 里,
否则「到访数按访客口径、日期按全量」会凑出「0 次却有最近到访」(数据集 9 实测 4 个地点)。
- ⚠️ **ingest 的回填必须排在 `insertEntityRaw` 之后**:判据要读 entity_raw,留档还没写盘时每一行都被当成独立到访
(实测整整多算一个「已并入」的量)。这个坑是新写的用例抓出来的,不是看出来的。
- 实测(数据集 9):places 到访数合计 **1001 → 937**(= 去掉 64 条),停留 **249769 → 222399 分**。
- 代价(EXPLAIN ANALYZE,时间线那条查询):0.35ms → **3.5ms**(42 次 entity_raw 主键探测),Planning 3.6 → 6.4ms(SQL 文本变长)。
真嫌慢就改派生列(导入/合并时写 `visits.solo`):查询回落到原样,判据更集中,代价是加列 + 重算。
- 边界(都记着,问起来能答):管理端「地点补全」页、`notes` 的备注汇总、`backup_diff/merge` 一律**全量**;
`datasets.visit_count` 是后台口径(全量,与时间线会差 64);城市统计的 `max(p.last_visit_at)` 仍取列值。
### I. 城市聚合(地图城市圈气泡)的既有口径错误(2026-09-29 审阅时发现并修)
- `/api/cities`(`apiCityAgg`)原来把聚合打在 `places ⋈ visits` 上:`sum(p.visit_count)` / `sum(p.dwell_minutes)`
会按该地点的到访条数重复累加、`count(*)` 被当成「地点数」显示。实测最大城市显示
「到访 72661 次 · 568 个地点」,真值 568 次 / 86 个地点(差 128 倍)。
- 修法:改成两级聚合——内层按 `p.id` GROUP BY 出每个地点的 visits/dwell,外层再按城市 `sum/count/min/max`。
顺带补上 `stats.SoloVisitSQL("v")`(与其它访客口径一致)。`ORDER BY 5`(城市到访数)与 Scan 列序不变。
- 回归:`city_agg_test.go`(手写同口径 SQL 对账 + 「地点数不可能多于到访数」的不变量);变异验证:把
`sum(pa.visits)` 改成 `*2` 立刻红。**这个 bug 是既有的,与「已并入」收口无关**,只是同一次审阅里发现。
### M. ⚠️ `ZLOCATION.ZISOCOUNTRYCODE` 保持 NULL,别「美化」成空串(2026-09-30 复查,差点重犯)
- 真机包自己那列会写空串(09-29v2:CN 410 + `''` 23),但**导入侧不接受空串**:09-29 做过真机对照实验
(同一份包、同一台设备,唯一差异这一列)——`''` → 「备份恢复失败」;`NULL` → 能导入(commit `d5e96ed`)。
- 所以「空值画像」判据在这里要留个例外:参照里 0 NULL 而导出里有 NULL **也不算违规**。
同类教训:真机自己写出来的形状不代表它能吃回去。
- 顺带记:2026-09-30 生产导出被拒收这一次,把技能里记的全部判据都跑了一遍仍没定位到形状违规
(打包层 / 骨架 / 空值 / 关系 / 类型 / OPT / UUID / 时间 / 文件头全过);后续结论补在这一节。
### L. 一次「功能对、就是慢」的性能回归与定位手法(2026-09-30)
- 症状:整套验收从 6 分钟变成 20+ 分钟;最慢的 4 个用例(309s / 274s / 226s / 115s,占了全套 55%)都是
「渲染若干页面」的布局用例(`TestWideTableCardsAreFullWidth` / `TestStackedTablesHaveLabels` /
`TestFiltersFoldHook` / `TestVisitorPagesWithRegionFilter`),**不是**夹具/导出类。
- 定位手法(按有效性排序):
1. **写一个临时的「逐页计时探针」**(渲染 7~8 个关键页并打印 ms)——一眼看出是 /(44s)与 /places(70s);
2. **变异对照**:把可疑判据临时改成恒真再跑同一个探针(70s → 0.3s ⇒ 就是它);
3. **CPU profile**:`-cpuprofile` 显示 135 秒里只用了 3 秒 CPU ⇒ 进程在**等**,不是算
(排除模板渲染/Go 逻辑,指向「大量往返 / 逐行取大字段」);
4. 反例:孤立的 `EXPLAIN ANALYZE` **会骗人**(同一条判据 12~38ms),因为它只有 40 行、不是整页的量级。
- 根因:判据 `raw->>'ZMERGEDTO' IS NOT NULL OR raw->>'ZPARENT' IS NOT NULL` 打在 `entity_raw.raw` 上,
而 raw 是**整行 Core Data JSON(TOAST)**——每行探针都要取回+解析。
- 修法(两层):派生列 `visits.is_merged`(读侧只看布尔)+ 部分索引 `idx_entity_raw_visit_flags`
(只索引真带标记的那几十行,给「从留档重算」的写入路径用)。
- 防回归:`page_render_budget_test.go` 给 7 个关键页卡 10 秒上限的**灾难探测器**
(「功能全对、只是慢」这种退化,之前没有任何用例会红——模板测试、快照、静态守卫都照过)。
### K. 「补录行被合并回来」造成的重复(2026-09-30 修,与「已并入」不是一类)
- 现象:生产时间线上成对出现**到达时间、离开时间、停留时长全都一样**的两条(一屏 53 对)。
与 09-04 那种「离开时间不同」的已并入行不同——用 `ZMERGEDTO`/`ZPARENT` 判据一条都收不到
(实测 43 对里 `with_hidden_side = 0`)。
- 成因链:站点**补录**的到访没有 `entity_raw` 留档 ⇒ 没有 UUID;导出时被补上确定性 UUID
`v5("ZVISIT/<src_pk>")` 写进包里,手机于是把它当一条真记录;这份包再合并回站点时,
**站点这侧没有 UUID 可对齐** ⇒ `DiffVisits` 判成「备份新增」又插一条。
实测:43 对里 41 对的副本 UUID 恰好等于 `v5("ZVISIT/配对行 src_pk")`(另 2 对编号被重排过)。
- 修法(**不改导出、不改数据**,只认它):
* `rond.DerivedUUID / DerivedVisitUUID / VisitMatchUUID`(导出侧 `coreDataUUID` 复用同一个函数);
* `DiffVisits` 的 `byUUID` 索引:无留档的行也挂上它的派生 UUID ⇒ 备份那行能对上;
* `backup_merge` 的 `storedByUUID` 同样处理(位移/天气端点映射也走它);
* 「时间 + 地点」兜底配对补 `used` 检查——同一条行现在两个索引里都有,不查会被 classify 两次;
* 卡片新增第二类判定(派生 UUID 命中,**排在「站点侧副本」之前**,证据更强),
清理时把副本名下的位移/天气**改挂**到保留项(补录行自己没有位移,跟着删就白丢里程)。
- 用例:`TestDiffVisitsAlignsSiteCreatedRowByDerivedUUID` + 反例 `...DoesNotAlignOtherDerivedUUID`;
web 端到端 `TestDupCleanMergedBackSiteRow`(造补录行+副本 → 检出 → 清理 → 断言改挂)。
变异验证:去掉改挂语义 / 关掉判定,两条立刻红。
- ⚠️ 测试里造这类数据时,补录行的时间要**避开基线里已有的时刻**,否则会先被「站点侧副本」
判据认走(用例里刻意错开 7 分钟,注释写明了原因)。
### J. 收尾项(同一天,最后一批)
- `scripts/verify.sh`:库 ≥ `VACUUM_MIN_MB`(默认 200MB)时先 `VACUUM (ANALYZE)`。
**只做普通 VACUUM**:`FULL` 要 ACCESS EXCLUSIVE 锁,万一有别的服务连着测试库会把验收整段卡住;
普通版是并发的。实测同一夹具用例 13.6s → 10.2s(200MB → 62MB),主要防的是「上一轮的死元组留到这一轮」
那种 30 分钟爬不完的最坏情况。缩文件还是要手动 `VACUUM (FULL, ANALYZE)`。
- 城市圈的**首末到访**改成按可见到访算(`min/max(v.arrival)` 内层聚合),不取 `places.first/last_visit_at`
(那两列是全量的,会被已并入 / 子段的行推早推晚)。
- ⚠️ **只比对同口径会假绿**:被并入的行大多落在父段区间内,所以「按列值」与「按可见到访」在夹具数据上
结果一样,断言看不出差别。必须**主动把某地点最晚那次到访标成已并入**,再看城市的最近到访有没有跟着退
(实测:仍停在 09-13 而应退到 09-08 ⇒ 抓到)。这条是变异验证抓出来的,不是看出来的。
- ⚠️ **派生列是必须的,不是可选**(2026-09-30 自我更正):我当初按「时间线查询只做 42 次主键探测」判它
「得不偿失」——**只量了单条查询,没量整页**。实际 /places 有「每个地点 × 5 个子查询」,每个子查询都要
取回并解析 TOAST 过的整行 JSON ⇒ **70 秒**才出页面(首页 44 秒)。已改成派生列 `visits.is_merged`
(`RefreshMergedFlagsSQL` 刷、部分索引兜住刷新路径),/places 70s → 0.2s。
教训:**性能判断要在「渲染出来的页面」上做**,孤立的 EXPLAIN(12~38ms)完全看不出来。
- 改指语句批量化仍然不做:一条语句 + `unnest` 会遇到「同一行 ZMERGEDTO 与 ZPARENT 指向两个不同副本」时只改一个键;
地点聚合批量化则会把 `refreshPlaceAgg` 抄成第二份。现在的循环是对的,别为了省几条语句换来边界错误。
- 文档:README 补了「数据编辑页上的重复到访」与「为什么有些到访在页面上看不到」两节,
以及验收脚本清理死元组、`datasets.is_active` 的说明。
### H. 全量验收的环境维护(每轮都要做)
- 一轮全量会让 `rond_fog_test` 涨 **~370MB 死元组**(16MB → 385MB):级联 `DELETE FROM users` 随之变慢,
下一轮就能爬到 20~30 分钟(进程 CPU 长时间不动,看着像 hang)。
- 跑前跑后各来一次 `VACUUM (FULL, ANALYZE)`:16MB 状态 **5m49s** 跑完,385MB 状态 22 分钟还没跑完。
- 顺手确认 `datasets` 里有 `is_active`(`UPDATE datasets SET is_active=(id=1)`),否则 `internal/stats` 6 个用例假 skip。
---
## 三十一、从 MEMORY.md 索引迁出的两段长文(2026-09-28/29 备份合并 + 导出恢复三条铁律)
> 2026-09-30 整理:索引文件(MEMORY.md)注入时被截断,按它自己的规矩把这些长文挪到这里;
> 与「廿一、合并执行」有部分重叠,后续可以合并去重。
## 备份合并(差异 → 执行)(details:2026-09-28)
- 链路:`POST /admin/preview`(`stashPut` 暂存)→ `GET /admin/preview/{token}`(`rond.DiffVisits/Places/Activities`)→ `POST /admin/preview/{token}/merge`(`BuildMergePlan` + `applyMergePlan`,单事务)。合并前必须重算差异(渲染到点击之间库可能已变)。
- 对齐键:到访 = `ZIDENTIFIER_`(库侧从 `entity_raw.raw #>> '{ZIDENTIFIER_,$blob}'` 取),地点 = `PlaceCoordKey`(坐标 6 位小数,**不认名称**),活动 = `ZUID_`。
- ⚠️ **`DiffPlaces` 对「同名同坐标」的重复地点**:2026-09-28 已修——`byCoord`/`byName` 改为**多值索引**(`map[string][]StoredPlace`)+ `take()` 逐条认领未使用的候选。旧实现是单槽位 map,同一坐标 N 条备份地点最多配 2 条 ⇒ 第 3 条永远「备份新增」,**每次合并多插一条重复地点、差异永不归零**(实测「柳州站」3 条 → 稳定每合并一次 +1)。回归用例 `TestDiffPlacesAlignsDuplicateNameCoordGroups`(含「站内真少两条仍报新增 2」反向断言)。
- ⚠️ `visitFieldChanges` 判「活动」:2026-09-28 已改为**按身份(`ZUID_`)比**(`StoredVisit.ActivityUUID` 由 `loadStoredVisits` 多 JOIN 一次 `entity_raw`/ZACTIVITY 取得),两边都缺 UUID 才退回名称。旧实现按名称比 ⇒ 活动一改名,挂在它下面的每条到访都报「冲突」(实测 16 个活动 → 355 条同因冲突),且对到访选「用实机」也改不掉名字(`activity_id` 按 UUID 映射回同一个)⇒ 冲突不收敛。
- ⚠️ **`DiffPlaces` 的「按名称兜底」必须有距离上限**:2026-09-29 已加 `nameMatchMaxKm = 1.0`(`takeByName`)。
名称不是唯一键——连锁店同名是常态(实机里「中国体育彩票」3 条、「长沙南站」2 条、「茶颜悦色」2 条),
没有上限就会把相隔几百公里的两家店配成一对;站长勾「用实机」时站内地点被搬到另一座城市、名下到访集体跳城
(实测把佛山的「中国体育彩票」配给贺州那条,**236.8km**,合并后站内 place 真的搬了)。`geo.Haversine` 返回**公里**,别按米写。
- ⚠️ `visitFieldChanges` 的「地点」字段比的是**坐标键**(`PlaceCoordKey`),**名称相同时必须把坐标印出来**
(`coordSuffix`),否则差异页渲染成「X → X」,站长看不出差在哪、无从选择。
- ⚠️ **导出侧 `ns()` 把空串当空(返回 nil)**:凡「真机从不留空」的列,写完值都要兜底——
`transportPlaceholderColor/Icon`、`activityPlaceholderColor`(2026-09-29 加)就是这么来的。
站内「地点补全」建的活动 `color=''`、`icon=''`(`fillup.go`),图标那侧早有 📍 兜底、颜色漏了,
导出写出 `ZACTIVITY.ZCOLOR_ = NULL` → **生产整包被 rond 拒收**(「备份恢复失败,你的当前数据未被更改」)。
守这个不变量的用例是 `TestExportNeverNullsColumnsTheDeviceAlwaysFills`(基线里 0 NULL 的列,
导出新行也不许 NULL;改了填值逻辑就顺手给它加个场景)。
- 排查「导出被拒收」的最快路径:拿一份参照包(真机原包 / 同一数据集上次的导出)做**逐列空值画像**
—— 参照里 0 NULL、导出里有 NULL 的列就是形状违规。判据必须是「参照里 0 个 NULL」:
`ZLOCATION.ZISOCOUNTRYCODE` 真机自己漏过 1 个 NULL ⇒ 它容忍 NULL,别去"修"。
- ⚠️ 「差异页默认选项是**用实机**」⇒ 站长直接点「合并」= 所有冲突都用实机值覆盖站内
(浏览器提交全部 checked 的 radio)。
写合并测试**必须照这个提交**(从页面抓 `name="(vc_|pc_|ac_)... value="backup"`);只提交 `add_new=1`
走的是「冲突保留站内」,与真实点击行为完全不同。合并后的自检项:**没有任何地点被搬动超过 1km**。
- **地点配对只有一份实现:`rond.MatchPlaces`**(2026-09-29 抽出)。差异判定(`DiffPlaces`)与合并执行
(`web.mergePlaces`)**必须共用它**——两处各配一遍就会漂移:合并侧原来用单槽位 map 自己配,多条同名
备份地点全并到同一行站内地点,到访被挂到错的分店(实测「茶颜悦色」差 8.3km)。两侧的地点查询都要
`ORDER BY id`(候选多于一个时靠顺序定谁配谁)。自检不变量:**合并后没有任何地点被搬动 >1km,
也没有任何到访离它的实机地点 >1km**。
- 计划里不要存「改哪一行」的键:`PlacePatch.CoordKey` / `ActivityPatch.Key` 已删(只写不读,
且容易被误当成查找键)。要找行就靠配对结果。
- 测试手法:包内临时测试 + `httptest.NewServer(srv.Handler())`;**必须 `CheckRedirect: http.ErrUseLastResponse`**(站点 303,自动跟随会读不到 Location);验证码答案读 `captcha.Store.Answer(id)`,不必 OCR。管理员默认口令 `rond-admin`。
## 导出/恢复链路的三条铁律(2026-09-29 全部踩过一遍)
1. **app 只能靠 Z_PK 解引用地点与活动**(它们没有 UUID)⇒ 导出的编号必须与**数据集的基线**同一套。
已实现 `internal/rond/export_align.go`(按身份对齐:地点坐标/名称、活动到访 UUID、标签方式名称、
位移天气按所属到访;匹配不上且原编号没被占用的行保持原编号,保证往返不变)。
⚠️ **对齐的目标是数据集的基线** ⇒ 基线必须是**手机包**,否则对到的是「站点自己的编号那套」。
做法:把手机导出作为新数据集上传 → 把旧站点导出包当备份合并进去 → 从它导出。
2. **rond 的导入是「整库替换」**:导入前**必须先清空 app 数据**,否则会出现成对重复(站长实测)。
自检:app 的到访数 ≈ 包内到访数(减去 `ZMERGEDTO`/`ZPARENT` 那些已并入的隐藏记录);
地点数 ≈ 包内「有到访的地点」数。
3. **站点数据里存在「同时间同地点、UUID 不同」的历史副本**(合并按 UUID 认人 ⇒ 它们被判成新增,
导出后就是成对重复)。清理用 `tools/dedup_site_visits.py`(**以手机包为参照**,默认只预演,
`--apply` 才删;会连带清位移/天气/留档,并把指向副本的 `ZMERGEDTO`/`ZPARENT` 改指到保留项)。
⚠️ 参照必须是手机包:只按 (时间,地点) 判会把手机包自身合法的成对条目一起删掉。
---
## 三十二、从 MEMORY.md 索引压缩出来的各节正文(2026-09-30 整理)
> 索引(MEMORY.md)注入时会被截断,超过 900 字的小节只留前两条要点,正文挪到这里。
> 小节标题就是原来的索引标题,按它检索即可。
### 口径 / 404 / 验收(details 十一~十三、廿七、廿八E)
- ⚠️ **跑全量验收前两件事**:① `VACUUM (FULL, ANALYZE)`——测试库死元组涨到 3.3GB 后级联 DELETE 变 IO 爬虫,30 分钟跑不完(看着像 hang);清完 5m49s。② 库内必须有 `is_active` 数据集,否则 `internal/stats` 6 个用例**假 skip**。跑着时别并发跑定点测试。`details 三十F`
- 区域判定**两端各过一遍再 AND**(同 `regionSQL`);`movementsWhere` 也必须两端各判,不能用 `EXISTS(... IN (from,to) ...)`;比较列一律 `COALESCE(col,'')=$n`。`region_consistency_test.go`
- 「累计停留」=Σ 各次到访时长(`LEAST(COALESCE(duration_min,0),10080)`);类型计数用分面 `ActivityCounts` 且清空 `ActivityIDs`。
- `Filter.Verdict` 全域一致:条件下沉 `visitsWhere`;`hasVisitConds()` 加 `|| f.Verdict != 0`。`stats/verdict_scope_test.go`
- 404 别混:`placeHiddenPage`=403、`notFoundPage`/`guardPage`=404;瓦片/图片故意纯文本 404。
- 验收四件套 `ROND_TEST_CONF`+`ROND_SRC`+`FOG_SRC`+`ROND_DSN`(须指 `rond_fog_test`);`-count=1 -v ./...` 要 **0 skip**。两个假绿坑:缺 `ROND_SRC` 静默 Skip;共用库 `Import` 让 `internal/stats` 假 skip ⇒ 用专用库,`t.Cleanup` 在 `defer` **之后**才跑。
- 静态守卫走 `moduleRoot(t)`/`pkgFile(t,name)`;`-race` 需 mingw 在 PATH。纪律:只走 IPv4、303 不跟随;**同一文件绝不并行发多条 Edit**;新回归测试做**变异验证**。
- 📐 覆盖率**必须 `-coverpkg=./internal/...`**(`-cover` 只算本包 ⇒ rond/geo 假报 0%,真值 82.2%/92.9%);解析 profile 按 `(文件,行段)` 取 **max 别求和**;**死代码删掉、别给它补测试**。关键页冒烟 `scripts/smoke.py`(`verify.sh --smoke`/`--all`):关键区块不能用 `nav.nav`、用 `.card` 不用 `div.card`。`details 廿九`