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

20 KiB
Raw Blame History

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 附近: 「真机自己也会写中文类别(一般道路/彩票店/美食街/租车点)⇒ 这列本来就是自由文本」)。 我从「库里看到中文值」直接推成了「真机写的」,没查这些值到底是谁写进去的。

实测(活跃数据集):

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