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

53 KiB
Raw Blame History

2026-09-15

rond-with-you:修复静态资源被截断(ERR_CONTENT_LENGTH_MISMATCH)

现象

浏览器控制台大量报错,页面样式/脚本失效:

GET /static/vendor/leaflet/leaflet.css?v=... net::ERR_CONTENT_LENGTH_MISMATCH 206 (Partial Content)
GET /static/css/app.css?v=...              net::ERR_CONTENT_LENGTH_MISMATCH 206
GET /static/js/rond-map.js?v=...           net::ERR_CONTENT_LENGTH_MISMATCH 206
Manifest: Line 1, column 1, Syntax error.
Uncaught TypeError: Cannot read properties of undefined (reading 'preview')   ← home.html 里 window.rondMap.preview(...)

根因(两层,缺一不可)

gzip 中间件(internal/web/gzip.go)与 http.FileServer 冲突:

  1. 206 层:浏览器对 immutable 缓存的静态资源会发 Range 请求 → http.FileServer 返回 206 并自带 Content-Length;gzip 无法在首部提交后再改长度 → 长度不符,文件被截断。
  2. 200 层(更隐蔽):http.FileServer(serveContent) 对任何请求都是 先设 Content-Length、 再显式调用 WriteHeader(200/206)、最后才写 body。原 gzipWriter 没有覆盖 WriteHeader, 所以 WriteHeader(200) 已经把 Content-Length: <未压缩长度> 提交出去;等到 Write 里才 Set("Content-Encoding","gzip") + Del("Content-Length") 全都太晚 → 响应既没有 Content-Encoding、 又声明了未压缩长度,body 却是 gzip → 浏览器按声明长度读不到那么多字节 → 截断。 (动态页面 s.render/s.json 不显式 WriteHeader、也不设 Content-Length,所以一直是好的—— 这解释了「页面 HTML 正常、静态 CSS/JS 全坏」。之前重编译后 ?v= 哈希变化强制重新拉取才暴露。)

修复(internal/web/gzip.go)

  • 给 gzipWriter 增加 WriteHeader 覆盖:在首部提交之前决定压不压。 只有 200 且 compressible(Content-Type) 才设 Content-Encoding: gzip + Del Content-Length; 206/304/404/500 等一律原样透传(自带长度语义,不能压)。
  • Write 路径保留「无显式 WriteHeader 时按首字节推断 Content-Type」的逻辑;gz 改懒初始化。
  • gzipHandler 额外加一道 Range != "" 直接透传的守卫(双保险)。

验证(curl 实测,全部通过)

  • 普通静态请求:200 + Content-Encoding: gzip、无 Content-Length、body 解压后 = 完整 21587B ✓
  • Range 请求:206、Content-Length: 100 = 实际 100B、无 Content-Encoding ✓
  • curl --compressed 全量拉 app.css = 14643B(完整)✓;manifest 解压后是合法 JSON ✓
  • PNG 图标:200 + image/png + Content-Length: 1492、无 Content-Encoding(透传未被压)✓
  • 首页 --compressed = 99259B 完整 HTML;/ /map /report /stats /places /timeline /api/cities /api/track 全 200 ✓

教训

Go 里做 gzip 中间件,必须覆盖 WriteHeader,否则任何「先 WriteHeader 再写 body」或 「显式设置 Content-Length」的 handler(典型如 http.FileServer/http.ServeContent)都会踩坑。 压缩决策要在首部提交前完成,不能拖到第一次 Write。


推荐 / 踩雷 投票 + 备注批量管理(A/B/C 三批次,均实测)

需求 & 决策(用户拍板)

  • 备注与「踩雷 / 推荐」分开:踩雷/推荐改成按钮式,统计里单独计。
  • 投票主体:站长官方结论 + 访客投票计数并存;展示:详情 + 地点库 + 地图; 备注管理:改为「全部地点可编辑」(含新增 / 批量)。

数据模型(internal/db/schema.sql)

  • place_votes(user_id, src_pk, verdict, updated_at):站长结论,verdict 1 推荐 / -1 踩雷,单值。
  • place_visitor_votes(src_pk, voter_key, vote, updated_at) PK(src_pk, voter_key):访客一人一票。
  • 都按 src_pk 存(与 place_notes 一致),换快照不丢。

批次 A:投票后端 + 详情页卡片

  • 新包 internal/votes:Verdicts/Verdict/SaveVerdict、Tallies/Tally、Voter、Cast (Cast 逻辑:没投过→插入;投同一票→撤销;投另一票→改票)。
  • internal/web/handlers_vote.go:voterKeyFromRequest / ensureVoterKey(cookie rond_voter
    • IP 一起 sha256 取前 16 字节作 voter_key,清 cookie 也认同一来源,轻度防刷);clientIP 取 XFF。 apiVotes(GET /api/votes)、apiVote(POST /api/vote)、adminPlaceVote(POST /admin/place/vote)。
  • setting 加 MarksPublic(marks_public,默认 true)+ 设置页勾选项。
  • 详情页 place.html:备注卡片下独立「推荐 / 踩雷」卡片;站长区是表单(提交即刷新,无需 JS), 访客区是 AJAX 投票(就地刷新票数);备注框删掉「踩雷还是推荐」占位文案。
  • 实测:匿名看不到站长控件、管理员可标结论、访客投票 up→撤销→down 正确。

批次 B:地点库 / 地图 / 统计

  • stats.Filter 加 Verdict,stats.Places 用 EXISTS(place_votes ...) 支持按结论筛选 (占位符接在内层条件之后,args 复用)。
  • stats.Place 加 Verdict/Up/Down;web.attachVotes 查询后填充(按 src_pk)。
  • Page 加 Verdict 字段 + filterPairs + VerdictLink;places.html 加标记筛选 chips + 行内徽章。
  • MapPoint 加 sp/vd/u/dn(omitempty);rond-map.js popup 显示结论与票数。
  • stats.html 加「推荐 & 踩雷」区块(VoteRec/VoteAvoid/VoteUp/VoteDown + 两个榜单)。
  • 实测:列表徽章、?verdict=±1 筛选、统计区块、api/points 字段全部正确。

批次 C:备注管理页重写

  • 数据源从 place_notes 改为「places LEFT JOIN place_notes LEFT JOIN place_votes」, 筛选 q/status(all|has|none)/city/sort(visit|note|talk|name) + 分页(50/页); 底部单列「孤儿备注」(src_pk 已不在当前数据集)。
  • NotesData 加 Orphans/CityOptions/Status/City/Sort/BackURL + PageLink 方法(保留筛选)。
  • 端点:POST /admin/notes/bulk(action=delete|clear|verdict);notes 存储加 SetNote(保留别名) / Delete。
  • notes.html 重写:关键点——批量复选框用 form="bulk-form" 关联到独立的批量表单, 避免「批量表单」与「每行编辑表单」嵌套(HTML 不允许嵌套 form);行内保存/打标用同一表单 + formaction 分流到 /admin/place/note 或 /admin/place/vote。
  • adminPlaceNote 优先用表单 next(带完整筛选)回跳,其次 Referer。
  • 实测:323 地点全列出、status=has/none 筛选、行内保存、批量设置结论/清空备注(保留别名)/删除 全部正确。

