1249 lines
135 KiB
Markdown
1249 lines
135 KiB
Markdown
# 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;`)。
|
||
- **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 冲突」可以放下了)。
|