用户要求这份记忆跟着仓库走,而不是只留在本机。 .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`)。
20 KiB
2026-09-20
补全页:手工填过的值自动进下拉候选
用户报「手工填了之前没有的活动/类别,后面同类地点还得再手工打一遍,效率低」。
根因:下拉候选是页面渲染时定好的(活动来自 activities 表、类别来自静态表),
本页新填的值在提交前进不了候选。
做法(全部在 templates/admin/fillup.html 的脚本里,后端没动):
sessionStorage['rond-fillup-newopts']存{act:[],cat:[]};本页文本框里的值 + 从别处带来的值, 一起补进同类下拉,插在建议之后、真候选之前(不沉到列表最底下),标成「新填:X」,带data-local标记- 本页打字即生效;翻页/「用高德分析」重载也留得住(重载时读回来重新 paint);提交成功后清掉
- 先 paint 再恢复草稿:草稿里选中的可能就是新填的值,选项不在跟前
select.value设不进去 - 草稿保存改成按 name 合并(
delete掉本页明确清空那些),翻页不再覆盖上一页;分页链接也接了保存 - 重画候选前要留住「这一格已选中的新填值」再还回去,否则改一下文本框会把别的行选好的值抹成空
两个坑:
- 文本框监听不能委托到
document:new Event('input')默认不冒泡,探针第一次就是这么假绿的。 直接box.addEventListener更稳,也少一层隐式约定。(用户真实打字是冒泡的,但别赌这个) - 验收时我用了新的
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 种恰好全都在用,所以「真机用过」组为空。
写测试时踩了两次自己的口径错误(都不是产品的错):
- 数「值出现几次」时把整页数了——下拉每行渲染一份,30 行就是 30 次。改成先截出第一个
<select>…</select>再数。 - 断言「餐厅(在用 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 报错」和实际点不动才发现。
三条处置:
- 改含转义的文本一律用 Edit 工具(它按字面写入),或 Python 里用
chr(92)+'n'显式拼; - 新增守卫测试
inline_script_test.go:扫模板里所有内联<script>,发现字符串跨行就报错 (带文件名+行号)。跳过注释/模板串/{{…}}。已做变异验证(放一个坏样本 → 精准报错)。现在扫 31 个脚本块。 - 改完模板必须用 CDP 看「JS 报错」是否为空——这条已在技能里,但这次才是它真正救命的场景。
排查「导出包导不回 rond」
用户给了一个从生产环境导出的包(data/20260914_151649-export-20260916(1)-export-20260917-export-20260920.rondbackup)
导回手机失败,要我和真机原版 data/20260914_151649.rondbackup 对比。
排查方法(可复用):先跑现成工具,再补三项工具没覆盖的检查。
tools/verify_export_structure.py rondbackup <原版> <导出>→ 表/索引/CREATE TABLE 逐字符、integrity_check、foreign_key_check、关键不变量、派生列重算 —— 结构一致性:通过。tools/verify_rondbackup.py逐字段比对 → 差异全部能解释成「用户在站点上的编辑」(改名/新增/删除/设类别)。- 工具没覆盖、我补查的:
- NOT NULL 违例(
integrity_check和foreign_key_check都不查这个!真出的话 Core Data 打不开) Z_METADATA.Z_PLIST/Z_UUID/Z_MODELCACHE.Z_CONTENT的字节哈希(技能只要求「相等」,工具只比了行数)Z_PRIMARYKEY.Z_MAXvs 各表实际MAX(Z_PK)- 所有列逐值比对(不只技能清单里那几列)→ 只暴露出时间戳最后一位差 ~0.1µs,
是 PG
timestamptz只有微秒精度造成的,在技能认可的 ≤1ms 容差内,不是 bug - 确定性:同数据导两次,
sha256完全相同 ✓ - zip 条目顺序(我们
sqlite,wal,shmvs 真机sqlite,shm,wal,都是 Store 不压缩) ACHANGE(4810) 内容、ZRAWVISIT抽样、78 个 sqlite 对象(含 sqlite_ 内部)── 全一致
- NOT NULL 违例(
找到的唯一实质缺陷:新建的交通方式「步行」导成 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 的四处修正(今天的实测推翻了里面的旧断言)
- 保真度清单补上「关系完整性」(手查悬空,必须 0)并加了专节;
- 明确警告
foreign_key_check在这个库里永远报 0(Core Data 没有外键约束)—— 旧版把它列进了必查项,等于给了个假的通行证;integrity_check也不查 NOT NULL; - 旧的「时间往返零差异,出现 1µs 偏差就是精度写法有问题」是错的:
实测 ~625 条到访带 0.1µs 偏差(PG
timestamptz只有微秒精度),而这样的包能导入。 改成「秒级零差异 + 说明 µs 偏差的成因,别去修」; - 「新建行」那条补上机械判据:基线里一条空值都没有的列,新行也不许留空。