踩坑

  • 嵌套 <form> 不合法:用 HTML5 form="id" 属性把行内复选框关联到外部批量表单,可绕开。
  • omitempty 会让 0 值字段(vd/u/dn)从 JSON 消失,grep 校验时要注意,别误判为没数据。
  • 内网 Git Bash curl -o /dev/null 在多命令 && 链里偶发 exit 23 中断后续命令,改用 -o 文件。

投票前移到地图气泡 + 备注只读展示 + 备注保存回跳(实测通过)

需求(用户原话要点)

  1. 点赞/点踩直接放到地图上点开的那个气泡里,属快速交互,不必再进详情页。
  2. 地图气泡:登录时显示「我的结论」(站长的 👍/👎),未登录显示「大家怎么说」(票数)。
  3. 顺带解决:在详情页输入备注但未提交时去点点赞点踩,会把备注框内容冲掉。
  4. 备注内容要能在气泡里直接看到(只读,不用编辑)。
  5. 提交备注后跳到 /admin?ok=备注已保存,与来源页无关,要跳回原处。

实现

  • 后端
    • MapPoint 加 Note string json:"note,omitempty",toPoint 带上 p.Note(备注是否可见仍由 applyNotes(..., IsAdmin || Settings.NotesPublic) 把关,访客在 notes_public=false 时拿不到)。
    • votes.VoterAll(ctx, voterKey) map[int]int:一次取回该访客投过的全部票,供地图标「我投的」。
    • votePayload 加 Admin / Public 两个布尔;apiVotes 填充(userFrom(ctx)!=nil 为 admin)。 前端据此决定气泡里渲染「我的结论」还是「大家怎么说」,不用在页面里另塞全局变量。
    • 新增 POST /api/verdict(requireAdmin → apiVerdict):气泡里 AJAX 设站长结论,不刷新页面。 注意:handler 用 r.PostFormValue,参数必须走 POST body(ParseForm 不把 query 并进 PostForm)。
  • 前端 rond-map.js
    • 新增模块级 voteData/voteMeta/voteLoad + loadVotes():进地图取一次 /api/votes, 气泡渲染时同步查表,不为每个点单独发请求;到货后 repaintVotes() 刷新已打开的气泡。
    • popup(p, vote) 改为构造 DOM 节点;bindPopup(function(){ return popup(p, vote); }) 用函数式内容 (已验证 vendored leaflet 支持 _content?this._content(this._source||this...)),这样每次打开都重算 「我投的」,避免字符串闭包里的陈旧票数。
    • 气泡结构:.mp > .mp-n / .mp-m / .mp-note(white-space:pre-line 只读直出) / .mp-foot + .mp-vote(.mp-vl 标签 + .mp-vb 按钮)。管理员点已选中按钮 = 取消(verdict=0)。
    • markers(points, crs, useCluster, vote) 多一个 vote 参数,state4 存 this.vote 并透传给 apply(),setPoints 也传 true —— 切底图坐标系重画标记时必须带上,否则表决区会消失。
  • 详情页 place.html
    • 备注表单加 <input type="hidden" name="next" value="/places/{{.Place.ID}}"> → 保存后跳回本页 (/places/49?ok=备注已保存),并在卡片里渲染 .Flash(placePage 只在 IsAdmin 时读 ?ok=)。
    • 站长结论从「表单提交」改为 AJAX(POST /api/verdict):原来提交会整页刷新,把没保存的备注冲掉, 这正是需求 3 的根因。
    • 删掉不再使用的 .vote-form CSS。
  • 顺手修:full() 原本没有 return,map.html 里 window.__map = window.rondMap.full(...) 一直是 undefined(虽无人读)。现在 full() 返回 Leaflet map。

验证(本机 Chrome + CDP 真实点击,非静态推断)

  • 管理员:气泡显示备注「测试」+「我的结论」+ 👍 推荐[选中];点 👎 → 气泡立即变 👎 踩雷[选中], /api/votes 变 {"49":-1};点 👍 回到 1。无 JS 报错。
  • 访客(清 cookie):admin:false,气泡显示「大家怎么说」👍0 👎1;点 👍 → 👍1[选中],再点 → 撤回 👍0。 备注 noteText=None —— notes_public=false,访客确实拿不到,符合预期。
  • curl 侧:备注保存 303 → Location: /places/49?ok=备注已保存;匿名 POST /api/verdict 被 requireAdmin 拦(303)。

环境经验(踩坑)

  • agent-browser 在本机卡死(open 挂了 7+ 分钟无输出,估计要 agent-browser install 下 Chromium)。 更省事的路子:本机 Chrome 已装,直接起 chrome.exe --headless=new --remote-debugging-port=9333 --remote-allow-origins=* --user-data-dir=<tmp> 再用 Python websocket-client 走 CDP(Runtime.evaluate / Network.setCookie / Page.navigate)。 必须加 --remote-allow-origins=*,否则 WS 握手 403。
  • 测试脚本要轮询等待而不是固定 sleep:setView(17) 让聚合散开后 openPopup() 才有效, 并且要重试直到 .leaflet-popup .mp 真的出现在 DOM 里(否则偶发抓不到气泡)。

rond 备份备注 → 地点备注(回填,实测通过)

  • 取证:rond 的备注在 ZVISIT.ZREMARK_ → visits.remark,per-visit。当前活动数据集 14 条、 覆盖 12 个地点(src_pk=286「上班」3 条:拿快递 / 充电 / 充电);place_notes 本来只有 1 行。
  • 用户拍板:① 多条全部保留,每条一行带日期(去重);② 覆盖但旧内容追加在后面; ③ 导入时自动 + 备注页手动按钮。
  • 落地方案:单独一列 place_notes.rond_note 存回填结果。 这是唯一能同时满足「幂等 + 不销毁手写内容 + rond 在前」的做法:回填只重写这一列, 手写 note 一个字都不动,展示时 joinNote(rond, hand) 按「rond + 空行 + 手写」拼接。 (若直接往 note 里追加,每次重跑都会把上一次的结果再追加一遍,无限膨胀。)
  • notes.BackfillRond(ctx, userID, datasetID):
    • 汇总 SQL 两层去重——DISTINCT (src_pk, line),line = to_char(arrival,'YYYY-MM-DD') || ' ' || btrim(remark), 再 string_agg(line, E'\n' ORDER BY day, line)。同一天写的同一句只留一条。
    • 顺带把「备注已从 rond 删掉」的地点清空(幂等),且限定在本数据集的地点内: dataset_id 不合法时子查询为空 → 一行都不动,不会误清别的数据集。
    • 不写 updated_at(那是手写备注的编辑时间)。日期用完整 YYYY-MM-DD:数据跨 2025/2026,MM-DD 会歧义。
  • notes.Save:两者都空时不再无条件删行——先 DELETE ... AND rond_note='',再 UPDATE SET alias='',note=''。这样批量「清空备注」不会把 rond 那一块一起清掉。
  • web.applyNotes 用 joinNote 拼装展示值(详情页/地点库/地图气泡/接口都走这里)。
  • notesPage:搜索加 n.rond_note ILIKE;status=has|none 与 HasNote 都把 rond_note 算进去。 行内多一块 来自 rond:...(.note-block,white-space: pre-line)。
  • 触发点:adminUpload 导入成功后回填(并在 flash 里报「已从 rond 备注回填 N 个地点」)、 adminActivate 切换数据集时重算、POST /admin/notes/rond 手动按钮。
  • PlaceData 拆成 MyNote(编辑框里的手写内容)+ RondNote(只读展示), 详情页站长卡片把 rond 那一块单独列在表单上方——否则编辑框里会看到拼好的全文,一保存就把 rond 行也写进手写备注。
  • 实测:手动回填 → 12 个地点;286 = 2026-08-22 拿快递\n2026-08-23 充电(重复的「充电」已去重); 重跑幂等;手写备注 和室友一起去的 保留且排在后(2025-07-19 拍日月塔\n\n和室友一起去的); 浏览器实测:气泡 white-space: pre-line、2/3 行正确渲染,详情页编辑框只含手写内容、next 回跳本页。
  • 遗留:place_notes 里 src_pk=49(来宾北站)还有一条 note=测试,疑似早期测试残留,未擅自删除。

