Files
rond-with-you/.workbuddy/memory/2026-09-20.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

254 lines
20 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-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. 「新建行」那条补上机械判据:**基线里一条空值都没有的列,新行也不许留空**。