用户要求这份记忆跟着仓库走,而不是只留在本机。 .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`)。
254 lines
20 KiB
Markdown
254 lines
20 KiB
Markdown
# 2026-09-20
|
||
|
||
### 补全页:手工填过的值自动进下拉候选
|
||
|
||
用户报「手工填了之前没有的活动/类别,后面同类地点还得再手工打一遍,效率低」。
|
||
根因:下拉候选是**页面渲染时定好的**(活动来自 `activities` 表、类别来自静态表),
|
||
本页新填的值在提交前进不了候选。
|
||
|
||
做法(全部在 `templates/admin/fillup.html` 的脚本里,后端没动):
|
||
- `sessionStorage['rond-fillup-newopts']` 存 `{act:[],cat:[]}`;本页文本框里的值 + 从别处带来的值,
|
||
一起补进同类下拉,插在**建议之后、真候选之前**(不沉到列表最底下),标成「新填:X」,带 `data-local` 标记
|
||
- 本页打字即生效;翻页/「用高德分析」重载也留得住(重载时读回来重新 paint);提交成功后清掉
|
||
- 先 paint 再恢复草稿:草稿里选中的可能就是新填的值,选项不在跟前 `select.value` 设不进去
|
||
- 草稿保存改成**按 name 合并**(`delete` 掉本页明确清空那些),翻页不再覆盖上一页;分页链接也接了保存
|
||
- **重画候选前要留住「这一格已选中的新填值」再还回去**,否则改一下文本框会把别的行选好的值抹成空
|
||
|
||
**两个坑**:
|
||
1. 文本框监听**不能委托到 `document`**:`new Event('input')` 默认**不冒泡**,探针第一次就是这么假绿的。
|
||
直接 `box.addEventListener` 更稳,也少一层隐式约定。(用户真实打字是冒泡的,但别赌这个)
|
||
2. 验收时我用了**新的** `ROND_CLEAN_OUT` 文件名,`-p 1` 下 `internal/rond` 按字母序**排在 `internal/web` 之前**,
|
||
导出文件还没生成 → `TestExportRoundtrip` 等 4 个测试全红。**这是假警报**,
|
||
先跑 web 的 `TestMovementCleanExport` 生成文件、或沿用上次的文件名即可。
|
||
提交 `ffee082`。
|
||
|
||
### 数据编辑页加「活动与类别」卡片
|
||
|
||
用户要求「在数据编辑页添加一个模块,对活动和类别进行更改、删除和新增」。先摸清现状:
|
||
|
||
- **活动**:`activities` 表,31 条。后端 create/update/delete **三个端点早就有**,
|
||
但 UI 只藏在「到访记录」卡片里——要先在下拉选中某个活动,再点按钮弹 `prompt/confirm`,
|
||
入口隐蔽、也看不见「哪些活动一条到访都没挂」(实测有「学习」0 条)。
|
||
- **类别**:**根本没有表**,就是 `places.poi_category`。16 种值在用(12 个 MapKit 枚举 + 4 个中文)。
|
||
候选来源还两套:编辑页/照片页读 `DISTINCT`(只有用过的才出现),补全页读静态表 `fillupCategories()`。
|
||
→ 库里真在用的中文类别(一般道路/彩票店/租车点/美食街)在补全页下拉里选不到。这是**已存在的 bug**,记下了。
|
||
|
||
**用户拍板的选择**(问过才动手):类别**不加字典表**、不预置空字典项,只做批量改名/合并/清空;
|
||
活动**要合并**。
|
||
|
||
实现:
|
||
- 新端点 `category/rename`(改成已有值就是合并,同一条 UPDATE)、`category/clear`(写 NULL)、
|
||
`activity/merge`(事务内把到访转过去 + 抄目标活动的 `is_home/is_work` + 删源活动 + `pruneEntityRaw`)
|
||
- `EditData` 加 `ActivityRows`/`CategoryRows`;编辑页原来那条 `DISTINCT poi_category` 查询
|
||
顺手改成 `GROUP BY` 带计数(一份数据两用,不额外查)
|
||
- 卡片 `data-card="taxonomy"` 放页首,是**唯一默认展开**的卡片(脚本里给 `taxonomy` 开了小灶)
|
||
|
||
**自己踩的坑(值得记)**:表我写成 `class="tb-stack"`,漏了配对的 `tb`。
|
||
CSS 全是 `.tw > table.tb.tb-stack …`,结果一条堆叠规则都没命中,390 宽下表还是 640px 底宽——
|
||
**而且守卫测试没拦住**(它只查 `tb-stack` 在不在),页面也不横滑(横滑在 `.tw` 里滚),
|
||
是探针量到「表宽 560 > 卡片宽 362」才发现的。已给 `wide_table_layout_test.go` 补上
|
||
「有 tb-stack 必须有 tb」的判据,并做了变异验证(去掉 tb → 守卫报错)。
|
||
另外宽度**别用行内 min-width** 写死,堆叠态的 `min-width:0` 会被 inline 压过去。
|
||
|
||
实测(CDP,全程拦截 fetch 保证不动真实数据):默认展开、31 活动/16 类别行、「家」引用 250、
|
||
改名没变→只提示不请求、删除确认带条数、清空确认、合并同一个→拒绝、合并确认带两个名字和条数、0 次请求。
|
||
|
||
### 补:补全页的类别候选取「在用的 ∪ 静态表」
|
||
|
||
上一轮记下的「两页候选不一致」用户决定修掉(原话:不然使用时会有异常)。
|
||
|
||
改动只在 `internal/web/fillup.go`:
|
||
- 新增 `(*Server).usedCategories(ctx, dsID)`:`SELECT poi_category, count(*) … GROUP BY 1 ORDER BY 2 DESC, 1`
|
||
- `fillupCategories(inUse []fillupCategory)` 现在三段拼:**在用的**(按地点数降序,标签 `中文名(在用 N)`)
|
||
→ 真机用过的 12 种(`(真机用过)`)→ 其余常见 MapKit 类别。用 `push` + `seen` 去重,
|
||
**同一个值只出现一次**,在用的那份信息更准、顶掉静态说明。
|
||
- 标签统一走 `poiCategoryLabel`(MKPOICategoryX→中文;未知枚举去掉前缀;中文值原样返回)。
|
||
|
||
**实测真实数据**:36 项 = 16 在用 + 18 未用过的静态 +(不动)+(其它「自己写」),零重复。
|
||
前几项:餐厅(在用 51)/ 商店(在用 29)/ 公共交通(在用 22)/ 酒店(在用 14)/ 地标(在用 10)/
|
||
**一般道路(在用 5)**/ 菜市场(在用 4)…;这个数据集里静态表的 12 种恰好全都在用,所以「真机用过」组为空。
|
||
|
||
写测试时踩了两次自己的口径错误(都不是产品的错):
|
||
1. 数「值出现几次」时把整页数了——**下拉每行渲染一份**,30 行就是 30 次。改成先截出第一个 `<select>…</select>` 再数。
|
||
2. 断言「餐厅(在用 1)」——**夹具导的是真实备份**,那类别本来就有 51 个地点在用。改成只断言 `value="X">中文(在用 ` 前缀,不钉数字。
|
||
|
||
### ⚠️ 更正一条错误结论:真机**从没**写过非枚举的类别值
|
||
|
||
用户问「类别这里部分显示中文、部分显示 MKPOICategoryBeach,直接改会有影响吗」,
|
||
顺手把数据核了一遍,结果**推翻了我 2026-09-19 的判断**(那条写在 `knownMKCategories` 附近:
|
||
「真机自己也会写中文类别(一般道路/彩票店/美食街/租车点)⇒ 这列本来就是自由文本」)。
|
||
我从「库里看到中文值」直接推成了「真机写的」,**没查这些值到底是谁写进去的**。
|
||
|
||
实测(活跃数据集):
|
||
|
||
```sql
|
||
-- 真机留档里的 ZCATEGORY_ 分布
|
||
SELECT COALESCE(NULLIF(r.raw->>'ZCATEGORY_',''),'--empty--'), count(*)
|
||
FROM entity_raw r WHERE r.dataset_id=… AND r.entity='ZLOCATION' GROUP BY 1;
|
||
-- → 201 空 + 12 种 MKPOICategoryXxx,非枚举值 **0 条**
|
||
-- 站内值与留档不一致的地点:**只有 2 个**(公园 1 + 美食街 1)
|
||
-- 4 个中文类别全部落在「真机本来就没给类别」的地点上(留档为空,或那行站内新建、根本没有留档)
|
||
```
|
||
|
||
⇒ **结论**:`ZCATEGORY_` 在真机那边就是 MapKit 枚举串;中文值是**站内**(补全页手填 / 高德建议 / 自定义)写进去的。
|
||
当时我还把这句错误结论当成依据安抚用户「写中文完全安全」,**这句得撤回**:
|
||
写中文不会坏数据(导出侧不校验、站内显示原样),但 **app 那边很可能显示不出类别**,
|
||
而且我们**没有任何实测样本**能保证 app 怎么处理未知值 —— 只能说「不损坏」。
|
||
|
||
**教训**:看到「库里某个值不符合预期形状」时,先分成「谁写的」——查 `entity_raw` 里的留档
|
||
(站内新建的行**没有留档**,`raw IS NULL` 就是最强指纹),别把「站内写进去的」当成「真机的行为」。
|
||
|
||
### 类别「显示名」可维护(枚举 ↔ 中文对照)
|
||
|
||
用户:「MKPOICategoryBeach 下边标注海滩,我希望能够改,一一对应的或者现在没有对应值的让我来填写补充」。
|
||
根因:中文名是 **Go 里硬编码的两张 map**,用户根本改不了;而且两张表还对不上。
|
||
|
||
**先合并两套中文名**(这次的关键清理):
|
||
- `view.go` 的 `poiNames`(38 条,给补全页静态项 + GPX)与 `handlers_edit.go` 的 `poiCategoryText`(33 条,给编辑页)
|
||
**冲突 3 处**:FoodMarket 菜市场/食品市场、Cafe 咖啡馆/咖啡厅、FitnessCenter 健身房/健身中心;
|
||
还有 `ATM`/`Atm` 大小写各写一份、一条永远匹配不上的死条目 `MKPOICategoryMarina2`。
|
||
- 合并成 `poiCategoryText` 一张(42 条,冲突处取编辑页那套),删掉 `poiNames`/`poiName`。
|
||
- **连带清掉两个模板函数** `catName` / `poiname`(它们绑在旧函数上)。模板里算不出显示名(要读站点覆盖),
|
||
所以改成在 Go 侧算好塞进数据:`FillupRow.CatLabel`、`PlaceData.CategoryLabel`。
|
||
|
||
**新增可维护层**:
|
||
- 表 `category_labels(value PK, label)`,**站点级、不挂 dataset_id**(值本身是稳定键)。
|
||
内置表降级为「默认值」,`categoryLabel(v, custom)` 的兜底顺序:自定义 > 内置 > 去掉前缀的原值。
|
||
- 端点 `POST /admin/edit/category/label`;**传空 label = 删除覆盖行、恢复默认**(UPSERT/ DELETE 两条路)。
|
||
- 类别台账那行改成两个带前缀标签的输入框:`导出值`(会写回手机)+ `显示名`(只影响显示),
|
||
外加「保存显示名」按钮;`Missing`(是 MKPOICategory* 但内置表没有)提示「填一个中文名」。
|
||
placeholder 分两种:缺名 →「填一个中文名」;否则「留空=默认「X」」。
|
||
|
||
**必须记住的不变量**:显示名**绝不写进 `places.poi_category`**——那是导出回手机的值,
|
||
中文跟着写进去就污染数据了。测试 `TestCategoryLabelNeverTouchesValue` 专门钉这一条。
|
||
|
||
实测:类别行「导出值=MKPOICategoryBeach / 显示名=空 / placeholder=留空=默认「海滩」」,
|
||
点保存发出 `value=MKPOICategoryBeach&label=海边`;390 宽卡内无溢出;访客地点页副标题照常显示「海滩」。
|
||
|
||
### 修:「填了显示名却还说没有中文名」+ 枚举拼错拦一道
|
||
|
||
用户报:原来「租车」那行,把导出值改成 `MKPOICategoryCarRent`、再手工添了显示名「租车」,
|
||
保存后页面仍显示「还没有中文名」。
|
||
|
||
**根因 1(真 bug)**:`Missing` 判定只看内置表(`categoryLabelMissing(v)`),
|
||
**没看有没有自定义覆盖** ⇒ 填过了还照样提示。改成 `categoryLabelMissing(c) && catNames[c] == ""`。
|
||
补了回归测试 `TestCategoryLabelMissingRespectsOverride`,并做了变异验证(去掉那半个条件 → 测试报错)。
|
||
|
||
**根因 2(用户真正踩的坑)**:`MKPOICategoryCarRent` **不是真机的枚举**,正确是 `MKPOICategoryCarRental`
|
||
(少了个 `al`)。站内看不出任何异常,但导回手机后那个地点显示不出类别——正是上一轮提醒过的风险。
|
||
|
||
**加的一道防线**:编辑页把内置枚举名渲染成 JS 数组 `KNOWN`,`okEnum(v)` 在**写 poi_category 的两个入口**
|
||
(类别台账的「改名」按钮、地点行内编辑的「自定义类别」提交)都拦一下:
|
||
不在 KNOWN 里就 confirm,还能猜「你是不是想写 MKPOICategoryCarRental」(双向前缀匹配)。
|
||
中文值不拦(不是枚举),已知枚举不拦(返回 true 不打扰)。
|
||
实测:普通提交/自定义中文/已知枚举都不拦,拼错的才拦。
|
||
|
||
**数据现状(用户的数据,等他们决定)**:地点 id=2290 `大理星羽阁精品民宿(古城店)`
|
||
(原来是「租车点」)的类别现在是 `MKPOICategoryCarRent`;还有一条孤儿覆盖行
|
||
`category_labels.MKPOICategoryCarRent → 租车`。正确做法是把导出值改成 `MKPOICategoryCarRental`
|
||
(内置名就是「租车」,覆盖行随即可删)。
|
||
|
||
### ⚠️ 教训:用脚本改模板时 `\n` 会被多转义一层
|
||
|
||
我通过 `Bash` + Python heredoc 往 `edit.html` 写 JS 时,写的是 `'\n\n你去…'`,
|
||
结果落到文件里变成**真换行** ⇒ JS 单引号字符串含裸换行 ⇒ **整块 `<script>` 语法错误**,
|
||
页面上所有交互静默失效。而 `TestPageTemplatesParse` 照样通过(模板语法本身没问题),
|
||
`present_files`/构建也不报错——只有 CDP 探针的「JS 报错」和实际点不动才发现。
|
||
|
||
三条处置:
|
||
1. **改含转义的文本一律用 Edit 工具**(它按字面写入),或 Python 里用 `chr(92)+'n'` 显式拼;
|
||
2. 新增守卫测试 `inline_script_test.go`:扫模板里所有内联 `<script>`,**发现字符串跨行就报错**
|
||
(带文件名+行号)。跳过注释/模板串/`{{…}}`。已做变异验证(放一个坏样本 → 精准报错)。现在扫 31 个脚本块。
|
||
3. **改完模板必须用 CDP 看「JS 报错」是否为空**——这条已在技能里,但这次才是它真正救命的场景。
|
||
|
||
### 排查「导出包导不回 rond」
|
||
|
||
用户给了一个从**生产环境**导出的包(`data/20260914_151649-export-20260916(1)-export-20260917-export-20260920.rondbackup`)
|
||
导回手机失败,要我和真机原版 `data/20260914_151649.rondbackup` 对比。
|
||
|
||
**排查方法(可复用)**:先跑现成工具,再补三项工具没覆盖的检查。
|
||
1. `tools/verify_export_structure.py rondbackup <原版> <导出>` → 表/索引/CREATE TABLE 逐字符、
|
||
`integrity_check`、`foreign_key_check`、关键不变量、派生列重算 —— **结构一致性:通过**。
|
||
2. `tools/verify_rondbackup.py` 逐字段比对 → 差异全部能解释成「用户在站点上的编辑」(改名/新增/删除/设类别)。
|
||
3. 工具没覆盖、我补查的:
|
||
- **NOT NULL 违例**(`integrity_check` 和 `foreign_key_check` 都不查这个!真出的话 Core Data 打不开)
|
||
- `Z_METADATA.Z_PLIST` / `Z_UUID` / `Z_MODELCACHE.Z_CONTENT` 的**字节哈希**(技能只要求「相等」,工具只比了行数)
|
||
- `Z_PRIMARYKEY.Z_MAX` vs 各表实际 `MAX(Z_PK)`
|
||
- **所有列**逐值比对(不只技能清单里那几列)→ 只暴露出时间戳最后一位差 ~0.1µs,
|
||
是 PG `timestamptz` 只有微秒精度造成的,在技能认可的 ≤1ms 容差内,**不是 bug**
|
||
- 确定性:同数据导两次,`sha256` 完全相同 ✓
|
||
- zip 条目顺序(我们 `sqlite,wal,shm` vs 真机 `sqlite,shm,wal`,都是 Store 不压缩)
|
||
- `ACHANGE`(4810) 内容、`ZRAWVISIT` 抽样、78 个 sqlite 对象(含 sqlite_ 内部)── 全一致
|
||
|
||
**找到的唯一实质缺陷**:新建的交通方式「步行」导成 `ZTRANSPORT.ZCOLOR_=NULL / ZICON_=NULL`,
|
||
而真机原有 5 条**全部**有值(`cyan`/`cablecar.fill` …)。真机这两列**从不写 NULL、也不写空串**。
|
||
成因链:站点不单独维护交通方式表(颜色/图标是从 `movements` 上聚合的)→ 建方式时没选就是空
|
||
→ `queryTransports` 的 `FILTER (WHERE ... IS NOT NULL)` 聚成 NULL → `ns()` 把空串/NULL 都变 nil → 写进包里是 NULL。
|
||
|
||
**修法**(`internal/rond/export.go`):**只对新建的行**(`!known["ZTRANSPORT"][src]`)补占位
|
||
`gray` / `circle.fill`;**基线已有的行不碰**——那种情况 `keepNilCols["ZTRANSPORT"]` 会保留真机原值,
|
||
擅自补占位反而会把真机的真值盖掉。与「新建活动补 `blue`/`📍`」同一套做法。
|
||
|
||
**新增通用守卫** `internal/web/export_placeholder_test.go`:判据是
|
||
「**基线里 100% 有值的列,导出里的新行也不许为空**」——对所有托管表逐列扫。
|
||
这才是这类问题的根治办法(单点修 ZTRANSPORT 只是治标)。已做变异验证(关掉修复 → 精准报错)。
|
||
|
||
**没法部署时的临时办法**:在编辑页把用了「步行」的**任意一条行程**的方式颜色/图标补上保存
|
||
(`editMovementUpdate` 只改那一条,但聚合取「最新一条非空的」),再重新导出,`ZCOLOR_/ZICON_` 就有值了。
|
||
|
||
### 真机报「备份恢复失败,你的当前数据未被更改」→ 找到悬空关系
|
||
|
||
用户重试仍失败,真机只给一句「错误,备份恢复失败,你的当前数据未被更改」。
|
||
「当前数据未被更改」说明它是**先校验、整体回滚**,即在校验阶段被拒。
|
||
|
||
**关键醒悟**:**Core Data 的 SQLite 表里没有真正的外键约束**(关系由 Core Data 自己维护),
|
||
所以之前跑的 `PRAGMA foreign_key_check` 报 0 违例**毫无意义** —— 它压根没有可检查的约束。
|
||
必须自己按「关系列 → 目标表 Z_PK」手查悬空引用。
|
||
|
||
手查结果(8 组关系列全扫):
|
||
|
||
```
|
||
原版:悬空引用 0
|
||
导出:⚠️ ZRAWVISIT.ZVISIT → ZVISIT 悬空 2 条 (2051→到访46, 465→到访200)
|
||
```
|
||
|
||
正是用户在站点上删掉的那两条到访(46、200):`ZRAWVISIT`(原始 GPS 采样点)是**不托管实体**、
|
||
导出原样保留,删到访后就留下了指向已删记录的悬空引用。原版 0 条,且真机删到访时采样点跟着**级联删**。
|
||
|
||
**修法**(`internal/rond/export.go`,`visit.finish()` 之后):删掉 `ZVISIT` 指向不存在到访的
|
||
`ZRAWVISIT` 行。选「删」而不是「置空 ZVISIT」的理由:真机「未归并采样点」虽有 1461/2246 条(置空合法),
|
||
但**留成未归并会让 app 重新推出一条到访、把用户删掉的记录复活**。
|
||
|
||
**顺手加固**:
|
||
- `tools/verify_export_structure.py` 新增 1.45 节「关系完整性」手查悬空(8 组关系列),
|
||
有悬空就把结论标成「有差异」——`foreign_key_check` 那条查不出来的坑,从此进标准验收清单。
|
||
- `internal/web/export_placeholder_test.go` 加 `TestExportDropsOrphanedRawVisits`:
|
||
删一条带采样点的到访 → 导出 → 断言没有悬空。已做变异验证。
|
||
|
||
**为了让用户在部署前就能验证**,直接对那个失败包做了两份修复包(`data/` 下):
|
||
A = 只修数据(保持原条目顺序);B = 修数据 + 条目顺序对齐真机(sqlite, shm, wal)。
|
||
两份都复核过:结构逐字符一致、悬空 0、交通方式空值 0、采样点 2246→2244。
|
||
|
||
### ✅ 结案:A 包导入成功,根因确认是悬空引用
|
||
|
||
用户实测 `data/20260920-fixed-A-数据修复.rondbackup` **能正常导入**。
|
||
|
||
- A 包 = 你那个失败包 + 清 2 条悬空采样点 + 补交通方式颜色/图标,**zip 条目顺序保持原样**
|
||
⇒ **条目顺序不影响真机导入**(B 包不用试了,可以删)
|
||
- ⇒ 根因就是**悬空关系引用**(`ZRAWVISIT.ZVISIT` 指向已删到访)被真机的校验拒掉
|
||
- A 里同时含「交通方式补颜色/图标」,所以严格说没单独隔离出它的影响;但悬空是**关系完整性**问题,
|
||
正是 Core Data 校验的对象,主因没有疑问。**不再让用户复做归因实验**(价值低、还要覆盖手机数据)
|
||
|
||
**今天的两个修复都在代码里了**:`56c7283`(新建行不留 NULL)、`78865fc`(清悬空采样点)。
|
||
**但都还没 push、没部署** —— 生产站点的导出包仍然会带悬空引用,除非部署后重导。
|
||
|
||
### 技能 `rond-backup-parse` 的四处修正(今天的实测推翻了里面的旧断言)
|
||
|
||
1. 保真度清单**补上「关系完整性」**(手查悬空,必须 0)并加了专节;
|
||
2. 明确警告 **`foreign_key_check` 在这个库里永远报 0**(Core Data 没有外键约束)——
|
||
旧版把它列进了必查项,等于给了个假的通行证;`integrity_check` 也不查 NOT NULL;
|
||
3. 旧的「时间往返**零差异**,出现 1µs 偏差就是精度写法有问题」**是错的**:
|
||
实测 ~625 条到访带 0.1µs 偏差(PG `timestamptz` 只有微秒精度),**而这样的包能导入**。
|
||
改成「秒级零差异 + 说明 µs 偏差的成因,别去修」;
|
||
4. 「新建行」那条补上机械判据:**基线里一条空值都没有的列,新行也不许留空**。
|