地图气泡尺寸 + 投票按钮重复(本轮修复,实测通过)

  • 气泡太窄 → 推荐/踩雷换行:真因是 Leaflet .leaflet-popup 默认 maxWidth≈300, 把 .mp 挤到 169px。修法:bindPopup(..., {maxWidth: 360, minWidth: 240}); 并在 CSS 上 .mp-vl/.mp-vb { white-space: nowrap; flex: 0 0 auto; } + .mp-vote { flex-wrap: nowrap; }。 实测桌面 1440 / 移动 390:气泡宽 241px,按钮 top 坐标唯一(不换行)。
  • 详情页站长同时看到「我的结论」+「大家怎么说」:模板原来把两块都渲染在 {{if or .IsAdmin .Settings.MarksPublic}} 里。改为:{{if .IsAdmin}} 只出「我的结论」+ 只读 访客票数 👍/👎;{{else}} 只出「大家怎么说」。实测站长 vote-row 数=1。
  • 同类隐藏 bug(备注页):行内 👍/👎 原是 formaction=/admin/place/vote 提交, 会整页刷新 → 丢掉同一行备注框里未保存的编辑(与详情页同一病根)。改为 AJAX POST /api/verdict (data-verdict-btn + data-src/data-v,toast 由 [data-verdict-status] 局部更新,不刷新页面)。
  • /api/verdict 取参数:必须 r.PostFormValue(...)(POST body),前端要放 body 不能放 query。
  • rond-map.js preview(opts):只有 opts.vote === true 才 loadVotes() + 传 vote。 否则详情页/picker 里的小地图气泡也会长出投票按钮。full() 仍 vote:true。
  • 验证方式:headless Chrome + CDP 真点击(见上文环境经验)。全部无 JS 报错。

详情页「访客票数」只读行样式统一(本轮,实测通过)

  • 反馈:站长详情页里 访客票数 👍0 👎0(只读) 是 12.5px 纯文本 + margin-top:6px, 比上面「我的结论」那一行(按钮 13.5px、padding 6/14、行高 27.5px)小一圈,看着不整齐。
  • 改法:不再单独搞 .vote-tally,直接复用 .vote-row 结构—— 访客票数 用 .vote-label,两个数字用 .vote-btn.ro(<span>,只读变体),末尾 .vote-tip 放「只读」。 CSS 只保留 .vote-btn.ro { cursor:default; color:var(--text-2); border-style:dashed; background:transparent }
    • 覆盖它的 :hover(不然会继承 .vote-btn:hover 的强调色,像可点)。
  • 实测(CDP 量出来的,不是肉眼看):两行 rowH=27.5、labelFont=12.5px、labelLH=20px、 margin-top=8px、按钮字号 13.5px 全部一致;1440 与 390 两种视口都对得上,无 JS 报错。
  • 用虚线边框 + cursor:default 区分「不可点」,比整块 opacity:.55 干净。

无密码状态下登录站长视图(本次新摸出来的路子,很好用)

  • 之前那套 storage/2fa-cookies.txt 里的 cookie 是 uid=1,但库里根本没有 id=1 的用户 (管理员是 id=2 / admin),所以带着它访问只会掉进访客分支。
  • 自己签一个会话 cookie 即可,不用知道口令: token = "<uid>|<exp>|" + base64_raw(hmac_sha256(secret_key, "<uid>|<exp>|" + password_hash[:16]))
    • secret_key 在 conf/app.ini;password_hash 用 psql 从 users 表取。
    • 逻辑见 internal/auth/auth.go 的 SignToken/mac(base64 是 RawStdEncoding,无 = 填充)。
  • CDP 两处坑(补上次的记录):
    1. /json/version 给的是 browser 级 WS,调 Page.enable 会报 -32601 wasn't found; 要用 /json 列表里 type=="page" 那个 webSocketDebuggerUrl。
    2. --window-size=390,844 在 --headless=new 下不会给出 390 的真实视口 (量出来卡片宽 457)。要 Emulation.setDeviceMetricsOverride(width,height,deviceScaleFactor,mobile=width<=500)。
    3. captureScreenshot(clip=...) 的 clip 是文档坐标,不是视口坐标——滚动过就要 rect.y + scrollY,否则截到别处空白;且截图前要 scrollIntoView({block:'center'}) 再等 ~0.5s,不等会得到半张空白 / 文字重叠的鬼影图。
  • 上述这套(含上面的自签 cookie)已沉淀成 skill rond-web-verify, 脚本 ~/.workbuddy/skills/rond-web-verify/scripts/site_cdp.py,下次直接调,不用再摸一遍。

移动端 .kpis 统计格子高矮不一(本轮,实测通过)

  • 反馈:/places/196 详情页移动端 2×2 统计格,第一行明显比第二行高。
  • 根因(实测复现,不是推断):.kpi .v 固定 font-size: 23px,「112天19小时」在窄屏放不下 就折成两行,把那一整行撑高——量出来 row1=124.6 / row2=87.8,差 36.8px 正好一个 line-height。 临界宽度在 340~350px 之间(330 时两行都折,看起来反而"齐";350 时只有停留那格折 → 就是你截图的样子)。 2025-05-30 这种数字窄,先撑不住的是中文字多的 dur 值(fmtDur 输出最长约「1234天23小时」9 字符)。
  • 改法(internal/web/static/css/app.css 的 .kpis / .kpi .v 两处):
    1. .kpis { grid-auto-rows: 1fr } —— 所有格子强行等高,这是兜底保证。
    2. .kpi .v { font-size: clamp(17px, 5.6vw, 23px) } —— 字号随视口收,让长值在窄屏也能一行放下; ≥411px 视口时 5.6vw ≥ 23px,clamp 封顶 → 桌面字号与原来完全一致,零回归。 没用 white-space: nowrap:万一将来出现更长的值,nowrap 会横向溢出卡片,比折行更难看; 靠字号收缩 + 行等高兜底更稳。
  • 实测:320/334/360/390/900 五种视口下 4 格宽高完全一致, 塞入最坏值「1234天23小时」仍是单行;/(8 格)、/stats(7 格)同样全等高、无折行、无 JS 报错; 1440 桌面 4 列、字号 23px 未变。
  • 一句话要记住:「每个 .kpi 是 grid item」不等于「每行等高」——grid-auto-rows 默认 auto, 某一格内容变高,它那一行就变高,另一行不受影响,视觉上就是"高矮不一"。

