用户要求这份记忆跟着仓库走,而不是只留在本机。 .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`)。
510 lines
37 KiB
Markdown
510 lines
37 KiB
Markdown
# 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`。
|