Files
rond-with-you/.workbuddy/memory/MEMORY-details.md
T

1249 lines
135 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 rond.exe .`(**只构建到仓库根,`bin/` 已废弃、2026-10-09 删除**)→ 停掉占 8080 的进程 → `./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 廿九`
- ⚠️ **验收要在「安静环境」跑,别和别的东西并发**:`db.Open` 的 ping 是**写死 10s**(`db.go:52`),
库被压着时 `TestOpenCreatesMissingDatabase` 会以「建库后的首次连接超时」失败 ——
2026-10-09 实测:和 CDP 审计并发时 10.02s 超时挂掉,把审计停掉**单独跑 4.1s 就过**(环境抖动,不是代码问题)。
另外这条测试失败时会把临时库 `rond_test_autocreate_*` **留在库里**(清库的 defer 在 `Open` **成功之后**才注册),
跑完记得手动 `DROP DATABASE` 清掉,别当成「库被占用 / 测试有 bug」。
同理:**变异实验也别和验收并发**(2026-10-09 实锤:变异期间正好被验收抓到,那轮结果直接作废)。
- ⚠️ **变异验证「内嵌文件」(`templates/`、`static/`)之后必须 `go clean -cache` 再跑验收**。
`//go:embed` 的内容虽然参与构建,但「改坏 → 还原」之后构建缓存会继续给出**变异期间编译的那份内嵌内容**:
2026-10-09 实测同一条守卫 `go test ./internal/web/` 绿、`go test ./...` 红(同一份磁盘文件、同一个测试);
`go clean -testcache` **不管用**(它只清测试结果),`go clean -cache` 之后立刻一致。
判据:两种调用方式结果不一致时,先清构建缓存再下结论。**只变异 `.go` 文件没有这个问题**(Go 源码参与缓存键)。
---
## 三十三、「换基线」(底座换代)做进后台(2026-09-30)
### 是什么 / 为什么要
导出的实现是「**底座为底 + 只覆盖托管表**」(见 `internal/rond/export.go` 头部注释):
Core Data 元数据、索引、持久化历史、`ZRAWVISIT` 采样点、`ZTRIP*` 全从底座原样带出来。
所以**底座必须是手机导出的包**。底座跟着数据集走,手机重新导出后想换代,
以前唯一办法是整包重新导入成新数据集(站点补录的行、主题、备注全要重收拾)。
新动作:**同一份数据的底座换代,站点数据一行不动**。
### 实现要点(`internal/web/baseline.go` + `handlers_export.go` + `templates/admin/baseline.html`)
- 顺序不可挪:写临时底座 → ①重合判据 → ②干跑导出 → 旧底座改名留档 → `rename` 就位 → 记档 → prune。
干跑(`rond.ExportRondbackup(..., tmp, io.Discard)`)把「底座缺表/缺实体编号」这类失败挡在替换之前,
否则会出现「底座换了、才发现导不出、原来那份已经不知去哪」。
- ①重合判据(唯一能挡住「换错包」的闸):分母是**站点侧带 UUID 的到访**(站点补录行本来就没有 UUID,
不计入);`minOverlapVisits=20` 以下不判;`matched==0` ⇒ 硬拒(**force 也不给过**);
`matched*2 < withUUID` ⇒ 要显式确认。理由:干跑只验结构,一份不相干的包照样导得出,
但换下去站点每行都在新底座里认不出身份 ⇒ 全按新行插到最大编号之后 ⇒ 真机上「这些到访全变成新的」。
- ⚠️ **留档名必须防重名**:`fmt.Sprintf("%s.bak-%d", dst, time.Now().Unix())` 是秒级;
「替换后紧接着回滚」两个操作落在同一秒时,回滚那次 `rename` 会把刚生成的留档**覆盖掉**,
于是回滚拿到的还是新底座——实测 `TestRollbackBaselineRestoresPrevious` 就是这么红的。
改用 `baselineBackupName()`(`20060102-150405` + 冲突自增)。
- 记档三列 `datasets.baseline_name/baseline_sha256/baseline_at`。
**`ensureBaseline` 必须先看记档**:只认 `datasets.sha256`(= 最初那份包)的话,
底座一丢就静默重建回旧包,上一次换基线白做。共用实现 `ensureBaselineTo(ctx, dsID, dst)`;
「重建」入口写临时文件再 `rename`,不做破坏性 `os.Remove`(重建失败不能让数据集没底座)。
- 上传件要落 `uploads`(`stashUpload`,按 sha 去重后复用已有那份),否则底座丢了按记档找不回包。
⚠️ 算摘要的落盘必须在 `uploads` **之外**(`ingest.TempDir` 下),否则 `findUploadBySha`
会把自己认成「已存在」。
- 判据没过不当场报错了事:包已经收好了,页面用 `?pending=<sha>&pname=&pm=&pw=` 渲染一个
「我确认,强制替换」按钮(不必重传几十 MB);`POST /admin/baseline/confirm` 走同一条路。
- 导出内容在换底座前后**逐条不变**(`ZVISIT` 的 UUID 集合):编号会换、身份不该换,
这条是回归用例最锋利的断言(谁在底座里没对上,就会多出一条派生 UUID 的行)。
- ⚠️ **导出条数 ≠ 站点条数**,别把差值当 bug:`tableWriter.finish()` 有**两段**——先删
「基线里有、PG 已没有」的行(`known` − `seen`),再把「导入时进不了 PG 但留档还在」的行
(`w.skipped` × `raws`)按原样补回。实测「站内 823 / 导出 824」就是这个第二段。
同一段代码也决定了换基线的语义:**新底座里站点没有的行会被清掉**(进 `known` 没进 `seen`),
所以换到「更新」的手机包**不会**把手机上的新记录带进导出包——想带得先用「分析差异」合并。
页面文案与提示都写明了这点,用例 `TestSwapBaselineDoesNotCarryRowsTheSiteDoesNotHave` 守着它。
- 路由:`GET /admin/baseline`、`POST /admin/baseline`(上传并换)、`/confirm`、`/rollback`、`/rebuild`。
### 用例 `internal/web/baseline_swap_test.go`(7 条)
成功路径(文件真换/旧底座留档 sha 一致/记档写入/站点计数与样本时间不变/导出 UUID 集合一致)、
记档优先重建(**带反证**:清掉记档后同一条路必须走不通,防止用例被别的兜底弄绿)、
部分重合需确认、一条都对不上 force 也不给过、非包文件第一步就挡住、回滚还原且当前那份也留档、
页面渲染到底(尾部 `</html>`)。
---
## 三十四、图片挂载恢复 + `place_images` 摘掉外键(2026-09-30)
### 症状与根因(用户报「之前上传的照片找不到了」)
`place_images.dataset_id` 原本带 `ON DELETE CASCADE`,而**读取按 `place_key` 坐标键、跨数据集**。
两套口径打架:删掉旧数据集时图片**行**被级联删掉,**文件却留在 `<data_dir>/media/<数据集>/…`**。
实测(本机 `rond_with_you`):`datasets` 只剩 155 号,`place_images` 0 行,盘上 4 个 `.webp` 好端端躺着。
同一批附属数据里,备注(17 行)、结论(3+1 行)因为早就迁到坐标键,**毫发无损**——只有图片倒霉。
### 恢复动作
- 入口:后台首页「图片挂载恢复 → 扫描并恢复」⇒ `POST /admin/images/recover`(`internal/web/image_recovery.go`)。
只**插入**行:不删文件、不动已有行、可反复点(按 `object_key` 判重)。`apply=false` 是预演。
- 认地点的两处来源:① 数据集还在库里 ⇒ 查它的 `places`(最准);② 数据集已删 ⇒ 读它留下的
**底座库** `baselines/<id>.sqlite`(换基线留下的 `.bak-*` 也算,新的优先)→ 还原「地点号 → 坐标」。
⚠️ **底座文件是救图的唯一依据**:清掉 baselines 就永远认不出这些图属于哪个地点(只报「认不出」)。
- 对象键**两种布局都要认**(历史原因):
`<dataset>/<YYYYMM>/<srcPK>-<内容哈希>.webp`(现在)与 `<dataset>/<srcPK>/<内容哈希>.webp`(早期,目录名就是地点号)。
判据不能只看「中间那层是不是 6 位数字」——地点号也可能刚好 6 位:先看文件名有没有 `<数字>-` 前缀,再退到目录名。
- `width/height` 无从得知、留 0:页面只用 `URL`(模板里没有宽高),`bytes` 用文件大小。
### 防复发
- `schema.sql`:`CREATE TABLE place_images` **去掉外键**,另加幂等迁移
`ALTER TABLE place_images DROP CONSTRAINT IF EXISTS place_images_dataset_id_fkey;`。
`dataset_id` 退化成「出处」这一列——图片认地点本来就不看它。
- `adminPlaceImageDelete` 不再按 `dataset_id` 过滤(数据集删了也得能删图),SELECT/DELETE 都只按 `id`。
- 后台「删除数据集」的二次确认文案改成实话:删的是到访/地点/位移/原始行留档 + **专题**,
备注/结论/图片按坐标键保留。
- ⚠️ **同类陷阱还剩一个**:`topics` 仍带 CASCADE。专题内容是「时间 + 区域」派生的,本可以跨数据集活,
但列表/详情都按 `dataset_id` 过滤,去掉外键就得同时改成用户级查询——**未做,待用户拍板**。
已丢的那批专题(本机 0 行)没有本地恢复来源。
### ⚠️ 测试隔离(这批用例踩出来的)
图片「按坐标键跨数据集读」+「按 object_key 判重」,都**不看数据集** ⇒ 用例之间会互相污染,两处都要隔离:
1. **盘上文件**:共用 media 目录里任何一张「坐标相同」的图都会被算成该地点的图
⇒ 实测「应当 2 张、实际 10 张」(上一轮跑剩的文件全在那张坐标上)。
2. **库里的行**:上一轮留下的 `object_key` 会让新文件被当成「已经在库里」直接跳过
⇒ 实测 `Already:1`,**兜底分支根本没被执行到**,而用例只看「挂回 1 张」差点就放过(假绿)。
做法:`isolateImageState(t, f, lat, lon)` —— 换空 `DataDir`(`t.TempDir()`)+ 按 `place_key`
清历史行(前置清一次、`t.Cleanup` 再清一次)。改名用的文件名再带 `UnixNano` 后缀避免撞键。
夹具 `newMovementFixture` 也补了收尾:显式 `DELETE FROM place_images WHERE dataset_id=$1` + 删 media 子目录
(**不再级联,必须自己清**),注册得最晚 ⇒ 在删用户之前跑。
### 用例 `internal/web/image_recovery_test.go`(4 条)
行没了文件还在 ⇒ 按坐标挂回(含预演不写库、幂等、挂回后**页面口径** `placeImagesOf` 看得见)、
早期键布局与 `parseImageKey` 的表驱动、**数据集已删 ⇒ 靠底座库认回**(并在报告里说明来源)、
删数据集不再带走图片行与文件。
### 三十四补记:对象存储(R2)侧 + 专题也一起改了(2026-09-30 23:20)
**R2 上的图怎么救**:行没了就只剩桶里有那些键,不列桶无从得知它们叫什么。
`internal/media` 加了 `ListObjects(ctx, prefix)`(ListObjectsV2 + SigV4 + 自动翻页 + XML 解析):
- 请求形状是**路径式、只到桶**:`GET <endpoint>/<bucket>?list-type=2&prefix=…`,
查询串必须**先百分号编码再签名**(`canonicalQuery` 按原样取 `RawQuery`,两边得是同一份字节,
prefix 里的 `/` 要写成 `%2F`)。翻页靠 `continuation-token`。
- ⚠️ R2 的 API Token 默认只给对象读写,**没有 `s3:ListBucket` 就列不出来**(403 AccessDenied)。
列桶失败**不能让整个恢复失败**:本地那半照救,原因作为 `report.Warnings` 带出去,
页面上用红色提示条显示(否则站长会以为恢复完了)。
- 候选对象 = 本地目录遍历 ∪ 桶里 `.webp`,同键以本地为准;行的 `storage` 按「对象实际在哪」写,
`bytes` 用桶里报的 Size。⚠️ 挂回了却没配 `s3_public_base` ⇒ 行有了、页面一片空白
(`placeImagesOf` 会跳过取不到址的行)——这个比「没恢复」更难懂,也要写进 warning。
**专题也改成归人不归数据集**(用户拍板「一并做」):
- `topics.dataset_id` 同样带 CASCADE ⇒ 删数据集带走专题,而专题**只存在这张表里**,没有恢复来源。
- 改法:外键摘掉(`ALTER TABLE topics DROP CONSTRAINT IF EXISTS topics_dataset_id_fkey`),
所有查询从 `dataset_id` 换成 `user_id`(`loadTopics`、`topicPage`、保存/删除/开关、`pageBase.HasTopics`)。
**专题内容仍照当前生效数据集算**(`topicContent` 的 dataset_id 不动)——变的是「这份定义归谁」。
- 附带:`Dataset` 补了 `UserID`(`datasetFor` 多查一列),前台与后台都能直接按属主定位用户级数据
(专题/备注/结论都是这一类);`topicMutate` 不再要求有生效数据集。
- 用例盯住迁移本身:直接查 `pg_constraint` 里 `topics_dataset_id_fkey` 必须不存在
(只看「删数据集没删专题」可能被别的原因弄绿)。
**不影响导出**:这轮动的 `place_images` / `topics` / `Dataset.UserID` 都不参与 `.rondbackup` 导出
(导出只读 datasets/places/visits/movements/weather/entity_raw)。
### ⚠️ 三十四再补:坐标来源不能「第一个能读的就算了」(2026-09-30 23:50,用户实测)
第一版上线后用戶反馈:**扫描 40 个对象,只挂回 7 张,认不出 33 张**,
报错是「数据集 #1 里没有编号 389 的地点」——即来源能读出来,但**不是上传那会儿的编号空间**。
根因:`srcPKCoords` 是「第一个能读到的来源就返回」,而 **换基线会覆盖 `baselines/<id>.sqlite`**,
当前那份未必是当年上传时用的编号;旧编号往往还躺在同一份底座的 `.bak` 留档里。
改成三件事:
1. **所有来源全部读进来合并**(先读的优先):库里 `places` → `entity_raw` 里 ZLOCATION 的整行留档
(站点上删掉的地点,places 没了、留档可能还在)→ `baselines/<id>.sqlite` 及其**每一份 `.bak-*`**。
读不动的文件跳过换下一个,不再「第一份成功就返回」。
2. **同一个编号保留多组候选坐标**,逐个试到能在当前数据集里对上为止:
站点纠过偏、或不同来源算出的坐标不一样,只认第一组会白白认不出。
3. **认不出要能排查**:`unresolvedImage` 带上 `Dataset / SrcPK / Note`,理由里写明
「已查来源:库里 places / 原始行留档 / 155.sqlite / …」与试过的坐标;
提示条按**数据集归并**上报(「数据集 #1:31 张(编号 389、390…),已查来源 …」),
不再刷「另有 31 张」;完整清单(前 30 条)进服务端日志。
教训(可复用):**「能读出数据」不等于「读对了那一版」**。凡是从「可能被覆盖过的留档」里还原编号/坐标,
都要把历史版本一起纳入,并且在失败信息里列出已查过的来源 —— 否则用户只能看到「认不出」,无从下手。
### 三十五、防复发:归属口径做成门禁(2026-10-01,用户「我只想避免类似的事件再次发生」)
用户放弃继续救那批旧图,要求的是**结构性防复发**。所以把这次的教训固化成三件东西:
1. **归属铁律(写进 schema.sql 抬头 + README)**
判断标准只有一句:**这份记录能不能从备份重新导入?**
- 能(places/visits/movements/weather/activities/tags/entity_raw)⇒ 可以挂
`REFERENCES datasets(id) ON DELETE CASCADE`,删数据集时跟着走是对的。
- 不能(place_images/place_notes/place_votes/topics 等站点自建数据)⇒
**只留 dataset_id 当出处、不许挂外键**。挂在备份里没有的记录上,删数据集=永久丢失。
2. **两道门禁(`internal/web/dataset_ownership_test.go`)**
- 运行时 `TestOnlyBackupDerivedTablesCascadeFromDatasets`:查 `pg_constraint`,
凡挂 `datasets` 的 FK 其表名必须在白名单里;**反向也查**(白名单里的表不许被顺手摘掉,
否则删数据集会留成片孤儿行、或外键变 RESTRICT 之后干脆删不动)。
- 静态 `TestSchemaDeclaresNoDatasetFKForUserData`:直接扫 `schema.sql` 的 `CREATE TABLE`。
只看运行时有盲点——**新建的库走 CREATE TABLE**,开发库可能压根没跑过那次迁移。
- 新加一张带 dataset_id 的表时,写 CREATE TABLE 的那一刻就会被拦住。
3. **体检常驻(真正的预防)**:`imageHealth` 每次进后台都算
「库里 N 张 / 本地盘上 M 个 / 落单 K 个」,落单 > 0 直接在首页报警。
那次事故的本质不是「删数据集」本身,而是**行与对象悄悄对不上、没人发现**,
等照片看不见了才追——让它当场可见,同样的漂移下次立刻亮
(`TestAdminHomeReportsOrphanImages` 守着这个提示)。
只扫本地盘(毫秒级);对象存储要列一次桶,放在「扫描并恢复」动作里,不在每次进后台的开销里。
顺带订正:后台「删除数据集」的二次确认文案在专题改完之后已经说过时话(说专题会一起删),改成实话。
### 三十五补记:变异验证又抓出一个真缺口(2026-10-01)
给两道门禁做**变异验证**时(按项目纪律,新回归测试必须证明它会响):
- 静态门禁:把 `topics` 的 CASCADE 外键补回去 ⇒ 立刻红,还原后绿 ✓
(顺带发现扫描必须先跳过 `--` 注释行:规矩的说明文字里就写着 `REFERENCES datasets(id)`,
不跳过会把自己写的文档当成违规误报。)
- 运行时门禁:手动给 `topics` 加上外键后**没响**——因为夹具每次都跑 `db.Migrate`,
迁移自己就把它摘了。⇒ 运行时判据单独看不够:它永远不会在不健康的库上报警。
真正的自愈靠迁移,于是补了 `TestMigrationDropsStaleDatasetForeignKeys`:
把事故原样的外键**注入回去**再跑一次迁移,断言它们没了(并先确认变异真的生效,防假绿)。
而这条自愈用例又抓出一个**此前漏掉的缺口**:`place_notes` / `place_votes` 上的同类外键
(更早那次改坐标键时摘的,老库/生产库上未必摘干净)**迁移并不会摘**——
原来只写了 `place_images` 与 `topics` 两句 DROP。已补成四张自建数据表全覆盖
(含 `place_visitor_votes` 兜底)。**部署出去的库要能自愈,DROP 必须覆盖全部同类表,不是只覆盖这次动过的那两张。**
---
## 三十六、统计页:站长结论与访客点赞拆成两块(2026-10-01)
用户问「数据统计处的推荐地点和踩雷地点只针对站长吗?我觉得应该分开,用户的点赞点踩单独做一块」。
查下来他看到的没错:`/stats` 那张卡**列表全是站长结论**(`verdictPlaces` = `place_votes`),
访客票只在副标题里露了个总数(`👍 N · 👎 M`)——既看不出哪几个地点被顶上去,也容易被当成两者合并的榜。
改动(`internal/votes` + `internal/web` + `templates/stats.html`):
- 新增 `votes.TopVisitorVoted(ctx, datasetID, vote, limit)`:按访客票数列地点榜。
`HAVING count(*) FILTER (WHERE vv.vote=$2) > 0` —— **只算至少有一票的那个方向**,
否则 0 票的地点会把榜塞满;同票数按 `visit_count DESC, p.id` 排,保证刷新之间顺序不跳。
票按坐标键挂,所以要 join 回当前数据集的地点才定位得到(与 `Tallies`/`verdictPlaces` 同一口径)。
- 「推荐 & 踩雷(我的结论)」:只讲站长,副标题只留 `推荐 N · 踩雷 M`,标题与列表都改成「我」的口吻。
- 新增「访客点赞」:副标题总票数(复用 `voteTotals` 的 `VoteUp/VoteDown`),两列分别列
👍/👎 票最多的地点(带票数),空态「还没有访客点赞 / 点踩」。
- 加载拆成**两个 pageJob**(`verdicts` / `visitorvotes`):两者互不依赖,串在一个任务里只会白等。
- 两块共用同一个可见性开关(`IsAdmin || Settings.MarksPublic`),与地点页的投票区一致。
⚠️ 用例隔离(又一次同一个坑):**两张票表都按坐标键读,与数据集无关**,
`place_visitor_votes` 还没有 `user_id`、不随删用户清理 ⇒ 上一轮跑剩的票会被算进来、票数越堆越多。
新增 `isolateVoteState`(按 place_key 清两张表,前置 + `t.Cleanup` 各一次)。
断言手法:按卡片标题切出两段 HTML(`htmlSection`),再断言「A 只在站长块、B 只在访客块」——
比数总数更能证明两块真的没串台。
顺带订正 README:那里还写着站长结论「按 `src_pk` 存」,其实早就是**坐标键**了。
---
## 三十七、措辞要分「站长视角」与「访客视角」(2026-10-01)
用户发现:**未登录也显示「推荐 & 踩雷(我的结论)」**,访客没法解读「谁的我的」。
顺着做了一次系统排查(`grep "[我你咱]"` 扫访客可见模板 + `static/js`),
发现同一个毛病还有三处,都是**写模板时站在站长视角下笔、而那一块对访客公开**:
| 位置 | 原文 | 改成 |
|---|---|---|
| `stats.html` 标题/副标题/两列小标题 | 我的结论 / 只统计**你自己**… / 我推荐的地点 / 我踩雷的地点 | `{{if .IsAdmin}}我的…{{else}}站长…{{end}}`(**按身份分支**) |
| `stats.html` 访客块副标题 | 与「我的结论」互不影响:**你标你的**,访客投访客的 | 「与上面的站长结论互不影响:各存各的表,谁也不会覆盖谁」 |
| `home.html` 标签卡标题 | 我的标签(同级卡片都是中性的:足迹地图/城市足迹/天气足迹…) | 足迹标签 |
| `report.html` | 这一年里,**你**第一次踏足的这些城市(年报是站长的回顾) | 这一年里第一次踏足的这些城市 |
**已经写对的(照它学)**:`place.html` 的投票区早就是 `{{if .IsAdmin}}` 分支
(站长「我的结论」/ 访客「大家怎么说」),`rond-map.js` 的气泡同理;
`layout.html`/`map.html` 的「含家/工作」chip 与一串「只有你自己能看到」都在 `{{if .IsAdmin}}` 里,没问题。
**门禁用例** `TestVisitorPagesUseThirdPersonCopy`:以**访客身份**(`context.Background()`,ctx 里没有 user)
取回 `/stats`、`/`、`/report`,逐个禁用词断言(`visitorCopyForbidden`),
同时断言站长那份**仍旧说「我的」**(别把站长自己的视角也改成第三人称)。
前置要把 `settings.site.marks_public` 打开(访客才看得到那一块),用完还原。
⚠️ 变异验证踩的坑(老坑,载入史册):**直接 `go test -run …` 会因为缺 `ROND_TEST_CONF`/`ROND_SRC`
静默 skip 并报 ok** —— 我第一次做变异就是这么「假绿」的(0.17s 就 ok,明显不对)。
变异验证必须走 `./scripts/verify.sh --pkg … --run …`(它会拦住 SKIP)。
## 三十八、时间框(flatpickr allowInput)的手输体验(2026-10-08)
**症状**:手输 `2026/6/6` 时,年份位要打到 `202666` 才进月份位。
**根因(实测 flatpickr 4.6.13,两条)**:
1. flatpickr 把 `<input type=datetime-local>` **就地改成 `type="text"`**
(`altInput` 默认 false、不新建节点、`sameNode=true`)⇒ 原生分段编辑器被替换掉。
2. `allowInput` 是**整串解析**语义,不是逐段消费。实测 `inst.parseDate`:
`2026`→2026-**1**月、`20266`→同上、**`202666`→2026-**6**月**(就是用户看到的)、
`2026666`→**2031**-6月、`20260606`→**2030**-**12**月(静默错解不报错)。
**落地**:`internal/web/static/js/datetime-input.js`(纯逻辑,无依赖,附 `.test.js`
34 条断言 `node` 直跑)。三处页面共用 `rondDatetime.attachPicker(el)`,
脚本挂在 `layout.html` 的 `{{if .Flatpickr}}` 块内、flatpickr **之后**。
- 严格标准格式(用户拍板):年 4 位、月/日/时/分各 2 位,**不自动补零、不前滚**。
- 段满即补分隔符 ⇒ 敲完第 4 位年份立刻 `2026-` 且光标跳月份位。
- 解析器纯数字只收 4/6/8/12/14 位(5/7/9/10/11 位拒:说不清是「日=30」还是「时06分30」)。
- 越界(月 66、日 45)**原样保留交解析器报错,绝不静默改值**。
### 三个必踩的实现坑
1. **挪光标必须「同步 + 微任务」两次**。flatpickr 自己监听 `input`/`setDate`,
会在我们之后改 `selectionStart`;只同步设一次会被它抢走 ⇒ 下次按键插到分隔符
**前面**(实测退化成 `2026-6`,月份位错位一格)。
2. **逐位取数必须跳过分隔符**。框里是 `2026-0` 时数字是 `20260` 不是 `2020`;
直接 `replace(/[-T:]/g,'')` 会让整体位次错一格。
3. **改 `el.value` 就必须同步 `_rondLaidOut`**,否则下一轮拿旧串去排,越排越乱;
reject 分支则**必须 return 且一个字符都不动**。
### CDP 验输入行为时的坑
- 派发字符必须 `keyDown` **带 `text`/`unmodifiedText`**,否则 Chrome 不执行默认动作。
- 挂载后 input 已改成 `type=text` ⇒ `querySelectorAll('input[type=datetime-local]')`
一律数到 **0**,要用 `input.flatpickr-input`。
- 自定义 `parseDate` 返回 `false` 时 flatpickr 走
`errorHandler("Invalid date provided: …")` 并**清空 `selectedDates`**,
所以「被拒绝」在页面上表现为**框被清空**,不是静默不动。
- 折叠卡片 / 未开 `details` 里的输入框量到 0 个 —— 先展开再量。
### 本机环境(验后台页必须自己签 cookie)
`site_cdp.py` 的会话签名算法**已过期**(还在用「口令摘要前 16 位」)。
现行 `internal/auth/auth.go` 是 `hmac(secret, payload+"\x00"+**完整**password_hash)`,
且 **secret 是裸字符串不是 hex 解码**(`server.go:118`:`[]byte(cfg.SecretKey)`)。
过期脚本签的 cookie 一律 303 到登录页。另:Git Bash 传 `--base http://…`
会被吃掉 `//`(报 `Cannot navigate to invalid URL`),须加 `MSYS_NO_PATHCONV=1`。
## 三十九、页面弹窗:不用 window.prompt(2026-10-08)
**症状**:`window.prompt` 会把提示文本与默认值写进地址栏
(Chrome 拼成 `当前页?新的活动名称=吃饭` 并留进历史)——刷新即丢、分享带原文。
`confirm` / `alert` **没有**这个毛病,只有 `prompt` 有。
**替换物**:`static/js/prompt-box.js` 的 `rondPrompt.ask(title, value, {required, readonly})`
⇒ `Promise<string|null>`。用原生 `<dialog>.showModal()`:居中、带 `::backdrop` 遮罩、
Esc 关闭、焦点自动进输入框、其余部分自动 inert。旧浏览器降级成页面顶部一条
(`.prompt-fallback`)。样式在 `app.css` 的 `dialog.prompt-box` / `.prompt-fallback`。
**已换的两处**:`report.html` 复制链接的兜底(`readonly: true`,让用户全选 Ctrl+C)、
`admin/edit.html` 活动改名(`required: true`,空值视作取消;监听器改成 `async`)。
脚本在 `layout.html` **无条件**引入(不挂 `.Flatpickr` 条件,报告页也要用)。
**门禁** `internal/web/no_prompt_test.go`:静态扫全部模板禁止 `prompt(` 调用
(注释与字符串先 strip 掉,否则注释里写「为什么不用 prompt」会误判);
另一条钉住 `prompt-box.js` 与 CSS 都在(否则模板能过、点击时才 TypeError)。
变异验证:把 report.html 改回 `window.prompt` ⇒ FAIL,命中 2 处。
### 验这个的坑(无头 Chrome)
⚠️ **`window.prompt` 与 `showModal()` 打开的模态 dialog 都会阻塞 CDP 求值**:
在同一个 `Runtime.evaluate` 里既触发弹窗又读值必然 `WebSocketTimeoutException`;
`Runtime.evaluate` 里 `.click()` 触发 prompt 同理卡死。
可行做法:**用 `Input.dispatchMouseEvent` 派发真实点击**(不占 CDP 通道),
或「先触发 → `time.sleep` → 截图 → 用 Input 派 Esc 关闭 → 再求值」。
(`Page.addScriptToEvaluateOnNewDocument` 传空 source 也会把 CDP 卡住,别用。)
移动端 390×844 实测:弹层宽 356、按钮高 44(触控下限)、输入框 16px(防 iOS 聚焦缩放)、
横向溢出 0。输入框字号必须 ≥16px,这条已在 CSS 里注明原因。
## 四十、统计脚本做成后台开关(2026-10-08)
原来只能改 `conf/app.ini` 的 `track_script`(要重启),现在后台「站点设置」可开关+ 填写。
**形状**:`Settings.TrackEnabled bool` + `Settings.TrackScript string`(JSON 存settings 表)。
**默认关闭**(统计脚本会把访问信息发到第三方,不该默认开)。
`pageBase` 里:开关开 → 用后台填的内容,为空则回落到 ini 的 `track_script`
(保留「改 ini 就能挂上」的旧用法,但**开关始终是唯一总闸**);
开关关 → `p.TrackScript` 为空,layout 的 `{{if and .TrackScript …}}` 自然不输出。
后台页与登录页仍由 `ne .Nav "admin"` 挡着(已实测)。
⚠️ **加 setting 字段要改三处**:`Default()`(显式写出默认值,别指望零值恰好对)、
`Load()` 的逐项覆盖(`Save()` 用整体 JSON,新增字段自动持久化)、
`settingsSubmit()` 的表单读取。`Load()` 里布尔项一律用 `present[key]` 判:
历史配置没有这个键时若直接套零值,语义会悄悄反转。
### `cleanTrackScript` 的判据(这里踩过两次,2026-10-09 重写)
⚠️ **不能用「出现 `</script` 就拒」当规则** —— 百度统计、Umami 贴进来的就是完整
HTML,一行里必然带 `</script>`,一拒就把正常用法挡死了(我第一版就是这么写的,
被自己的测试当场抓住)。
现行判据两条:
1. **开闭 `<script>` 标签必须严格配平(`open != shut` 就拒)**。少一个 `</script>`
会提前闭合、后面内容当正文渲染;多一个 `<script>` 会把页面剩下的收尾整段吞进脚本。
⚠️ 只拒 `shut > open` 是不够的(初版就是),末尾未闭合的开标签会被放行 ——
`<script>x</script><script>y` 实测通过,已改成严格配平。
2. **`src=` 只在 `<script …>` 开标签内部找**,只放行 `http(s)://` 与 `/` 开头的相对路径。
⚠️ 初版对**整段**做 `strings.Index(low, "src=")`,脚本体里一句 `img.src=u` 就被命中、
整段被判危险而清空 —— 而页面照样提示「设置已保存」,站长完全看不出脚本没挂上。
实测:`<script>var i=new Image();i.src=u;</script>` 旧版返回 `""`,现在原样放行。
超 4096 截断。
**收尾要显式**:函数签名改成 `(script, warn string)`,说明码
(`truncated` / `unbalanced` / `badsrc`)经 `?warn=` 跳到设置页、由 `trackWarnMessage`
翻成人话显示。用**固定码**而不是回显查询串原文(避免反射内容)。
判为危险时**整段不采纳、库里原脚本与开关都不动**(把站长手上好用的脚本一并清掉更糟)。
门禁 `TestCleanTrackScript` 逐条钉边界 + **定义性不变量**:
采纳了非空结果时,里面的开闭标签数必定相等(不是「不得含 `</script`」——整段贴进来的
本来就带闭合标签)。
## 四十一、2026-10-09 三处修复:导航口径 / 启动自检 / 二进制只留根目录
### 1. 导航的页面显隐**只管访客**(`layout.html`)
`guardPage`(`server.go`)拦的只是访客:`userFrom(ctx)==nil && !sets.HasPage(nav)` → 404;
站长始终能访问全部公开页。但导航写的是 `{{if .Settings.HasPage "map"}}`,**没有 IsAdmin 分支**
⇒ 页面开关一关,站长自己也只剩「主页」一个入口 —— 能访问却点不到。
已改成 `{{if or .IsAdmin (.Settings.HasPage "x")}}`(map/places/timeline/stats/report 五处)。
门禁 `nav_visibility_test.go`:直接执行 `nav` 模板,钉「关掉开关后站长仍有 5 个入口、
访客一个都没有、全开放时访客也看得到」三向不变量(做过变异验证)。
⚠️ **访客侧「只有主页」是配置出来的预期状态,别去"修"数据**:库里 `settings.pages = null`
(= 站长把公开页面全关了),访客 `/map /places /timeline /stats /report` 一律 404。
2026-10-09 用户明确确认:**访客能看哪些页就是按后台配的范围公开,这样才对**。
要改就改后台的「公开页面」开关,不要直接动 `settings` 行。
### 2. 启动自检 `smokeRender`(`template_smoke.go`)
⚠️ **「模板能解析」不等于「渲染得完」**:字段不存在、nil 解引用都只在**执行期**暴露,
而 `render` 只把错误写进日志 ⇒ 线上是「HTTP 200 + 渲染到一半的半张页面」。
2026-10-09 的 /admin/settings 截断就是这么坏的(提交里同时改了 `setting.Settings` 和
`settings.html`,跑的是改动前编译的二进制,debug 下模板走磁盘热加载 → 新模板 + 旧结构体)。
做法:`pageProbe`(每个页面一份「最小可用数据」)+ `smokeRender` 在 `New()` 里把
**所有页面各渲染一遍**,任一失败就 `New` 返回 error → main `log.Fatalf` **拒绝启动**。
- `pageProbe` 的键必须与 `pageFiles` **双向一致**:缺项按失败处理(新加页面必须补探针)。
- 探针值只求「类型对、不炸」——页面本来就得能渲染空数据。
- `smokeOne` 里 `recover()` 兜住方法里的 panic,报出「哪一页坏了」而不是崩栈。
- 门禁 `template_smoke_test.go`:一个跑全量自检;一个**变异验证**(把 `admin/settings`
的探针换成字段更少的 `Page`,必须报错且点名该页)。
- 实测:往 `settings.html` 插一个不存在的字段 → 启动即报
`初始化服务失败: 模板自检未通过…admin/settings: …can't evaluate field …`,退出码 1。
### 3. 二进制只留在仓库根(`bin/` 已删,2026-10-09)
`go build -o rond.exe .` 一个位置。`bin/` 整套(15 个 exe、412MB)已删;
`.gitignore` 的 `/bin` 保留作为兜底。`scripts/build-linux.sh` 照旧只产
`dist/rond-linux-amd64`。相关技能(`rond-web-verify`)与 details 十八已同步改掉
`bin/rond.exe` 的写法。
## 四十二、前端设计令牌(A/B 批次,2026-10-09)
完整方案与实测数据见仓库根 `frontend-plan-2026-10-09.md`。这里只记**以后改前端必须守的规矩**。
### 字阶 / 圆角 / 阴影一律走令牌,别写 px
- 文本 6 档:`--fs-xs/sm/md/lg/xl/2xl` = 11 / 13 / 15 / 21 / 26 / 34px。
- `--fs-control: 16px`:**窄屏表单控件必须 ≥16px**(iOS 聚焦 <16px 会把整页放大)。这是功能约束,
所以它独立于字阶;窄屏媒体查询里用 `--field-fs` 覆盖。**别把它并进字阶。**
- `--glyph-*` 6 档:✕ / 箭头 / 空态图标 / 表情的字号。**字形不是文字层级**,按控件尺寸定;
硬塞进字阶(比如把 22px 的 ✕ 提到 26px)只会把按钮撑坏。
- 圆角 `--r-xs/sm/md/lg` = 3 / 6 / 8 / 12px。`50%` 与 `999px` 是形状(圆 / 胶囊),不算圆角档。
- 阴影 `--sh-1/2/3`:⚠️ **暗色模式必须整套覆盖**(原来只覆盖一个 `--shadow`,拆档后漏一个就会
在暗色下用错档)。
- 间距 `--sp-1..5` 本批**只迁移了卡片内边距这类主节奏**,其余没做全量替换。
- `--card-pad` 与折叠卡片标题行的负 margin 绑在一起(`calc(var(--card-pad) * -1)`),改一个即改两处。
**门禁** `TestNoBareFontSizePx`:静态扫 app.css 与全部模板,出现 `font-size: <数字>px` 就红。
### 卡片三档
`.card--hero`(首屏地图 / 主数区)/ `.card`(默认)/ `.card--quiet`(只读说明)。
⚠️ quiet **别拿 `--bg-2` 当底色**:它与页面底 `--bg`(#f6f7f8) 只差 4/255,实测看不出边界,
卡片会像「破了个洞」而不是「轻了一档」。要「页面底色 + 保留细边 + 无阴影」。
首屏主数用 `.kpi-lead`(不是 `.kpi`):主数 + 一行上下文 + 左侧 3px 主色条。
### ⚠️ 改根字号会带动 vendor 库与「没设字号的默认标签」
`html, body` 的 font-size 一改,至少三处会跟着漂:
1. **Leaflet**:vendor 里 `.leaflet-container { font-size: 0.75rem }` 是**相对根字号**的,
根从 14 → 13px 后地图署名/比例尺掉到 **9.8px**、弹窗正文也跟着缩。
app.css 里已钉死(容器 `--fs-sm`、署名/比例尺 `--fs-xs`)。
2. **`h1..h4`**:app.css 原来只给它们设了字重与 margin、**没设字号**,于是靠浏览器默认的
2em / 1.5em / 1.17em —— 同样跟着根字号漂。已显式给出 h1/h2/h3。
3. **`<b> / <strong>`**:浏览器默认 bold(700),比全站最高层级(标题 600)还重。已钉成 600。
判据:**量 DOM 的 (字号,字重) 组合**。只看截图不会发现这三处(页面「看着正常」,
只是有点不齐)。其它 rem/em 的 vendor 样式同理要查。
## 四十三、图表 / 类型色 / 窄屏导航(C、D 批次,2026-10-09)
### 图表(手写 .cols / .bars,不引图表库)
- `.cols` 有基线(`border-bottom`)与 `padding-top: 16px` —— 后者是给峰值柱顶上那个数字(`.pv`)留的位置,
改高度时别把 padding 弄丢(`height` 走 border-box,内容区才是柱子的 100% 基准)。
- 峰值柱:模板里 `{{if eq .Count $.MaxMonthly}} peak{{end}}`,柱顶标次数;副标题里就不再重复写「最高 N 次」。
- `.cols-band` 是「6 小时一段」的标尺,**必须用 24 列 grid**(不是 `flex: 6`),否则段边界与柱子对不齐。
它取代了原来那行只有一个数字的坐标轴。
- 首页的月度图峰值靠 `HomeData.MaxMonthly`(与统计页同口径 `maxOf`),别只改一边。
### 类型色:只在 CSS 变量里用,别当文字色
- 模板用 `csscolor` / `csssoft`(`colorof` 已删)。
⚠️ 这两个函数**必须返回 `template.CSS`**:`html/template` 的 CSS 过滤器不认识 `var()`,
返回普通 string 会被换成 **`ZgotmplZ`** —— 页面上所有圆点当场变透明,而且不报错、模板测试照过(踩过)。
守卫 `TestCSSColorSurvivesTemplateSanitizer`。
- 但挂了 `template.CSS` 就等于关掉那道过滤,所以**数据侧色值必须先过严格校验**(只有 `#` + 6 位十六进制放行),
否则 `#"><img…` 能直接逃出 style 属性。
- ⚠️ **这套色连图形对象 3:1 都到不了**:白底上 yellow 1.87、mint 2.12、orange 2.20、green 2.22、
cyan 2.54、teal 2.57。所以它们**只能当身份标记(圆点 / 柱条),绝不能当文字色或承载唯一信息的图形**。
实测过、别再被「做成彩色标签更好看」带走。
- 浅色那 6 个色**不要改暗**:它们是手机端与地图上的同一套身份色,改暗会破坏对应关系。
- 地图那份调色板(`rond-map.js` 的 `APPLE`)**故意独立**:瓦片在暗色下仍是浅色,标记要用浅色底那套值。
- 暗色只提亮了 `--cat-indigo`(2.90 → 3.55:1),其余一律不动。
### 窄屏导航(≤620px)
- 链接本体是 `{{define "navitems"}}`,渲染两遍:桌面 `.nav-links` 平铺、窄屏 `.nav-menu`(原生 `<details>`,
不靠 JS 也能开合;JS 只补点外面 / Esc / 点链接后收起)。
守卫 `TestNarrowNavCollapsesToMenu` 钉「CSS 两半 + 模板两个类 + 渲染两遍」的成对关系。
- ⚠️ 顶栏 `--z-nav: 1200`,**必须高于 `--z-map-overlay`(1100)**:菜单是 `.nav` 的子元素、
自己抬不动层叠上下文,不抬就会被地图的筛选面板盖住(`/map` 上用 `elementFromPoint` 实测验证过)。
## 四十四、日期控件:原生 `type=date` 也不能用(2026-10-09)
⚠️ **`<input type="date">` 与 `datetime-local` 一样不可靠。** Chrome 的年段允许超过 4 位数字
(年份上限 275760),所以敲 `20260606` 会被切成 **年=202606 + 月=06** —— zh-CN 下显示 `202606/06`,
而且 `el.value` 是**空**(非法)⇒ 筛选条件静默丢掉。原生分段状态读不到、
`setSelectionRange` 对 date 类输入直接抛,所以「修原生」这条路不通,只能换控件。
**规矩**:
- 日期字段一律走 `rondDatetime.attachAll(root)`(在 `datetime-input.js` 里):它按 `el.type`
自动分派只日期(输出 `Y-m-d`)或带时间(输出 `Y-m-dTH:i`)。
⚠️ **不要手写 `querySelector('input[type=...]')`** —— 2026-10-09 的 bug 就是
「只挂了 datetime-local,后来加的 type=date 没人管」。
- flatpickr 与 `datetime-input.js` **都无条件加载**(js+css+l10n 约 18KB gzip):
筛选区在公共页上也有日期框,而且「按页开开关」已经被证明会漏,所以干脆**不设开关**
(`Page.Flatpickr` 字段也删了)。用户明确接受这点资源增量(手机上有日历弹层比省这 18KB 重要)。
挂载要在 body 末尾**立刻**跑一次,别只等 `DOMContentLoaded`,否则先露出原生框再变文本框。
- 门禁 `TestDateInputsGoThroughAttachAll`:模板里出现 `input[type=date]` /
`input[type=datetime-local]` 这种选择器即红;并钉住「layout 无条件引入 flatpickr + datetime-input +
attachAll」、以及「不许再出现 {{if .Flatpickr}} 这种开关」。
- JS 单测:`layoutTo(spec, raw)` 是纯函数,逐键模拟敲 `20260606` 必须得 `2026-06-06`
(`datetime-input.test.js`,node 直跑,45 条)。
**同批修的两处观感问题**(都属于「只有量才看得见」):
- `.grid` 必须 `align-items: start`。grid 默认 `stretch`,同一行里内容少的那张卡会被拉到与最高的
一样高,卡片内部留一大片空白(/stats 的「类型分布」1411px vs「一天中的分布」256px)。
- `.temp-strip` 的柱子窄屏固定 36px + 横滚;**宽屏要 `flex: 1 1 36px; max-width: 96px` 并把轨道加高**。
原来固定 36×84,10 个月只占 1042px 里的 396px,右边 600 多像素全空 —— 看着就是「图表很小」。
### 日历弹层的两处坑(2026-10-10)
- **中文文案要在共享入口设一次**:以前是各页 `whenReady` 里自己调 `flatpickr.localize(l10ns.zh)`,
公共页开始加载 flatpickr 之后就漏了 —— 日历是英文的(Sun/Mon/January…),跟中文界面明显割裂。
现在放在 `attachPicker` 里(`localizeOnce()`,全局只调一次)。
- ⚠️ **站点全局的 `input/select` 样式会扫进日历内部**:那条规则带 `width:100%`、内边距与边框,
落到日历里就是「月份下拉被撑满整行、压在年份输入上」(实测重叠成一团),时间格也变成方框输入框。
修法是**在全局规则里排除** `input.numInput` 与 `select.flatpickr-monthDropdown-months`
(实测日历内部只有这 4 个控件:`select.flatpickr-monthDropdown-months` + `input.numInput.cur-year`
/ `.flatpickr-hour` / `.flatpickr-minute`)。
⚠️ **不要**试图在 `.flatpickr-calendar input{...}` 里对冲 —— 那条全局选择器带 10 个 `:not([type=…])`,
特异性 (0,10,1),普通对冲压不过(实测年份框仍是站点样式)。
`input:focus` 的焦点环同理要一起排除,否则年份框上会套一圈主色光圈。
- **体量差太多的卡片别并排**:类型分布(60 多行)与一天中的分布(24 根柱)并排时,右侧下面必然
一大片空白 —— `align-items: start` 只能消掉**卡内**的空白,消不掉并排本身的空。各占整行才对
(列表横铺、24 根柱也终于有宽度)。`align-items: start` 仍然保留:其它同行卡片也会体量不等。
## 四十五、专题的迷雾按区域收口(2026-10-10)
**现象**:用户建了专题问「迷雾好像无法根据选择显示范围?」。查下来是**实现缺口**,不是配置问题 ——
迷雾图层一直是全局的,专题的区域(中心+半径)只用来 `fitCircle` 定视图,跟迷雾完全无关。
管理页那句「区域内的世界迷雾由地图按视野自动呈现,**不需要单独圈选**」纯属**文档与实现不符**,
把「没做」写成了「不用做」。用户选的口径:**按专题区域收口**(不按时间收口)。
**实现**(`rond-map.js` + `topic.html`):`opts.fogArea = {lat, lon, radiusKm}` 由 `topic.html`
在 `.Topic.HasArea` 时下发;脚本给迷雾**单独建一个 pane** 再对该 pane 做 `clip-path: circle()`。
⚠️ 三个必踩的坑:
- **不能裁默认 `tilePane`** —— 底图瓦片也在那里面,裁下去地图跟着一起被挖空。所以
`map.createPane('rondFog')`(`zIndex=250`:底图 200 之上、覆盖物 400 之下,`pointerEvents='none'`),
迷雾 tileLayer 走 `pane: fogPane ? 'rondFog' : 'tilePane'`。
- **圆心必须 `project()` 到底图坐标系**(跟 `fitCircle` 一样),直接拿经纬度当容器坐标会整体偏出几百米。
半径用「同经度上偏移 radiusKm/111320 纬度」再量容器像素距离,随缩放自然变化。
- 🔴 **注册点必须排在 `applyFit()` 之后**。`latLngToContainerPoint()` 在视图未定时会抛
「Set map center and zoom first.」—— 实测放早了:pane 建好了、**迷雾瓦片一张都不出来**
(那行异常把后面所有图层添加一起打断,且不报错给用户,页面看着只是「迷雾没了」)。
正确写法:`applyFit(); if (applyFogClip) { map.on('move zoom viewreset resize', applyFogClip); applyFogClip(); }`
- 迷雾是**累积快照、没有时间维度** ⇒ 只按区域收口,不按专题的时间范围收口(管理页文案已写明)。
- 老浏览器不认 `clip-path` 只是「不裁剪」(退回全局迷雾),不会报错。
- 门禁 `TestTopicFogIsClippedToArea`:钉住两端 —— `topic.html` 得发 `fogArea:`、`rond-map.js` 得认
`opts.fogArea`/`createPane('rondFog')`/`clipPath`,且注册点下标 > `var applyFit = function` 的下标
(防的就是上面那个「放早了整片迷雾消失」)。
- 实测:临时专题(中心 24.698/108.075、半径 3km)→ `clip = circle(173px at 529px 260px)`,
反解半径正好 **3000 m**,`setZoom(9)` 后仍正确;`/map`、`/stats` 不受影响(它们没有 `fogArea`)。
- ⚠️ 用户自己的专题 `id=15`「2026年国庆」`radius_km=0` ⇒ 收口不生效,要在后台把中心与半径填上。
### 怎么证「裁剪真的生效」(2026-10-10 实测方法)
`clip-path` 读得到 ≠ 画出来生效,而且**目测会骗人**:截图上那片「灰色」可能是底图不是迷雾。
可靠判据是一组**像素对比**,四张图同一个视图、同一个(临时)专题:
| 图 | 迷雾瓦片 | 裁剪 |
|---|---|---|
| A | 显示 | 有 |
| B | 显示 | 无(`pane.style.clipPath='none'`)|
| D | 隐藏(`img.style.display='none'`)| 有 |
- **迷雾掩码** = `|A−D| > 阈值`(裁剪态实际画出来的迷雾)、`|B−D|`(无裁剪态)。
- 结论(半径 1km 那张,阈值 60):裁剪态 **83,185** 个迷雾像素、**圆外 0 个**(最远半径 462.4 / 圆 462.1);
无裁剪态 222,510 个、**62.3% 溢出圆外**(最远 1,117.8)。⇒ 裁剪精准,边界正好落位。
- 想知道**裁剪圆到底画在哪**,别用红色探针(底图有红路/红 POI 会污染掩码)——
在 pane 里塞一个 `background:#ff00ff` 的巨型子元素(子元素一并被裁剪),品红掩码即裁剪区域,
`填满率=π r² 反解`能同时校出圆心与半径。实测圆心/半径与 CSS 标称差 < 1px。
- 🔴 **图像坐标不能用元素坐标直接换算**:`site_cdp.py --clip` 会把裁切框每边外扩 `pad=10` CSS px
(×`--scale`),图像原点在元素左上角**左上方 20px**。差这一步会得到「圆整体偏 (18.5,19)px」的
假 bug,还会把边界抗锯齿当成「圆外有 1830 个迷雾像素」。正确做法:**在同一张图里量一个已知参照定标**
(品红探针就是为此)。已写进技能 `rond-web-verify` 的「四个必踩的坑」第 4 条。
## 四十六、专题显示区:一组圆 + 地图点选(2026-10-12)
**语义变了**:专题的「区域」从**一个圆**变成**一组圆**(并集——落在任一圆内即算在区域内)。
起因是用户提的两点:不该只有一个圆;应该在**地图上点选**而不是手填经纬度和半径。
(原管理页那句「不需要单独圈选」是文档与实现不一致,见四十五。)
### 存储:`topics.areas JSONB`,不单开表
`areas` 是 `[{lat, lon, radiusKm}, ...]`,坐标 **GCJ-02**(地点过滤是拿它跟
`places.lat/lon` 直接比球面距离的)。
为什么用 jsonb 而不是一张 `topic_areas` 表:这些圆是专题定义的组成部分、与专题一起
读写、**没有独立生命周期**;单开表还要过一遍「能不能从备份重导」的归属口径、改
`dataset_ownership_test.go` 的表白名单、注意迁移块必须排在建表之后 —— jsonb 一次全绕开。
- 存量回填在 schema.sql 里:`UPDATE topics SET areas = jsonb_build_array(...) WHERE radius_km > 0 AND areas = '[]'`。
- ⚠️ `center_lat/center_lon/radius_km` **已废弃但保留**,写入时镜像第 1 个圆:回滚旧二进制时
退化成「第一个圆」总比「没有区域」强。判据是三列的注释,别当成还有用的列。
- ⚠️ **出库值必须是 `[]` 不是 `null`**:列上有 NOT NULL,而 `json.Marshal(nil slice)` 恰好给
`"null"`。用 `topicAreasColumn()` 收敛这一处。
- `Topic.AreaList()` 做归一:`Areas` 为空时退回老的三列 ⇒ 未回填的老行与**测试里直接构造
Topic 的写法**都继续工作(`topic_stats_test.go` 就是这么写的)。`HasArea()`/`AreasJSON()`
都走它,保证「HasArea 为真」与「脚本拿得到圆」永远是同一份判据。
### 查询:N 个圆的 OR
`topicAreaCond` 每个圆占 **3 个占位符**(纬度、经度、半径),条件用 ` AND (... OR ...)`。
⚠️ 占位符纪律照旧(写错就是「专题内容全变 0」那种难查的事故)。判据写成**定义性不变量**:
本段引用的 `$n` 必须**恰好**是 `[prefix+1, len(args)]` 这个连续区间,且最大值 == `len(args)`
(列表页那条 UNION ALL 是靠 `len(args)` 自己算编号的,错位就靠这个抓)。注意断言的是
「本段自己那 3n 个」,前缀那几个由查询别处引用,**不该**出现在区域条件里。
### 贴合:`fitCircle` 已删除,统一 `fitBBox`
多圆之后「圆心 + 半径」表达不了,留着 `fitCircle` 就是两套并行的贴合口径。`fitParams`
改为返回所有圆的并集外接矩形(`topicAreasBounds`,高纬处夹一下余弦)——
单圆时外接矩形正是那个圆的外接正方形,与原路径等价。
### 前台:`clip-path: path()`(circle() 只表达得了一个圆)
- 每个圆一段圆弧子路径、**同绕向(sweep=0)** ⇒ nonzero 填充规则下多段自然取并集,
重叠处**不会**互相挖洞。绕向写反的话重叠区会被抵消成一个洞。
- 拼路径抽成独立文件 `static/js/fog-clip.js`(`rond-map.js` 是个大 IIFE,函数取不出来,
抽出来才能跑 node 单测);⚠️ layout 里**必须排在 rond-map.js 之前**加载。
- 给迷雾单独 pane 再裁它(不能裁 `tilePane`,底图在里面);注册时机仍要在 `applyFit()`
之后(视图未定时 `latLngToContainerPoint()` 会抛错并把后续图层添加一起打断,见四十五)。
- `topic.html` 的 `fogAreas` **无条件下发**(空数组 = 不限区域),别包在 `{{if}}` 里 ——
条件分支是「有区域忘了新增分支」这类漏传的温床。
- ⚠️ **CSSOM 会把 `clip-path` 规范化**:`style.clipPath` 读回来会被补空格
(`path("M 405 296 a 87 87 …")`),而且空格位置不固定(可能出现 `296 a87`)、
第一个子路径尾部还可能多一个空格。任何「解析回几何」的代码必须每处分隔都用
`\s*`/`\s+` 容忍 —— 写死就等着被咬(本轮连错两版正则)。
### 前端单测接进 `verify.sh`
`internal/web/static/js/*.test.js` 以前只靠人记得手动跑 = 等于没有。现在挂在
「第 1.5 段」,且**找不到 node 直接失败而不是跳过** —— 跳过会让验收看着全绿、
实际一行前端脚本都没验。
### 验证方法:自校准(别再手算元素坐标)
`site_cdp.py --clip` 会把裁切框每边外扩 `pad=10` CSS px(×`--scale`),所以
「CSS 容器坐标 → 图像像素」不能直接换算。做法是:先拍一张**只画一个圆**的品红探针图,
用已知圆心/半径反解出 `scale` 与 `offset`(实测 2.0 / ~19.2),再用它给别处定标。
两张图反解出的 scale/offset 必须互相吻合,否则说明取景变了、比较无意义。
本轮实测(两圆,重叠):A 独有点只有 A 亮、B 独有点只有 B 亮、**重叠中心两圆都亮且
并集也亮(无洞)**,整体集合差异 0.013%;迷雾掩码 86,776 px 按并集解析式判**圆外 0 个**,
两个圆内分别 52,609 / 46,757 px ⇒ 第二个圆真的被认了。
### 后台编辑器:地图点选(2026-10-12 当天补完)
三个手填输入框(中心纬度/经度/半径)换成**地图 + 圆列表**(`static/js/topic-areas.js`:
点地图加圆、拖圆心移动、拖圆边改半径、列表里可改半径/定位/删除/清空)。改动立刻写进
隐藏字段 `areas`(JSON) 与 `crs`,随专题表单一起提交。复用 `/admin/fog-mask` 那套约定
(`/api/map-config` 取底图、底图坐标系随表单带上、后端换算 GCJ-02)。
地图初始视野取生效数据集的地点包围盒(`placesBounds`),一上来就落在自己的地界上。
⚠️ **三条容易再踩的**:
- 🔴 **`/admin/topics` 必须 `p.HasMap = true`**(`topicsAdminPage`)。漏了的表现极隐蔽:
脚本加载了、`rondTopicAreas` 也在,就是 `window.L` 永远 undefined,`init` 静默返回、
**控制台不报错**、页面看着像「什么都没发生」。守卫 `TestTopicsAdminPageLoadsLeaflet`
(按函数体截取后断言含 `HasMap = true`)。
- 🔴 **`areas` 的初值要渲染进隐藏字段**(`value="{{.Form.AreasJSON}}"`),JS 也从隐藏字段
读初值而不是另传一份:脚本没加载上时提交的仍是服务端渲染的原值,否则**一次保存就把
已有显示区静默清空**。守卫 `TestTopicAdminUsesMapEditorNotCoordInputs` 钉住初值来源、
挂点 id 齐全、以及「三个旧输入框必须没了」(留着就是第二套只写第 1 个圆的入口)。
- **上限两边各写一份**(JS `MAX_AREAS/MIN_KM/MAX_KM` vs Go `maxTopicAreas` 等),
守卫 `TestTopicAreaLimitsMatchBetweenGoAndJS` 直接比对数字;前端还据此**禁用提交**。
不一致的表现是「前端放行、后端拒绝,用户看到红字却不知道改哪里」。
小坑:半径输入框得套一层**定宽容器**(`.area-km-box`)——站点全局 input 规则是
`width:100%` 且带 10 个 `:not()`(特异性 (0,11,1)),直接给 input 写宽度压不过。
⚠️ **`<input type="number">` 的 `step` 是相对 `min` 递增的**,不是「`step` 的整数倍」:
`min=0.05` + `step=0.5` 的合法值只有 0.05/0.55/1.05…,用户填 3 或 3.5 会被 Chrome 判非法、
提交时弹「请输入有效值。两个最接近的有效值分别为 2.55 和 3.05」。**这是浏览器原生提示,
JS 与后端都拦不到**(我们的校验根本没机会说话)——只能在源头避免。
⇒ 要么 `step="any"`(交给 JS/后端校验),要么让 `min` 是 `step` 的整数倍。
`min`/`max` 可以留着(真实边界 + spinner 范围)。守卫 `TestNumberInputStepNeverMismatchesMin`
扫**所有**模板的数字框(不是只盯出问题那个),JS 动态建的另有
`TestTopicAreaRadiusInputAllowsAnyStep`。
`topic-areas.js` 只把**纯函数**暴露给 node 单测,另加最小测试钩子 `api._map`
供无头验证(同 `window.rondMap._state` 惯例)—— 不留把手就只能真拿鼠标点地图。
### gofmt / 行尾(同一天的附带结论)
`gofmt -l` 报「整文件重写」有两层原因:① Go 1.19 起 gofmt 会重排**文档注释**里的缩进列表
(`// ① …` 要改成 tab 缩进块);② 工作区 CRLF 而 `.gitattributes` 声明 `eol=lf`。
`gofmt -w` 归一后 `git diff` **没有额外噪声** ⇒ **blob 本来就是 LF,CRLF 只是检出产物**,
而不是仓库内容问题(之前担心的「模板 CRLF 冲突」可以放下了)。