访客可见区域:白名单 + 黑名单双模式(本轮,实测通过)

  • 背景:原本 settings.regions 是白名单(填了的区域才放行)。用户要黑名单,最终选「加模式开关,两种都留」。
  • 落地点:
    • internal/setting/setting.go:新增 RegionsMode(whitelist/blacklist)+ RegionBlacklist()。 Default() 用黑名单;Load() 在缺 regions_mode 键时一律回退白名单 —— 历史配置里填过的白名单 若被新默认值顺过来,会静默反转成黑名单(本该放行的区域全藏起来),这是唯一有破坏性的点。
    • internal/stats/stats.go:Filter.RegionsExclude 决定 EXISTS(...) 前面加不加 NOT。
    • internal/web/handlers_public.go:regionSQL(regions, exclude, args) 统一给报告页和城市聚合用; filterOf 传 RegionsExclude;新增 regionHidden() 给详情页兜底。
    • 后台:settings.html 加白名单/黑名单单选;handlers_admin.go 只认这两个值。
  • 补掉两个真窟窿(改黑名单后更要紧,黑名单的意义就是藏住的绝不漏):
    1. placePage 原来按 ID 直取地点、完全没走 filterOf → 访客拿到链接就能看隐藏区域的地点。现在返回 404。
    2. apiCityAgg 直接查 places 分组、没挂区域条件 → 隐藏区域的城市名/坐标照样出现在地图上。现在挂上了。
  • 🩸 踩到的 SQL 坑(三值逻辑):黑名单条件下访客的城市列表空了。 NOT (province='x' OR city='x' OR district='x') —— district 常为 NULL,NULL='x' 是 NULL, 整个 OR 变 NULL,NOT NULL 还是 NULL,行被静默丢掉。修法:COALESCE(p.district,'') = $n。 注意 stats.go 那份不需要改:条件包在 EXISTS 里,NULL 只是让子查询查不到行,NOT EXISTS 照样正确。 两处形式不同是对的,已在两处各留一行注释说明原因。
  • 实测(真跑服务 + 真读库):
    • 白名单 [贵阳市]:访客 points=73、/api/cities 只有贵阳市、/places/1(贵阳)=200、/places/196(河池)=404;站长 283/9 城/两个都 200。
    • 黑名单 [贵阳市]:访客 points=210(=283-73)、cities=8(除贵阳)、/places/1=404、/places/196=200;站长全量。
    • 后台表单端到端 POST 存黑名单 → 库里 regions_mode='blacklist' ✓。
    • 还原成原始配置(regions: null、无 regions_mode)后行为回到基线:访客 283/9 城/两个都 200。
  • ⚠️ 测试期间临时改过 settings(改完已还原,并用 JSON 全字段 diff 确认与原值完全一致)。 还原时注意:原始配置里 marks_public 这个键本来就不存在(靠 Load() 的缺键默认值兜成 true), 重建表单提交会把缺失键写成 false 而改变行为 —— 用 value - 'regions_mode' - 'marks_public' 精确还原。
  • 遗留:用户明确说上一轮那两个问题(src_pk=49 的 测试 备注、回填日期格式)都不要动,已停止追问。

黑名单城市彻底隐藏 + 后台说明文案移动端换行优化(本轮,实测通过)

需求 E-1:黑名单城市要从「城市列表」本身消失

  • 上一轮黑名单已过滤地点/地图/统计,但城市筛选下拉框仍显示被拉黑城市,点进去才空结果。
  • 根因:internal/stats/stats.go 的 Cities() 直接查 places 表,完全没走 filterOf / 区域条件。
  • 修法:
    • Cities(ctx, datasetID, regions, exclude) 增加区域参数;省/市/区三级用 COALESCE 匹配(直接查 places 时 district NULL 会触发三值逻辑bug,必须 COALESCE)。
    • pageBase(internal/web/handlers_public.go)对非站长传 p.Settings.Regions + RegionBlacklist();站长传空列表,始终全量。
    • 管理员备注页 handlers_admin.go 调用也传空列表,保证后台筛选城市选项不受区域限制。
  • 实测(当前配置 regions:[河池市], regions_mode:blacklist):
    • 访客 /places 城市下拉只剩 8 城,河池市消失。
    • 管理员 /places 城市下拉仍为 9 城,河池市在首位。
    • 时间线 / 地图的城市下拉共享同一 CityList,同步生效。

需求 E-2:后台「访客可见区域」说明文案移动端换行不美观

  • 原模板用行内 <b style="font-weight:500">只</b> / <b>隐藏</b>,导致移动端 隐藏 被单独 orphans 到一行,像被「分开展示」。
  • 修法:
    • internal/web/templates/admin/settings.html 去掉行内 <b>,改为自然文案:
      • 白名单:访客只能看到这些区域的记录
      • 黑名单:这些区域的记录对访客隐藏,其余照常开放
    • 关键短语加 <span class="nobr"> 保持同框:「只能看到这些区域」「对访客隐藏」。
    • internal/web/static/css/app.css:.check 加 word-break: keep-all; overflow-wrap: break-word;,让中文词组优先不被拆开;新增 .nobr { white-space: nowrap; }。
  • 实测:390px CDP 截图,区域说明两行都按「词组」自然换行,不再出现单字/强调词孤立。

服务状态

  • 重新编译并启动于后台任务 Zy7wUd,home=200,端口 8080 正常。

新功能:编辑导入的 rond 数据 → 导出回 .rondbackup(完整编辑器 + PG 重建导出)

用户诉求:有些记录点当时没带手机,想手动补录(新增点、时间)后导出为与原始格式一致的 .rondbackup。

关键事实(先纠正一个旧假设)

  • rond 的 ZLOCATION 坐标就是 GCJ-02(README.md:84-88,实测 2246 采样点与 GCJ-02 理论偏移残差 0m);站点 places.lat/lon 存的就是它。→ 导出回 rondbackup 无需任何坐标变换(之前以为 rond 是 WGS-84 是错的)。
  • Core Data 主键表真名是 Z_PRIMARYKEY(带下划线),不是 ZPRIMARYKEY。列 Z_ENT/Z_NAME/Z_MAX。实体号:Location=6, Tag=11, Visit=18, Activity=1, ActivityGroup=2, HourlyWeather=3 …
  • 访问↔标签多对多表名形如 Z_11VISITS_,列 Z_18VISITS_(visit FK)/ Z_11TAGS_5(tag FK)。实体号可从 Z_PRIMARYKEY 反查。
  • Core Data 时间戳 = 自 2001-01-01 UTC 起的 float64 秒;往返存在 亚微秒级浮点舍入(实测最大 1.024µs),可忽略。

