# 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`(城市/坐标/到访/地点/类型/备注/路线/方式/里程); 末尾那个只放「编辑」的操作格**不加标签**(表头本来就是空的 ``,硬加会把按钮/表单挤窄)。 - 三处行内编辑表单的 `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 行脚本(跟在 `` 后):按 `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'
', b'
')` 全量替换,而 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:` 不丢票。 连带把**所有按这两张表统计的地方**都改成按坐标键 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_` / `dp_` / `da_`)。 键用 `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` 会被地点行张冠李戴。 改用 `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--`)打一下,看**哪个日志文件**出现这条记录, 那个文件对应的才是活着的实例。别急着重编——先看日志确认是不是 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+ 选项的 `` 后选中项直接是中文。 页面原始 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___custom`)。 (当时以为「真机自己也存中文类别」所以让用户随便写中文 —— **那条判断是错的,见 2026-09-20 的更正**: 真机只写过 `MKPOICategoryXxx`,中文全是站内自己写进去的。) 2. **建议被强制预选 ⇒ 用户表达不了「不动」** —— 而且点「用高德分析」会整页重载, 刚选的全丢,看起来就像锁死。改成**默认什么都不选**,批量采纳走「采纳本页建议」按钮; 分析前把本页填过的存进 sessionStorage,回来恢复,提交时清掉。 **教训**:`select` 只适合「选项可穷举」的字段。活动是用户可自由新增的,就绝不能做成纯下拉。 顺带被测试抓到一次:分页测试数 `name="cat_` 时把新加的 `cat_X__custom` 也算进去了(30 行变 60), 口径改成数 `