Files
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

614 lines
53 KiB
Markdown
Raw Permalink 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-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` 去掉内联样式:
```css
.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),要用新导出的包覆盖导入。