实现(方案:PG 重建 + 导入期抓取 Core Data 元信息)

  • internal/db/schema.sql:datasets 新增列 coredata_meta JSONB。
  • internal/rond/parse.go:Parse 末尾调 captureMeta 抓 Z_PRIMARYKEY(实体名→Z_ENT/Z_MAX)、全部 Z* 表 DDL、以及用 PRAGMA table_info 定位的访问↔标签关联表(名+两列),存进 Backup.Meta。新增 MetaFromArchive(path) 只抓结构。
  • internal/ingest/ingest.go:Import 把 meta 写进 datasets.coredata_meta(新建快照路径 + 内容相同的复用路径都写)。
  • internal/web/handlers_admin.go:adminUpload 的 sha256 去重短路 分支里,若该数据集缺 meta 就 MetaFromArchive 补抓——这是旧数据集唯一的补抓途径(重传同一文件)。
  • internal/rond/export.go(新):ExportRondbackup(ctx, db, datasetID, meta, dst) 从 PG 按实体重建 LifeEasy.sqlite(用抓取的 DDL 建表,INSERT 部分列,Z_PK=src_pk,appleSeconds 写时间,回写 Z_PRIMARYKEY.Z_MAX),zip 成含 .sqlite/-wal/-shm 的 .rondbackup。写入需注意 PG 布尔列要扫进 bool 而非 int(is_home/is_work/is_bookmarked/is_user_added/is_daylight)。
  • internal/rond/export_roundtrip_test.go(新):用 rond 自己的 Open+Parse 反向解析导出包与原始包逐项比对(env ROND_SRC/ROND_EXPORT)。回归测试保留。
  • 编辑界面:internal/web/handlers_edit.go + templates/admin/edit.html + 路由 GET /admin/edit 及 POST /admin/edit/{place,place/update,place/delete,visit,visit/update,visit/delete,movement,weather};server.go 注册 admin/edit 模板;后台首页加「数据编辑」入口与「导出 rondbackup」按钮。新增行 src_pk=MAX+1、is_user_added=TRUE;改删后重算地点/数据集聚合。
  • 地图选点用站点自带 /tiles/amap-street/{z}/{x}/{y}(GCJ-02,点选即所得)。新增 CSS .inline-edit。

验证结论

  • 往返测试:823 到访 / 323 地点 / 8 标签 / 30 活动 / 791 位移 / 557 天气完全一致,坐标偏差 0,时间偏差 ≤1µs。rawvisits 2246→0(原始 GPS 采样点不导出,属预期)。
  • 补录实测:新增地点 src_pk=324 + 到访 src_pk=825 → 导出包中 ZLOCATION(324)、ZVISIT(825)(含时间/ZUSERADDED=1/标签关联 (1,825))、Z_PRIMARYKEY Z_MAX 正确 bump;改/删/清理均正常,计数回到 323/823。
  • 服务:重新编译,运行于后台任务 N52YdB,端口 8080。
  • 旧数据集需重传一次原始 .rondbackup 补抓 coredata_meta(内容相同也会补抓);未补抓时导出接口会明确提示。

注意

  • storage/ 已 gitignore;导出样本 storage/export_clean.rondbackup 保留备查。

修复:/admin/edit 「新增地点」下方地图空白(用户反馈)

两个叠加的根因(缺一不可,只修一个仍空白):

  1. 脚本时序:layout.html 的 {{template "content" .}}(L140)在 Leaflet <script>(L148)之前渲染。编辑页内联脚本执行时 window.L 仍是 undefined,if (!el || !window.L) return; 直接退出 → 地图容器连 leaflet-container 类都没有。实测证据:tiles:0、容器无 leaflet 类、截图仅剩一个空边框 div。
  2. 瓦片 URL 写错:内联脚本硬写了 /tiles/amap-street/{z}/{x}/{y}。但 handleTile 只转发 s.tiles.upstream 里的图层,而 map_provider=amap 且 map_key 为空时 upstream 是空的,免密钥底图走浏览器直连的 amapStyleStreet(带 {s} 占位符)→ 该路由 404。

改法:

  • internal/web/tiles.go:mapConfig 响应新增 subs(由 tiles.subStart/subCount 生成,amap 为 ["1","2","3","4"]),供前端交给 Leaflet 展开 {s}。
  • templates/admin/edit.html:内联脚本改为 whenReady(document.readyState==='complete' 或等 load 事件)后再 fetch('/api/map-config'),取 base[0] + 顶层 subs 建 L.tileLayer,并保留「直接高德直连 URL」兜底。不再写站内 /tiles/... 路由。

验证(无头 Chrome + CDP,站长会话 uid=2):容器含 leaflet-container、7 个 pane、10 个瓦片全部 loaded,src 为 https://webrd02.is.autonavi.com/...({s} 已展开),JS 报错为空,截图可见中国全域底图。

可复用教训

  • 本项目模板用 //go:embed,改 .html 必须重新编译二进制,否则无头浏览器验证的还是旧页面。
  • 编辑页地图类断言必须先等 load(Leaflet 在 content 之后加载),否则拿到「容器存在但没初始化」的假象。

地理编码(按名称搜坐标)可行性实测

  • 用户想法:「能否直接搜索名称来添加 GPS 坐标」。
  • 实测本机网络:Nominatim(OSM 免密钥)超时不可达(http=000,12s);高德 restapi.amap.com/v3/geocode/geo 可达(无效 key 返回 200)、天地图 api.tianditu.gov.cn/geocoder 可达(无效 tk 返回 400)。
  • 结论:免密钥路线在当前网络下不可行,做地理编码必须申请 key。当前 conf/app.ini 为 map_provider=amap + map_key= 空(无任何 key)。
  • 待用户拍板方案:A 申请高德「Web 服务」类型 Key(返回 GCJ-02,零坐标转换,质量最好,仅用于地理编码,底图维持免密钥高德);B 申请天地图 tk 并把底图也换成天地图(一个 key 管瓦片+地理编码,但底图观感变化,且返回 WGS-84 近似需转 GCJ-02)。key 一律只在服务端代理(新增 /api/geocode),不进前端源码。

新增:「新增地点」按名称搜坐标 + 可选同时建到访(用户拍板方案 A)

用户两点诉求:①「新增地点」没法填到访时间;②想按名称直接搜出 GPS 坐标。

结论修正(用户提问):「安全密钥(jscode)」与我们无关

  • 用户担心「没有安全密钥不能用」——那是高德 Web端(JS API) 的要求。我们走的是 Web服务 类型的 REST 接口 (restapi.amap.com/v3/place/text、/v3/geocode/geo),只需 key,不需要安全密钥。
  • 且底图仍走免密钥瓦片 CDN(webrd0x.is.autonavi.com),根本没碰 JS SDK。→ 配置里只填一个 geocode_key 即可。 申请时在高德开放平台选「Web服务」类型,别选「Web端(JS API)」。

