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