Files
rond-with-you/.workbuddy/memory/2026-09-19.md
T
tamakyi 3f1b737a78 记忆:把 .workbuddy/memory 纳入版本控制
用户要求这份记忆跟着仓库走,而不是只留在本机。

.gitignore 里挡路的是 `.workbuddy/`。改成反忽略六行,并且**只放行 .md**:
别用 `!.workbuddy/memory/**`——它会把 memory 下任何文件都放行,本项目真发生过
`web.test.exe` 混进暂存。复核用 `git check-ignore -q --no-index` 逐条打靶:
memory/*.png、memory/sub/x.md、memory/MEMORY.md.bak、plans/a.md 全 IGNORED,
17 个 .md 全 included;`git add -An .workbuddy/` 恰好 16 个文件。
其余 .workbuddy 内容(计划、本地设置、临时产物)继续忽略。记忆里没有真凭据
(唯一命中是 2026-09-14.md 里的 `password=rond-admin`,那是项目默认口令)。

顺带做两件整理:

1. 取证了注入上限。以前按「字节数」判断 MEMORY.md 是否超限是错的:
   WorkBuddy 的 `getWorkingMemoryContent()` 用 `MAX_MEMORY_CHARS = 1e4` 比 JS
   `content.length`,即**字符数**(中文 1 字算 1);超了 substring 截断、尾部静默丢失。
   本 details 与每日日志都不参与注入,只有 MEMORY.md 受约束。
   现已从 6604 字符收到上限的 66%,并把这条上限写进索引抬头。
2. 把「备份合并」「导出/恢复三条铁律」两段长文迁进 MEMORY-details.md 的卅一/卅二节,
   索引只留关键词指针。行尾统一为 LF(仓库 .gitattributes 是 `* text=auto eol=lf`)。
2026-09-30 18:31:08 +08:00

510 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-09-19
## 真机反馈「移动端还是要左右滑」:容器查询是错的,改回媒体查询(阈值 800)
站长 00:09 反馈:在本地环境用移动端测试,还是得左右滑动,否则只能看到一部分信息。
### 根因:我上一轮把堆叠判据换成 @container 是个错误决定
无头 Chrome(新版)里容器查询工作正常,390 下表格都堆叠了;但**容器查询要 iOS 16 / Chrome 105
起才支持**,不支持的浏览器会把整块 `@container` 规则当不认识的东西**整块丢弃** ⇒ 表格完全不堆叠、
8 列要横滑 328~388px。站长的手机浏览器大概率就落在这一档,于是「还是得左右滑、只能看到一部分」。
(顺带:媒体查询 620 那个旧版本同样有漏 —— 视口 621~792 的卡片既放不下表格又没窄到 620 以下。)
### 改法
`app.css` 末尾:`@container (max-width: 699px)` + `.tw{container-type:inline-size}` →
**`@media (max-width: 800px)`**。800 是从「卡片能不能放下表格」换算来的:卡片内容宽 ≈ 视口 − 93,
最宽的表 min-width 700 ⇒ 视口 793 以上才放得下,取 800 留几像素余量(注释里写清了换算依据)。
`wide_table_layout_test.go` 的 CSS 断言同步改成:**每条 tb-stack 规则都必须落在 max-width ≥ 792
的 @media 里**(容器查询会被判为「不在任何 @media 里」,退回 620 会被判「阈值太小」)。
### 实测
- 形态切换点:360/390/620/800 → 堆叠;801/900/1440 → 表格。**全部横滑 0**(/places、/stats、/report)。
- **禁用容器查询的环境下依然堆叠**(注入脚本删 CSSContainerRule:删到 0 条、横滑仍 0)⇒ 旧手机不会再退化。
- 两次变异验证:阈值退回 620 → FAIL(「视口 621~792 的卡片放不下表格」);
改回 @container → FAIL(「不在任何 @media 里」)。
- 四轮全站审计(390 站长 / 390 访客 / 1440 / 320)与改动前**逐值一致**(溢出全 0、0 JS 报错)。
- 两段式全量:纯净包 PASS → 11 包 ok、0 FAIL。
### 移动端还剩哪三处能左右滑(普查全站所有 overflow-x:auto 的元素)
| 位置 | 需滑 | 性质 |
| --- | --- | --- |
| `.nav-links`(每个页面顶部) | 107~137px | 7 个导航项在 390 里放不下(内容 321 / 可视 214),`overflow-x:auto` + 隐藏滚动条 |
| `.heat`(首页「活跃日历」) | 513px | 有意的横向滚动(一周 53 格) |
| `.temp-strip`(统计页「天气与气温」逐月气温条) | 32px | 有意的横向滚动 |
表格已全部不滑。导航那处每个页面都有、且用户看不出「还能滑」,是下一个候选(方案:换行两行 /
「更多」菜单 / 保持滑动但加渐隐提示),等站长定。
### 教训
**"更精确"的技术特性要先看兼容性代价**:容器查询在无头 Chrome 里完美,我却没评估旧浏览器会
整块失效——这是把「自己环境的验证通过」当成了「用户环境可用」。凡是写 CSS/JS 判据,
先问一句「不支持这个特性时会发生什么」,退化路径比精确性更重要。
## 站长补充定位:「后台数据编辑页面,比如地点列表等地方」
我把问题定位到 `/admin/edit`:**三张表(地点列表 / 到访记录 / 行程记录)从来没有打过 `tb-stack`**,
390 下 5 列要横滑 316px。之所以上两轮全站审计没发现,是因为它们默认折叠在 `data-card` 卡片里、
探针量到的是 `display:none` —— **默认态测不出隐藏内容的横滑**,这是漏页的真正原因。
### 改动
- `admin/edit.html`:三张表加 `tb-stack`,非首格补 `data-label`(城市/坐标/到访/地点/类型/备注/路线/方式/里程);
末尾那个只放「编辑」的操作格**不加标签**(表头本来就是空的 `<th></th>`,硬加会把按钮/表单挤窄)。
- 三处行内编辑表单的 `style="min-width:280/300/320px"` → `style="--efw:280/300/320px"`;
CSS 里 `.inline-edit form { min-width: var(--efw, 280px) }`,放不下宽表时再放开成 0。
用 CSS 变量而不是 `!important`:inline 的 min-width 压不过后面的媒体查询覆盖,变量没有优先级问题。
- **操作格的 `white-space:nowrap` 从 inline style 挪成类 `.ops`**(`table.tb td.ops`):`white-space`
会继承到格内的编辑表单,堆叠后「已选:#403 2026-09-13 19:53 · 某某地」这一行不换行、把卡片顶出 44px。
同样的 nowrap 还出现在 `admin/index.html` 与 `admin/topics.html`,一并改成类。
- 堆叠态放开:`td:not([data-label])` 与 `td.ops` 都 `white-space: normal`。
### 测试(重点:让「漏页」不再可能)
- `tb_stack_test.go` targets 加 `/admin/edit`(12 张表);**标签豁免规则改成跟着表头走**:
表头为空的列不需要 `data-label`(正好覆盖操作格,规则自洽)。
- `wide_table_layout_test.go` targets 加 `/admin/edit`、`/admin/photos`;div 栈同时跟踪 `.tw`,
新增判据 **「凡被 .tw 包住的表格都必须带 tb-stack」**(照片表按既有方案单独处理,显式豁免)。
这条是通用的:以后任何新页面漏加,测试直接报出来。
### 实测
- 展开三张表后:360/390/620/801/1440 横滑**全部 0**(改前 390 是 316)。
- 每张表各展开一行编辑表单:28 个可见控件逐个 `elementFromPoint` 点测 **0 个点不到**,
表单不超出卡片(390 下 302px 放进 324px);0 JS 报错。桌面 1440 仍是表格、`td.ops` 仍 nowrap。
- 后台各页(edit / photos / admin / topics)展开后全站可横滑元素只剩 `.nav-links`(导航,107px)。
- 两段式全量:纯净包 PASS → 11 包 ok、0 FAIL(`tb_stack_test` 12 张表、`wide_table_layout_test` 12 张表 13 条规则)。
- 四轮审计与上一版**逐值一致**(表格改的都是折叠区内容,默认态审计看不出来)。
### 两个坑
1. **折叠/隐藏区域里的内容,默认态探针测不到** —— 审计脚本跑的是页面默认态,`/admin/edit` 三张表
一直是 `display:none`。凡是"某个区块要展开/切换才出现"的页面,必须**先展开再量**。
2. **CDP 独占**:我在审计脚本运行期间并发跑了截图命令,两边抢同一个 Chrome/9333,
审计数字当场就歪了(`/timeline` 小触控 181 → 6、`/map` 13 → 6)。审计/探针/截图一律串行。
## 导航栏:保持可滑动 + 右缘渐隐提示(站长选的方案)
站长的第二问选了「保持滑动,加渐隐提示」。7 个导航项在 390 下内容 321 / 可视 214,硬塞不下
(每项 2~3 个汉字 + 44px 触控高度,缩字号内边距也省不出 107px),换行或「更多」菜单都动结构,
所以按他选的做提示。
- `layout.html` 的 `nav` 定义里加 9 行脚本(跟在 `</nav>` 后):按
`scrollWidth - clientWidth - scrollLeft > 2` 给 `.nav-links` 加/去 `.overflow-right`,
监听 `scroll`(passive)与 `resize`。
- `app.css` 620 块里加 `.nav-links.overflow-right { mask-image: linear-gradient(...) }`。
用 **mask** 而不是盖一层渐变伪元素——伪元素会把链接文字一起遮住,mask 是让内容本身渐隐。
不支持 mask 的浏览器只是少个提示,滑动照旧(退化安全)。
实测:390 溢出 107 → 有类、mask 生效;滑到末端类自动消失、滑回开头回来;1440 无溢出无类、mask=none。
**像素级验证渐隐可见**:同一页面 A/B(注入脚本摘掉类)截图对比,差异区 `x 516~548`(scale 2)
正好是 `.nav-links` 右缘那 28px——注意别拿整图最右侧去采样,那里是「后台/退出」按钮、不在 mask 范围内
(我第一次就这么采的,差 0.0 差点误判成"渐隐没生效")。
全量测试(含 layout.html/CSS 改动):纯净包 PASS → 11 包 ok、0 FAIL。改动仍未提交。
## 收尾复查与提交(01:30~01:55)
复查清单与结论:
- `go build ./...` / `go vet ./...` / `gofmt -l` 全清;CSS 花括号 485/485 平衡;CSS 里 `@container`
只出现在注释里(`stripCSSComments` 那条判据就是为它写的:注释里的 `@media/@container` 会带偏解析)。
- **导出验收两段式**:`ROND_CLEAN_OUT=… go test ./internal/web -run TestMovementCleanExport` →
`ROND_EXPORT=… go test ./internal/rond -run TestExportRoundtrip`,visits 823/places 323/moves 791 全对上,
**坐标偏差 0.000000000、时间偏差 0s**。注意:**这两个测试的相对路径是相对包目录**(`internal/rond`),
从仓库根传 `ROND_SRC=data/…` 会报「找不到文件」,必须给绝对路径 `C:/…/data/…`。
- 全量 `go test -count=1 ./...`(带 `ROND_TEST_CONF` + `ROND_SRC`):11 包 ok、**0 FAIL 0 SKIP**。
- CDP 实测(debug=true,模板/CSS 热加载):390 下 `/admin/edit` `/stats` `/report` 整页溢出 0、
每个 `.tw` 横滑 0、`__errs=0`;1440 下同样 0 溢出,且 **nav 无 overflow-right 类**(桌面不该有渐隐),
操作格 `white-space`=桌面 nowrap / 堆叠态 normal。三张行内编辑表的字段逐一核对无缺失。
**修了一处测试的逻辑瑕疵**:`checkWideTablesOutsideGrid` 原来「凡在 g2 里的表都报错」,
但 g2 里放窄表是合法的 → 会误报。改成只判 `tb-stack`(那才是「宽表」的定义),窄表走 .tw 那条判据。
**做了变异验证**:把 `/stats` 的 grid 改回 g2 → 测试确实报 3 条 FAIL → 还原。
⚠️ **变异验证差点污染工作区**:我图省事用 `b.replace(b'<div class="grid">', b'<div class="grid g2">')`
全量替换,而 stats.html 里本来就有 2 处没动过的 `grid g2`(「推荐&踩雷」「类型分布+一天中的分布」),
还原时又被全量换回去 ⇒ 凭空多出 2 处我不想要的改动(diff 从 3 行变 5 行)。
**教训:变异只改一处、且改完用 `git diff --stat` 跟改前的行数对账**(这次是看到 6→10 才发现)。
按行号改(不是按内容 replace)才安全。
提交 `9e01923` 并 push(`96e7631..9e01923`,一次带上了前三次未推的提交)。
---
## 评估「导入新备份 → 分析差异 → 合并」(未动生产代码)
用户诉求:`data/20260919_111819.rondbackup` 与运行中的库有明显差异、两边各有对方没有的数据;
fwss 虽然能选叠加/覆盖,但看不到差异。
**先做实测,结论推翻了朴素设想**。临时探针 `tools/{diffprobe,idprobe,colprobe,fogprobe}/`(一次性,可删):
1. `diffprobe 20260919 … 9`:867 条到访「同号且一致 0 / 仅备份有 5 / 同号冲突 867」;
但按**到达时刻**对齐能命中 866/871 ⇒ 是同一批数据,**Z_PK 被整体重排**(配对全是 1→555、2→276 这种乱序)。
2. 对照 `diffprobe 20260914 … 9`:同号一致 819/824、1→1 2→2… ⇒ 库内那一份就是 0914 备份
(外加 44 条站点补录 `is_user_added` + 4 处站点改过的时间)。所以 0914 时 PK 还是稳的,是这 5 天里变的。
3. `idprobe`:`ZVISIT.ZIDENTIFIER_` 两次备份 **822 条 UUID 完全交集**(A 独有 0),
但 822 条里 **Z_PK 全变**;`ZACTIVITY.ZUID_` 同理(30/31)。⇒ **UUID 才是跨备份稳定键**。
4. `colprobe`:`ZLOCATION.ZPLACEID` 322/323 为空、`ZEVENTIDENTIFIER` 全空 ⇒ 地点**没有 UUID 可用**,
只能「名称+坐标(1e-5)」当键(B 的 367 个地点命中 A 323 个,44 个是新的)。
5. `fogprobe`:库内 10542 块/670565 格。0914 快照 8701 块对库 overlay 新增 **0 格**(全重复),
而 cover 会**静默丢弃 1841 块 / 94401 格**(≈14%)。`fog-export-fixed.fwss` 与库完全一致(0 差异)。
**副产品发现**:`schema.sql` 里「src_pk(rond 内部地点 ID,跨快照稳定)」的注释是错的;
`ingest.contentHash` 用 `SrcPK|Arrival` 做指纹,被 PK 重排骗过 ⇒ 同批数据重导会被当成新内容、新建数据集。
**给出的方案(待批准,未写代码)**:上传先 dry-run 出差异页(新增/冲突/站点独有,UUID 优先对齐)→
确认后单事务合并;fwss 先补数字差异面板(重点是 cover 会丢多少),再考虑差异地图预览。
## 用户拍板 + 落地迷雾差异(阶段 1)
三个回答:①**没重装/换设备/恢复 iCloud,"最后一次是从本项目正式环境导出数据覆盖到 rond 里"**
②冲突策略=全部逐条 ③先做差异。
①是决定性的:**Z_PK 重排不是 iCloud 的锅,是「把站点导出的包导回 rond」这个动作本身**,
即每次往返都会发生。于是补做了链路验证 `tools/exportprobe`:调 `ExportRondbackup` 导一份,
再与 0914 原始备份比 —— **822 条 UUID 全部一致、Z_PK 也全部一致**(B 独有 44 条正是站点补录的)。
⇒ **站点侧导出完好,UUID 是贯穿全链路的唯一稳定键**,合并方案的地基成立。
已实现(`internal/fog/diff.go` + `internal/web/fog*.go` + `templates/admin/fog-preview.html`):
- `fog.DiffBlocks(cur, inc)` 纯函数,出 GainBlocks/GainCells/GainArea、Overlap/SameBlocks、
LostBlocks/LostCells/LostArea;面积按块中心纬度 cos 修正。
- 导入改**三段式**:`POST /admin/fog/preview`(暂存到 `data_dir/fog-staging/`,令牌 32 位 hex,
TTL 2h)→ `GET /admin/fog/preview/{token}`(现算差异,**不落库**)→ `POST /admin/fog?stash=…`
(此时是 urlencoded,`PostFormValue` 能读到 mode;无 stash 才走原 multipart,向后兼容)。
- 差异页把两件事摆出来:叠加能补多少;**整体替换会丢多少块/格/面积**(快照缺 Model/~、Model/# 时另给警示)。
真实数据实测(开发库 10542 块 / 670565 格 vs `data/Snapshot-20260914T154839+0800.fwss`):
叠加 **+0**(全是重复)⇒ 文案报「已被完全覆盖」;整体替换会丢 **1841 块 / 94401 格 / 7.7 km²**。
页面数字与独立探针 `tools/fogprobe` 逐项吻合。CDP 量过 1440/390 均无横向溢出、按钮 84×44 达标。
验证:`fog/diff_test.go`(5 例)+ `web/fog_preview_test.go`(预览不落库、叠加真写、替换丢数据、
令牌挡路径穿越)。**变异验证**:把 `ix.Import(..., mode=="overlay")` 改成 `true`,
`TestFogPreviewWarnsBeforeCoverLosesData` 立刻失败("应有 3 格,实际 5")。
`go test ./internal/web/ ./internal/fog/` 全绿;全量 `./...` 也绿(`internal/rond` 那条
`TestBaselineKeepsCoreData` 要传**绝对路径**的 `ROND_SRC`,相对路径会报"不是有效的 zip")。
探针仍在 `tools/{diffprobe,idprobe,colprobe,fogprobe,exportprobe}/`,后续做 rondbackup 合并还要用。
**下一步(未做)**:rondbackup 差异预演页(UUID 对齐 + 地点用名称/坐标兜底),冲突全部逐条选。
## rondbackup 差异预演(本次)
`internal/rond/diff.go`(纯函数:`DiffVisits`/`DiffPlaces`/`DiffActivities` + `Counts`)
+ `rond.Parse` 新增 `Backup.VisitUUID`/`ActivityUUID`(从 SQLite `hex(ZIDENTIFIER_)` 读)
+ `web/backup_diff.go`(库侧加载 + `POST /admin/preview` 暂存 + `GET /admin/preview/{token}` 渲染)
+ `templates/admin/backup-preview.html`。后台上传区改成两个按钮:**分析差异**(primary)/ 直接导入(普通)。
暂存逻辑同时抽成通用 `stashPut/stashRead/stashSweep`(迷雾与备份共用),迷雾那套测试原样通过。
### 过程中撞到并修掉的两个真问题
1. **无 UUID 的到访被双算**:真机 824 条到访里有 2 条没写 UUID,只按 UUID 配对 ⇒ 同一条既进
「备份新增」又进「仅站内有」。修法是加「到达时间(到秒)+ 地点坐标」兜底配对
(`timeKey`;只到秒是因为苹果时间戳是浮点秒、PG 存微秒)。
2. **地点改名造成 17 条假冲突**:首版地点键是「名称+坐标」,而站点的 `backfillAfterImport`
把「体育馆美食街」反查成了「河池市国民体质监测中心」(坐标一字未动),于是一整个地点的
17 条到访全被报成「换了地点」。改成**地点只认坐标**(`PlaceCoordKey`),名称降级为比较字段;
配不上再按名称兜底(覆盖站点把坐标纠偏过的情况)。改完真实数据上冲突 **17 → 0**。
### 真实数据实测(0919 备份 vs 库内 0914 数据集)
| 实体 | 备份 | 站内 | 备份新增 | 仅站内有 | 冲突 | 一致 |
|---|---|---|---|---|---|---|
| 到访 | 872 | 867 | 49 | 45 | **0** | **822** |
| 地点 | 367 | 368 | 1 | 2 | 4 | 362 |
| 活动 | 31 | 31 | 0 | 0 | 0 | 31 |
账目(`tools/keyprobe` 逐条核过):0914 备份 824 行 = 822 有 UUID + 2 无(#16、#191);
其中 #191 无到达时间,导入时就被跳过且**不写 visits 但写了留档**,所以库里是 821 条有 UUID 的到访。
0919 备份 872 行 = 870 有 UUID + 2 无(#365、#372 无到达时间)。
于是:**821 条按 UUID 配对**、**49 条是 0919 新出现的 UUID**(870−821)、
**#365 靠「到达时间+地点坐标」兜底配上库里的 #16**、
库里 45 条站点补录无 UUID 也没留档 ⇒ **仅站内有 45**。四个数字全部自洽。
(一度以为「有 1 条无 UUID 没配上」,是把补录数记成 44 了 —— `is_user_added` 实际是 45 条。)
地点 4 条冲突正是反查补全留下的:
改名 1 条、站内比备份更全 1 条、坐标被纠偏 2 条(28.141772→28.144964 这类)。
### 验证
`rond/diff_test.go` 11 例(UUID 对齐/字段差异/编号撞号不得误配/兜底配对/改名/坐标纠偏/活动重命名)
+ `web/backup_diff_test.go` 3 例端到端(同一份备份报一致、站内改动静默识别、**渲染前后行数不变**)。
**变异验证**:去掉 `used[i] = true`,fog 与 web 两层同时失败。
完整验收(带 `ROND_SRC`/`ROND_EXPORT`/`ROND_DSN`/`FOG_SRC`/`ROND_CLEAN_OUT`)见当次结果。
### 按建议补做的三项
1. **`ingest.contentHash` 换稳定键**(原为 `SrcPK|Arrival`):改成按到访 UUID / 活动 UUID /
地点「名称+坐标」排序后取摘要,没有 UUID 的老备份退回「到达时间+地点坐标」。
回归 `ingest_test.go` 六例,变异验证(把 UUID 换回 `fmt.Sprintf("V%d", pk)`)当场失败。
2. **修正两处声称「src_pk 跨快照稳定」的注释**(`db/schema.sql`、`rond/export.go`),
改成「只在单个数据集内稳定」并写明跨备份要靠 UUID。
3. **复查「1 条配对不上」**:用 `tools/keyprobe` 把两边的键逐条打出来,结论是**没有 bug**,
是补录条数记错(45 而非 44)。兜底配对实际工作正常。
**顺带确认**:「站点补录行占用 nextSrcPK 号段」这个隐患**不影响正确性** ——
跨备份对齐走 UUID,导出时新行是 INSERT(基线里没有该号),回导实机后 pk 重排也无所谓。
## 合并执行(本次)
`rond/merge.go`(纯逻辑:`Choices` + `BuildMergePlan`,把「差异 + 页面上逐条的选择」翻成改动清单)
+ `web/backup_merge.go`(真执行)+ 差异页的合并表单(`form=` 属性把各表格里的 radio 关联到同一个 `#merge-form`)
→ `POST /admin/preview/{token}/merge`。
**语义**:新增类勾选才并;「仅站内有」一律保留;冲突逐条选(用实机/用站内/跳过,默认**用站内**,没提交或怪值也退回用站内)。
**不做删除** —— 删地点会级联删掉它的全部到访。
**合并顺序**:地点 → 活动 → 标签 → 到访 → 位移/天气 → 重算地点聚合与数据集汇总,全在一个 `withTx` 里。
### 三个实测撞出来的坑(都已修)
1. **位移/天气被整份复制**:一开始遍历 `b.Movements` 只要两端能映射就插,而映射表里包含**所有**备份到访
⇒ 库里已有的位移又插了一遍(791 → 1547)。改成只处理「至少一端是这一轮新插入的到访」。
2. **天气重复**:站内删到访时是把 `weather.visit_src` 置 NULL 而保留记录(`editVisitDelete`),
合并把到访找回来时又插一条同 `at` 的天气。改成插入前先**认领**「同一时刻、`visit_src IS NULL`」的那条。
3. **测试自己的手法问题**:直接 `DELETE FROM visits` 会留下孤儿天气/位移(`weather.visit_src` 没有外键),
数字对不上。改成按站点的方式删(删位移、天气置 NULL、删到访)。
### 验证
`rond/merge_test.go` 4 例(选择翻译、怪值退回用站内、跳过、活动键优先 UUID)
+ `web/backup_merge_test.go` 3 例端到端:**删两条到访 → 合并 → 到访/位移/天气全部复原 → 再比对「零差异」(幂等)**、
冲突选「用实机」真覆盖 / 选「用站内」不动、什么都不勾则一个字节都不写。
**变异验证**:把新行的 `writeRawRow` 去掉,留档变 821/823 且**幂等性当场破**(合并后再比对报「新增 1 / 仅站内有 1」)。
真实数据只验证了**渲染**(radio 分组正确、390 宽无溢出),**没有在开发库上真执行合并**——那会改动你 0914 那套真实数据。
## 附属数据改挂坐标键(迁移)
**动因**:备注/结论/图片原本挂 `(dataset_id, src_pk)`,换个数据集就全看不见——这是用户最初的痛点之一。
用户这次明确要求:**迁移可以,但导出出去的 .rondbackup 与 .fwss 必须还是原有结构**。
核对过:这次只动 PG 的三张附属表,`ExportRondbackup` 走的 visits/places/movements/weather/entity_raw
一个没碰,迷雾那几张表也没碰,所以导出结构不受影响(并用往返测试实测确认)。
**做法**:`place_key` = 经纬度各 6 位小数(约 0.1m)。`place_notes`/`place_votes` 主键换成
`(user_id, place_key)`;`place_images` 加列 + 索引查。Store 的**对外签名一律不变**(仍是
userID/datasetID/srcPK),内部改成按 key 存查,所以调用方一处没改。
**关键前提验证**:Go 的 `%.6f` 与 PG 的 `FM999990.000000` 必须逐位一致——先在真实数据上
比了 **368/368 全同**才敢动。
### 撞出来的一个大坑:迁移块互相打架
改完后测试报 `没有匹配 ON CONFLICT 说明的唯一或者排除约束`。查测试库发现:`place_key` 列**在**、
主键却还是老的 `(user_id, dataset_id, src_pk)`。
根因:`schema.sql` 里**早就有一段**把主键改回三列的 DO 块(当年加 dataset_id 时写的),
守卫是「有 src_pk 列」——而 src_pk 永远存在,于是它**每次启动都执行**,把我刚改成 place_key 的主键
又改回去。**每次都停在最后执行的那个块上**。
修法两条:① 老块先判「没有 place_key 列」再动手(让位);② 新块的判据从「列在不在」改成
**「主键列组合对不对」**,这样「列已补、主键还老」的半迁移库也能自愈
(测试库正好卡在这个状态,改完立刻验证通过)。
**结论**:`schema.sql` 里所有改同一约束的块都会互相覆盖,**必须让老块能被新块取代**。
### 访客票一并迁移(用户确认了猜法)
用户明确:**访客投票只属于本站,rondbackup 里没有这一项**;但它同样要认得出是哪个地点,
「如果两边都有就跟着活跃的走」。于是 `place_visitor_votes` 也改挂坐标键,
主键换成 `(place_key, voter_key)`;这张表没有 dataset_id,回填时按**活跃数据集**认领
(同编号在多个数据集里也只认活跃的那个),认不出的留 `orphan:<pk>` 不丢票。
连带把**所有按这两张表统计的地方**都改成按坐标键 join `places`:`voteTotals`、
`verdictPlaces`、备注页的排序与两处关联、`stats` 的 `Verdict` 过滤。
按它们自己的 `dataset_id` 过滤会漏掉「从别的数据集写进来的票」。
`apiVote` 原本没有数据集上下文,补了 `datasetFor`。
开发库实测:1 条访客票迁到 `26.471574|106.673434`,与活跃数据集里 `src_pk=162`
(孔学堂-礼仪广场)的坐标完全一致;`/api/votes` 返回 `tallies:{"162":{"up":1}}`、
`verdicts` 4 条都对得上。新增 `TestVisitorVotesFollowPlaceAcrossDatasets`
(换数据集票还在、从任一侧点两次都能撤掉同一张票)。
### 验证
- 开发库:迁移后行数不变(notes 14 / votes 3 / images 5)、无空键、无孤儿键、
`place_key` 与实际坐标 0 条不符;**重启两次主键稳定**(收敛)
- 新增 `place_identity_test.go` 两例:**换数据集后备注与结论照旧跟在同一个地点**、
坐标差 0.1 度的地点**不会互相认领**
- 全量复测(含 `TestExportRoundtrip` 与 `TestNativeSnapshotRoundtrip`)见当次结果
## 合并的删除(上一轮)
「仅站内有」的记录**默认一条都不删**,要清的逐条勾「合并时删除」(`dv_<src_pk>` / `dp_<src_pk>` / `da_<src_pk>`)。
键用 `src_pk` 而不是 UUID —— 站点补录的行压根没有 UUID。
**顺序:到访 → 活动 → 地点**,删到访时连 `movements`、`entity_raw` 一起清、`weather.visit_src` 置空(与站点
`editVisitDelete` 一致)。**名下有到访的地点直接拒绝**(外键是 `ON DELETE CASCADE`,真删会带走到访),
拒绝后整体回滚。
**测试**:`TestBackupMergeDeletesOnlyChecked`(勾一条只删一条、没勾的不动、留档一起清、再合并不勾仍不删)
+ `TestBackupMergeRefusesDeletingPlaceInUse`(空地点可删、有到访的地点被拒且数据不变)。
**变异验证**:把「有到访就拒绝」改成 `n > 100000`,测试当场失败。
页面实测:45 个 `dv_` 勾选框**默认全未勾**、12 个冲突 radio、`add_new` 默认勾选;
那两个「仅站内有」的地点因为 `visit_count > 0`,按设计不给删除框(显示「还有到访,不能删」)。
**踩的坑**:`TestBackupDiffIdentical` 一开始断言整页含「完全一致」四个字——结果被**说明文字里的
「UUID 完全一致」**命中,成了假阳性;且断言整页 `data-label="冲突">0</td>` 会被地点行张冠李戴。
改用 `visitCounts()` 正则取「到访」那一行。另外「上传同一份备份」在地点层面**本来就有差异**
(导入后反查补全改过名称/地址),所以不能拿整页 Identical 当断言。
## 差异地图(用户要求「差异部分在地图上展示」)
新增 `internal/fog/render_diff.go`:`RenderDiffTile(cur, inc, z, x, y, crs)` 出三色瓦片
(绿=叠加会新增 `{36,164,90}`、灰=两边都有 `{126,134,146}`、红=替换会丢 `{214,56,56}`)。
**精度分两级**:z≥14(scale≥1)逐格精确;更粗的级别下一个块还不到一像素,按块内多数类整块涂
(`dominantKind`,绘制顺序 both→gain→lost,让绿/红压住灰)。不做描边(三色已够,粗级别会糊)。
`internal/web/fog_diff.go`:瓦片端点 + 两边块集的单槽缓存(按 token,TTL 10 分钟,
否则每张瓦片都要重解一遍 680KB 的 .fwss)。`diffBounds` 算两边包围盒给前端 `fitBounds`
(按底图 crs 换算,否则视野偏几百米)。
**踩的坑(值得记)**:第一次 CDP 测出 `container=NO`、`diffTiles=0` 且**控制台零报错** ——
原因是页面没设 `p.HasMap=true`,layout 因此不引 Leaflet,而脚本里 `if(!window.L) return`
静默退出。记忆里本来就写着这条,还是漏了。判据要认 `leaflet-container` 类,不能只看有没有报错。
真实数据验证(0914 快照 vs 开发库):`container=ok diffTiles=15 ok=15 bad=0`,
三色像素 **gain=0 / lost=557 / both=2473** —— 绿色为 0 与页面上「叠加 +0」完全自洽。
390/1440 均无横向溢出(地图 324/1042 宽,高 400)。
测试:`render_diff_test.go`(逐格三色、单边整块、粗级别多数类、边界、dominantKind、计数)
+ `fog_preview_test.go` 的 `TestFogDiffTileColors`(端点颜色语义 + 越界/无效令牌 404;
第一版用例把 `(0,0)` 当越界,被测试自己纠正成 `x=262144`)。**变异验证**:把 `cellKind` 的
gain/lost 反过来,fog 与 web 两层测试同时失败。
### 后台服务任务的假失败(12:44)
`bin/rond.exe` 的后台任务报 failed,查下来是**重复启动**撞端口:旧实例还占着 8080,新进程只打一行
「bind: Only one usage」就退出(日志写进的是*新进程*那个文件,真正在服务的实例日志在
`storage/_rond_new.log`)。判据:用唯一 URL(`/probe-<pid>-<rand>`)打一下,看**哪个日志文件**出现这条记录,
那个文件对应的才是活着的实例。别急着重编——先看日志确认是不是 bind 冲突。
本次实例是 12:44:09 起的、一直在正常服务(各页 200),templates/CSS 在 debug=true 下热加载,不受影响。
## 地点补全(本轮)
用户要的:新增/活动和类别为空的地点,**根据地名自动判断填充**;匹配不上就新增或手工填。
摸底:dataset 9 有 368 个地点,**217 个没类别**、**52 个完全没活动**(71 条到访无活动)、41 个零到访空地点。
`poi_category` 存的是 **Apple MapKit 枚举**(真机用过 12 种),不是高德分类 —— 补的时候必须写这种形状。
实现:`fillup.go`(页面/列表/写入)+ `fillup_rules.go`(词表与映射)+ `fillup.html`。
三层判断:本地词表 → 高德周边搜索(按坐标,比按名字准)→ 人工。**只填空**,活动**按地点批量**设。
### 撞出来的三件事
1. **规则匹配要先掐括号、再按词长优先**:否则「尚优居客栈(大理古城步行街店)」命中「步行街」、
「马鞍山公园」命中「山」。
2. **单字关键词必须删掉**:「独山路28-17号」被猜成「山」——误命中率太高,这类交给高德更稳。
3. **页面一度 927KB**:每行两个 30+ 选项的 `<select>`,218 行就是 13000 个 option。
改成 `input + datalist` 后降到 169KB。
### 验证
真实数据跑十批高德:218 个待补里 **71 个拿到建议**(活动 25、类别 47),
其中词表 10 个、其余来自高德。剩下的高德查过但类型映射不上(邮局、公司、地名地址这类),
缓存记下不再重试,所以批次跑几轮就收敛。
测试:`fillup_rules_test.go`(用真实地名做用例)+ `fillup_test.go`
(按地点批量设活动 / **已有值不被覆盖** / 填陌生名字自动建活动类型)。
**变异验证**:去掉 `AND activity_id IS NULL`,`TestFillupNeverOverwrites` 当场失败。
完整验收(含导出往返)全绿。
### 补:高德参考列 + 类别安全标注
没映射出建议的行原先一片空白,但高德其实已经说了「这里是什么」。多了一列「高德参考」
(POI 名 + 类型原文),「中国邮政 / 生活服务;邮局」这种一眼就能判断该填什么。
类别下拉加了中文标签,并标出哪 12 种是**真机确实用过**的。
**顺带实测回答了一个重要问题**:`poi_category` 改动后导出的 rondbackup 还能不能导回 rond ——
**能**。用 `exportprobe` 导一份再读 `ZLOCATION.ZCATEGORY_`,值与真机备份**同格式**
(都是 `MKPOICategoryXxx` 字符串)。当时还写了「**真机自己也会写中文类别**」——
**⚠️ 这条 2026-09-20 已证伪,见下面那次的更正:真机从没写过非枚举值。**
### 补:下拉改回 select
`input + datalist` 只在下拉展开时显示 label,**框里永远显示 value**
(`MKPOICategoryPark` 这种枚举名),站长看不懂。改成 `<select>` 后选中项直接是中文。
页面原始 940KB、**gzip 后 22KB**(选项高度重复),体积不是问题,不必再为它绕路。
`datalist` 这个坑值得记:**要用它就得接受「框里显示 value」**,想显示中文只能用 select。
## 全量回归(今天所有改动 + 原有功能)
三层都跑了,**没发现回归,也没改任何代码**。
**1. 代码层** `go test ./...`(带齐 ROND_TEST_CONF / ROND_SRC / ROND_EXPORT / ROND_DSN / FOG_SRC):
所有包 ok、零 FAIL。关键细节:fwss 往返核对 8701 块 / 106 个其它层条目;
rondbackup 往返逐项一致(visits 823 / places 323 / tags 8 / acts 30 / moves 791 / weather 557 / rawvisits 2246),
**坐标偏差 0.000000000、时间偏差 0s**;`tb_stack_test` 检查了 12 张表;访客页面 6 个子测试全过。
**2. HTTP 层**(curl 全站扫):站长 18 页全 200;访客 9 页全 200;
访客访问 /admin/* 四个都 303 跳登录(权限边界正常);API 五支全 200 且都有数据
(points 64KB / track 108KB / cities / votes / map-config);导出四支全 200
(geojson 108KB / gpx 62KB / rondbackup 2.9MB / fwss 693KB);迷雾瓦片三档全 200 且访客也能取。
新增的三个页面(迷雾差异 / 备份差异 / 地点补全)也各 200,备份差异页数字与之前一致(49/45/0)。
**3. 渲染层**(CDP,390 宽):`run_all.py` 扫的 14 个页面**横向溢出全部为 0**;
新增三页 × 两种视口也是溢出 0、零 JS 报错。
**两个改进点**(不是 bug):
- `/admin/fillup` 在 390 下页高 54870px —— 218 行堆叠确实很长,**该加分页**(每页 30 行)。
- `/stats` 小字 40、`/map` iOS 自放大 1,都是**既有**的(技能笔记里就记着),与本次改动无关。
### 补:补全页分页
每页 30 行。390 宽下页高 **54870 → 8126**。页码夹紧(`?page=999` 停在末页、解析不出来回第一页),
提交后留在同一页;翻页前若有改过还没提交的下拉会先 confirm(预选的建议不算改过,靠记录初始值判断)。
测试:`TestClampPage`(8 个边界)+ `TestFillupPagination`(造 35 个地点验两页与越界)。
**变异验证**:去掉夹紧逻辑,两个测试同时失败。
### ⚠️ 测试基础设施的已知脆弱点(不是代码 bug)
`go test ./...`(默认并行跑包)时 `internal/stats` 偶发 SKIP —— 它的 `setup` 读**全局**的
`datasets WHERE is_active LIMIT 1`,而 `internal/web` 的 fixture 会在导入期间**临时把 is_active
指向自己的数据集**、cleanup 时再删掉。两者撞上就是「没有生效数据集」或「找不到可用省份」而 skip。
**单独跑 stats 包 5 个测试全过**,`-p 1` 串行跑也不会有这问题。
以后做「完整验收」建议用 `go test -p 1`,或者把 stats 测试也改成自带数据集。
### 补:修 UI 回归(用户报的)
用户报「活动和类别变得无法手工填写」+「选不动后点用高德分析就锁死了」。根因都是我上一版改 select 引入的:
1. **纯 select 把手工填写的路堵死了** —— 活动改回可输入的 input(活动名本来就是中文,没有显示问题);
类别保留 select 但末尾加「其它(自己写)…」,选中露出文本框(后端认 `cat_<id>__custom`)。
(当时以为「真机自己也存中文类别」所以让用户随便写中文 —— **那条判断是错的,见 2026-09-20 的更正**:
真机只写过 `MKPOICategoryXxx`,中文全是站内自己写进去的。)
2. **建议被强制预选 ⇒ 用户表达不了「不动」** —— 而且点「用高德分析」会整页重载,
刚选的全丢,看起来就像锁死。改成**默认什么都不选**,批量采纳走「采纳本页建议」按钮;
分析前把本页填过的存进 sessionStorage,回来恢复,提交时清掉。
**教训**:`select` 只适合「选项可穷举」的字段。活动是用户可自由新增的,就绝不能做成纯下拉。
顺带被测试抓到一次:分页测试数 `name="cat_` 时把新加的 `cat_X__custom` 也算进去了(30 行变 60),
口径改成数 `<select name="cat_`。
### 补:活动与类别统一样式(用户报的)
用户说「类别和活动的选择样式好像不一样」。上一版我为了恢复手工填写,把活动做成 input+datalist、
类别做成 select+「其它」,同一行两个格子长得不一样。
现在活动也改成 select + 「其它(自己写)…」+ 文本框,与类别完全同款:
- 模板两处结构一字不差,只换 `act_`/`cat_` 前缀;CSS 类合并成 `fill-sel`(原 `cat-sel` 全部替换)
- 后端 `act_<id>` 读到 `__custom__` 就读 `act_<id>__custom`
- 删掉 `datalist#fill-act`;「采纳本页建议」与草稿存取改走 `select.fill-sel` 一次遍历
- 草稿恢复对 SELECT 派发 `change` ⇒ 「其它」文本框能跟着恢复显示(否则值在、框被隐藏)
实测(CDP):390 宽下两者完全一致 229×37;1440 宽下同字号/内边距/边框,宽度由内容撑开略有差。
**新增两个测试**:`TestFillupCustomActivity`(写文本框里的名字)、`TestFillupCustomActivityEmptyIsSkipped`(空名不建类型)。
**验收插曲**:第一次串行跑出现 `--- SKIP: TestMovementCleanExport` —— 不是回归,是我把
`ROND_CLEAN_OUT` 只写在第一条命令的前缀里(前缀 env 只作用于那一条),整链跑时没导出。
改成 `export` 全部环境变量后重跑:12 个包全 ok,零 FAIL 零 SKIP。**教训:验收链要用 `export`,
不要用命令前缀**,否则导出验收会在完整跑里静默跳过、看着像全绿其实没跑。
提交 `bf23fc6`。