实现

  • internal/config/config.go:新增 GeocodeKey(配置项 geocode_key,可选,留空则功能关闭)。
  • internal/web/geocode.go(新):GET /api/geocode?q=,仅管理员(requireAdmin,防配额被外部消耗); 先打 place/text(POI 关键字,适合地名),无结果再兜底 geocode/geo(结构化地址); 高德返回 GCJ-02,与库内同系,零坐标转换;密钥只在服务端拼接,不进前端源码。 高德字段 province/city/district/address 可能返回空数组而非字符串(直辖市常见),用 amapStr(json.RawMessage) 兼容。
  • templates/admin/edit.html:「新增地点」卡片顶部加搜索框 + 候选列表(点选填入 name/lat/lon/省市区并平移地图); 仅 {{if .GeocodeReady}}(= geocode_key 非空)时渲染,未配置则显示一行配置提示,避免「看着能用却报错」。 地图选点与搜索共用 fillCoord() / mapGoto(),地图未就绪时坐标照样能填。
  • 「可选:同时创建一条到访记录」:用 <details class="opt-visit"> 折叠,含到达/离开时间、活动、备注。 只有填了 arrival 才建到访,否则行为与以前完全一致。
  • handlers_edit.go:抽出 insertVisit(...)(editVisitCreate 与「地点+到访一次提交」共用,保证落库一致); editPlaceCreate 改为 INSERT ... RETURNING id 以拿到新地点 id,再按需建到访。
  • view.go:EditData.GeocodeReady。static/css/app.css:.geo-list/.geo-item/.opt-visit。
  • conf/app.ini:新增 geocode_key = 及说明注释。

验证(无头 Chrome + CDP + psql)

  • 地图:容器含 leaflet-container、7 pane、10 瓦片全 loaded,无 JS 报错;截图确认新 UI 正常。
  • 未配 key:搜索框不渲染,显示配置提示;/api/geocode 返回 {"error":"服务端未配置 geocode_key…"};访客访问返回 303(被 requireAdmin 拦)。
  • 临时填假 key 实测上游:返回 {"error":"高德返回:INVALID_USER_KEY"} → 证明服务端确实打到了高德、参数构造正确、错误能优雅透出(随后已还原 geocode_key 为空)。
  • 补录流程:只填位置 → places(324),visit_count=0、无到访(行为未变);位置+时间 → places(325) + visits(825, place_id=649, arrival 2026-08-01 10:00, departure 12:30, duration_min=150, is_user_added=true)。
  • 用 place/delete 清理测试数据后计数回到 323 / 823,无残留;go test ./... 全绿。

地理编码上线实测(用户已填 geocode_key,验证通过)

  • 用户在高德开放平台申请的 Web服务 类型 key 已填入 conf/app.ini 的 geocode_key(32 位 hex)。 注意:配置只在启动时读取,填完必须重启服务才生效。
  • GET /api/geocode?q=杭州西湖 返回真实候选:「杭州西湖风景名胜区」(30.222692,120.121358)、「西湖」(30.246255,120.144302) 等, 坐标经核算是 GCJ-02(西湖 WGS-84 约 30.2438/120.1503,加 GCJ 偏移后正合 30.2463/120.1443)。
  • 端到端 UI 实测(无头 Chrome + CDP 自建轮询脚本):输入「上海外滩」→ 点搜索 → 8 个候选 → 点第 1 个 → 表单被填为 name=外滩 / lat=31.233516 / lon=121.492127 / 上海市 / 上海市 / 黄浦区,地图落 1 个 marker 并平移到外滩, 提示语「已填入:外滩」,无 JS 报错。截图里 marker 正好压在高德底图的「外滩」标注上 → 直观证明坐标与底图同系。
  • 探测脚本只填表单未提交,数据计数仍为 places=323 / visits=823,无脏数据。
  • /.gitignore 已含 /conf/app.ini,密钥不会被提交进仓库。
  • 环境坑:Write/Read/present_files 与 Windows 版 python 的 /tmp/... 解析为 C:\tmp\..., 而 Git Bash 的 /tmp 是另一处;跨工具传路径要统一用 C:/tmp/...。

修复:「按名称搜坐标」输入框与按钮不同行 / 按钮歪(用户反馈)

两个具体根因(都用 headless 量数确认):

  1. 纵向偏移 13.8px:label.f 自带 margin-bottom: 13px(.field 基线间距),而那行用了 align-items: flex-end。 flex 是按外边距盒底边对齐的 → 标签的 13px 下边距把输入框整体抬高,按钮看起来"矮一截/歪"。 实测:1280 下输入框 top=256.78 h=38.39,按钮 top=270.58 h=37.59 → 底边差 13px、高度差 0.8px。
  2. 窄屏换行:输入框写了 flex:3 1 240px(基准 240px),.row 又是 flex-wrap: wrap → 320px 视口下按钮被挤到下一行。实测 320 下 sameLine=False,按钮 top 比输入框低 60.6px。

改法:新增专用类 .search-row(static/css/app.css),模板 edit.html 去掉内联样式:

.search-row { display:flex; flex-wrap:nowrap; align-items:flex-end; gap:8px; margin-bottom:12px; }
.search-row > label.f { flex:1 1 auto; min-width:0; margin-bottom:0; }   /* 归零外边距 + 可收缩 */
.search-row > .btn { flex:0 0 auto; font-size:14px; }                     /* 与输入框同字号 → 同高 */

注意:flex:1 1 auto(可增长)是刻意保持的 —— 项目既有约定就是「宽输入框 + 按钮靠右」 (edit.html:74 地点列表搜索、layout.html:84 筛选栏都写 margin-bottom:0 + flex:2 1 200px),不要改成定宽靠左。

实测(三视口,修复前/后对照):1280 / 390 / 320 下修复后均 同行=True、底边差=0、高度差=0; 320 下输入框 right=221、按钮 left=229(8px 间距),无溢出;JS 无报错。

待办(已复现,等用户确认再动)

同类搜索行在窄屏也会换行,但未包含在本次改动范围内: edit.html:73 的「地点列表」搜索行在 320 和 390 都换行(实测 320:标签 bottom=1624、按钮组 top=1634; 390:1391 / 1401)。layout.html:84 的筛选栏同理(flex:2 1 180px + 可换行)。若用户要统一,把这两处也换成 .search-row 即可。

推广:「搜索行」统一(用户回「一起改」)

把 .search-row 推广到同类位置,并顺手发现筛选栏有反方向的同类偏移:

  • edit.html 地点列表搜索行(<form class="row" style="margin:6px 0 12px">)→ 改 class="search-row" style="margin-top:6px", 按钮组加 .act 包裹。CSS 相应扩展 .search-row > .act { display:flex; gap:8px } 与 .search-row .btn。
  • layout.html 的 {{define "filters"}}(用于 /places、/stats、/timeline):控件行 <div class="row"> 原用默认 align-items: center,而每个 label 是「文字+输入框」整盒(约 63px),按钮组只有 38px → 按钮被居中,底边比输入框高 14px。 改成 style="align-items:flex-end",并删掉按钮组那个没用的 padding-bottom:1px 补丁。
  • 保留换行:filters 是 6 控件筛选卡片,窄屏换行是设计如此(实测 390 底边极差 193px、320 为 412px,即多行),不能强上 nowrap。 notes.html:30 的筛选行本来就写了 align-items:flex-end + margin:0,notes.html:85 用 align-items:end 的 grid —— 都是对的,未动。 map.html 侧栏筛选 = 控件与按钮本就分行,未动。places.html:18/30 是 chip 行,未动。

复测(底边极差,0 = 齐平):按名称搜坐标 390/320 → 0;地点列表搜索 1280/390/320 → 0(320 下按钮 left=163、right=287,确认不换行); 筛选栏 1280 → 0(按钮 div bottom=382.8 = 各 label bottom)。JS 无报错。

