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

37 KiB
Raw Blame History

2026-09-19

真机反馈「移动端还是要左右滑」:容器查询是错的,改回媒体查询(阈值 800)

站长 00:09 反馈:在本地环境用移动端测试,还是得左右滑动,否则只能看到一部分信息。

根因:我上一轮把堆叠判据换成 @container 是个错误决定

无头 Chrome(新版)里容器查询工作正常,390 下表格都堆叠了;但容器查询要 iOS 16 / Chrome 105 起才支持,不支持的浏览器会把整块 @container 规则当不认识的东西整块丢弃 ⇒ 表格完全不堆叠、 8 列要横滑 328388px。站长的手机浏览器大概率就落在这一档,于是「还是得左右滑、只能看到一部分」。 (顺带:媒体查询 620 那个旧版本同样有漏 —— 视口 621792 的卡片既放不下表格又没窄到 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。