可复用教训(已写进 rond-web-verify 技能)

  • 判断 flex 行是否「同一行」不能用 top 去重:label 内含文字 span,top 天然比按钮高约 25px, 会误判成换行。正确判据:竖直重叠 / button.left >= input.right;是否对齐要看 bottom 而不是 top。

🔴 P0:导出的 .rondbackup 无法回导(rond 提示「备份恢复失败」)——根因取证

用户反馈:导出包只有 85KB,原始包 3402KB;怀疑丢数据/改了库名。库名没问题(都是 LifeEasy.sqlite), 但确实大量缺失。逐项对比(原始 data/20260914_151649.rondbackup vs storage/export_clean.rondbackup):

项 原始 导出 说明
表 31 12 缺 19 张
索引 47 1 只重建了表,CREATE INDEX 全丢
关联表 6 张 1 张 只重建了 Z_11VISITS_,Z_6TAGS_(2 行) 等全丢
.sqlite 356KB 254KB
-wal 3,094,152B 0B 原始数据主要在 WAL 里
-shm 32,768B 0B
zip 压缩 STORED(0) DEFLATE(8) 与 rond 不一致

致命根因:缺 Z_METADATA(Core Data 靠它判断 store 与模型是否兼容)

  • Z_METADATA 有 1 行:Z_VERSION / Z_UUID / Z_PLIST(1585B)。plist 键含 NSStoreModelVersionHashes(19 个实体的模型哈希:Keyword/UserPhoto/Tag/RawVisit/PoiCategory/Movement…)、 NSStoreModelVersionHashesDigest、NSStoreModelVersionChecksumKey、NSPersistenceFrameworkVersion、NSStoreType。
  • 我们的导出根本没有这张表 → Core Data 打不开库 → 备份恢复失败。Z_MODELCACHE(16073B) 同样缺失。

代码层根因(internal/rond/parse.go + export.go)

  1. captureMeta 的 DDL 抓取条件 type='table' AND name LIKE 'Z%' → 漏掉 ACHANGE/ATRANSACTION/ATRANSACTIONSTRING(非 Z 前缀), 也完全没抓 type='index'。
  2. buildSQLite 只按硬编码 createOrder 建 11 张表 → Z_METADATA、Z_MODELCACHE、ZKEYWORD(8 行数据!)、 ZACTIVITYGROUP、ZTRIP*、ZJOURNAL、ZUSERPHOTO 等一律不建,也不写 Core Data 元数据行的内容。
  3. 数据真丢失:ZKEYWORD 8 行、Z_6TAGS_ 2 行、ZRAWVISIT 2246 行。
  4. parse.go:441 对 ZRAWVISIT 只 SELECT count(*),从不读行 → PG 里也没存原始 GPS 采样点 (datasets.raw_visit_count=2246 只是统计值),所以现状下无法补导。
  5. zipBackup 用默认 DEFLATE,且把 0 字节的 -wal/-shm 也打进去。

修复方案(待用户拍板)

  • 方案 A(推荐)基线库 + 覆盖:导入时把原始 sqlite(checkpoint 合并 WAL 后)留一份基线 (如 storage/data/<dataset_id>/base.sqlite);导出时复制基线 → 清空并重写我们管理的实体表 (ZLOCATION/ZVISIT/ZACTIVITY/ZTAG/ZTAGGROUP/ZTRANSPORT/ZMOVEMENT/ZHOURLYWEATHER + 全部关联表) → 回写 Z_PRIMARYKEY.Z_MAX → checkpoint → STORED 打包。 好处:Z_METADATA/Z_MODELCACHE/持久化历史/47 索引/我们不管的表(含 ZRAWVISIT 2246 个原始点、ZKEYWORD、ZTRIP*) 全部原样保留,不需要往 PG 存原始点;最保真。 代价:每数据集多存约 3.4MB 基线;已导入的旧数据集要重建基线(可从 storage/uploads/ 里按 original_name 找回,或重传一次)。
  • 方案 B 全量抓结构进 coredata_meta:抓全 sqlite_master(表+索引+触发器)并把 Z_METADATA/Z_MODELCACHE 行以 blob 存进 JSONB。 代价:ZRAWVISIT 2246 行仍然丢(PG 没存),要真正不丢还得同时改导入新增原始点表,工作量更大、体积也大。

P0 导出回导:方案 A「基线库 + 覆盖」已实现并验证(18:45)

用户拍板走方案 A。改动清单:

文件 改动
internal/rond/parse.go 新增 WriteBaseline(archivePath, dst):解包 + wal_checkpoint(TRUNCATE) 后 VACUUM INTO 出单文件基线(退路是直接复制合并过 WAL 的主库);copyFile。顺手删掉只用过一次的 MetaFromArchive(其存在理由已消失)
internal/rond/export.go 全量重写。新签名 ExportRondbackup(ctx, db, datasetID, baselinePath, dst io.Writer);patchSQLite 复制基线 → 只 DELETE/INSERT 7 张管理表 + 访问↔标签表;loadEntities/discoverVisitTagJoin/tableExists 从基线探测结构,不再依赖 coredata_meta;Z_PRIMARYKEY.Z_MAX 只上调;zipBackup 改 zip.Store
internal/web/handlers_export.go baselinePath(<data_dir>/baselines/<id>.sqlite)、ensureBaseline(缺失时自愈)、findSourceArchive(按 sha256 去 upload_dir 找回原包)
internal/web/handlers_admin.go 导入成功后写基线;sha256 去重分支从「补抓 coredata_meta」改成「补写基线」
internal/web/handlers_edit.go HasMeta → CanExport:改为调 ensureBaseline 判断,老数据集访问编辑页即自愈
internal/web/templates/admin/edit.html 提示文案改为「基线库不可用…重新上传即可恢复导出」
internal/rond/export_roundtrip_test.go 新增 TestBaselineKeepsCoreData(守 Z_METADATA/Z_MODELCACHE/表索引集合/ZRAWVISIT),用 ROND_SRC 门控

踩到的坑:SetMaxOpenConns(1) 自死锁(卡 5 分钟、零输出、零报错)

patchSQLite 里 BeginTx 已经独占那唯一连接,之后又用 sdb.QueryContext 读 ZTAGGROUP → 永久等连接。 改为 tx.QueryContext。规律记进 skill 了:开事务后要么走 tx,要么把只读查询挪到 BeginTx 之前。

验证结果(实测,非推断)

原始包 = storage/uploads/1789372176-20260914_151649.rondbackup(3402 KB,sha 匹配 dataset #1)。

HTTP GET /admin/export/rondbackup   → 200,2,871,700 B(≈2804 KB,不再是 85 KB)
包内:LifeEasy.sqlite / -wal(0) / -shm(0),method=0(Store,与 rond 一致)
表 31 / 索引 47 / Z_METADATA ✅ / Z_MODELCACHE ✅(1 行 16073B) / ZRAWVISIT 2246 / ZKEYWORD 8
Z_METADATA.Z_PLIST 字节哈希 502bf007e218c077 …与原始包逐字节相同 ✅
表集合、索引集合与原始包完全一致;Z_PRIMARYKEY 22 个实体不变,仅 Z_MAX 上调(Location 323→324、Visit 824→825)
导出 → 再导出:各项计数完全一致(幂等)✅

导出内容 = PG 当前状态:PG 里 324 地点 / 824 到访(比原包多 1 个地点),导出与之逐项吻合 —— 说明「编辑页补录的记录会随导出回写」这条链路也是通的。

遗留 / 可选项

  • coredata_meta 现在只作为结构快照留档,不再参与导出。想彻底清掉的话,要一起动 Backup.Meta、captureMeta、CoreDataMeta、ingest 的两处写入、以及 schema 里的 coredata_meta 列。属可选清理,未做。
  • 每数据集多存约 2.8 MB 基线(storage/data/baselines/,已 gitignore)。
  • 仍未真机验证:导出包在 iPhone 上能否被 rond 成功恢复,需要用户实测。已留 storage/roundtrip-check.rondbackup 供其直接 AirDrop 试。
  • 基线里保留了原始持久化历史(ACHANGE/ATRANSACTION*),而业务表是被直接 SQL 覆盖的, 历史与实际数据不再对应;Core Data 一般不做交叉校验,但若真机恢复失败,这里是第一处可怀疑点 (退路:清空历史表并重置 history token)。

导出闭环验证 + 发现两个老数据问题(19:00)

闭环:导出 → 回传 → 被识别为同一份数据 ✅

把 /admin/export/rondbackup 产出的包直接回传到 POST /admin/upload:

flash[ok]: 这份备份的记录与已有数据集 #1 完全一致(824 条到访、323 个地点),未新建快照,当前展示不变

这是不依赖真机的最强证明——导出包能被我们自己的解析器读回来,且内容指纹与原数据集逐键一致 (824 个 (Z_PK, arrival.Unix()) 键完全相同,说明 Core Data 浮点时间戳的往返在秒级精度上无损)。

发现 1(P1,已修):老数据集 content_hash 为 NULL,内容去重永远不命中

  • datasets.content_hash 是后来加的列;在它出现之前导入的数据集(如 #1)该列是 NULL。
  • ingest.Import 的去重查询 WHERE user_id=$1 AND content_hash=$2 永远匹配不到 NULL → 「内容相同、字节不同」的包(典型就是自己导出的 rondbackup)会新建重复数据集并切走展示。
  • sha256 去重挡不住这种场景(rond 每次导出字节都不同)。
  • 修复:手工给 #1 回填了正确指纹 ba2712819f710b43…(用临时 Go 程序按 ingest.contentHash 同一套逻辑实算,不能用 psql/Python 复刻——跨语言算出来对不上,实测证明)。
  • 建议后续做:启动时或导入时对 content_hash IS NULL 的数据集批量回填,否则其它老库照样重复。 若历史上还有别的老数据集,这条要补。

发现 2(P2,未修):datasets.place_count/visit_count 是缓存值,编辑页新增后不更新

回传导出包时 flash 显示「323 个地点」,但 places WHERE dataset_id=1 实际 324 条。 handlers_edit.go 的增删改只动业务表,没同步 datasets 上的计数列 → 后台 flash 与统计数字会漂。 低危,但要修的话应在编辑处理里重算或改成实时 count。

教训:跨语言复刻哈希逻辑不可信

为回填 content_hash 我先用 psql+Python 复刻 Go 的 contentHash,结果算出的值与 Go 实算不一致 (键集合明明逐条相同)。凡是要写回库里的指纹/哈希,必须用原实现(Go)算,别跨语言复刻。


「固定」状态 + P2 计数漂移 已修(19:35)

用户反馈

真机导入成功,但导入的新地点在 rond 里默认是「固定」状态;另外让顺手修 P2(计数漂移)。

「固定」根因(实测定位,非猜测)

逐列对比真机原始包 vs 我们的导出包,发现所有被重写的行都丢了 rond 自己写的列:

  • ZLOCATION.ZPINSTYLE_:真机 323 行全是 0,我们导出全是 NULL
  • 同样丢的还有 ZTYPE_/ZBOOKMARKED/ZUSERADDED/ZUSERIGNORED/ZRADIUS/ZRAWLAT(ZLON)/ZLASTARRIVALDATE_/ZSUBTHOROUGHFARE/…
  • ZVISIT.ZRAW(785)/ZIDENTIFIER_(822)/ZMERGEDTO(52)/ZPARENT(10)、ZMOVEMENT.ZTYPE_(791)/ZISMODIFIED、 ZHOURLYWEATHER.ZPRECIPITATION_/ZDEWPOINT_、ZACTIVITY.ZUID_、ZTAG.ZLASTARRIVALDATE_ —— 共 48 列

原因:patchSQLite 是 DELETE+INSERT,INSERT 只列出我们管理的十几个列,其余全成 NULL。 rond 对 ZPINSTYLE_ 等标志位 NULL 的行按「固定」处理。

修复:导出改为 UPDATE-in-place(internal/rond/export.go 重写 patchSQLite)

  • 基线里已有的主键 → UPDATE 只动我们管理的列,rond 写的其余列原样保留
  • 新增的行 → INSERT,并把 rond 会写的标志位补成 0(照真机包取值分布抄)
  • PG 里已删除的行 → finish() 时 DELETE 同步
  • Z_ENT/Z_OPT 只在 INSERT 时写;UPDATE 不碰,避免动 Core Data 的乐观锁版本
  • 结构性重构:PG 侧行先取回内存再统一写 SQLite(避免两库交错),新增 tableWriter (write/finish)与 knownPKs;各表列清单与默认值集中在构造处,一眼可查

顺手修掉的连带 bug:基线被「导出包」污染

重传导出包时,我此前的代码会在 Identical 分支之前重写基线 → 用退化副本覆盖了原始基线, 导致后一次导出是在坏基线上做的(第一次复检时 48 列仍全 NULL,就是这个原因)。 修复:handlers_admin.go 只在 !st.Identical 时写基线;重传一律交给 ensureBaseline 按 sha256 找回原始包重建。本次实测:删掉坏基线 → 访问编辑页自愈 → 导出恢复正常。

复检结果(原始 vs 新导出)

  • 丢列清单:只剩 ZVISIT.ZEMOJINAME_(真机里是 '' 空串,我们写 NULL,语义等价,可接受)
  • ZLOCATION.ZPINSTYLE_ 323→324(多出的 1 行是编辑页新增的地点,且 全标志位=0,不再 NULL)
  • ZVISIT.ZRAW/ZIDENTIFIER_/ZMERGEDTO/ZPARENT、ZMOVEMENT.ZTYPE_、ZHOURLYWEATHER.ZPRECIPITATION_ 等 全部 100% 保留;Z_METADATA 哈希仍是 502bf007e218c077
  • 回传导出包仍被识别为「与数据集 #1 完全一致,未新建快照」✅

P2 修复(internal/web/handlers_edit.go)

refreshDatasetAgg 原来只重算 visit_count/first/last,补上 place_count。 实测:新增地点 → place_count 324→325,删除 → 回到 324,与实际行数一致。 (raw_visit_count 不重算——PG 不存原始采样点,它只是导入时的统计值。)

待用户真机复测

重新导出一份再导入 rond,确认新地点不再显示「固定」。 注意:手机上已导入的那份还是旧导出包(标志位是 NULL),要用新导出的包覆盖导入。