记忆:把 .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`)。
This commit is contained in:
@@ -0,0 +1,613 @@
|
||||
# 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),要用新导出的包覆盖导入。
|
||||
Reference in New Issue
Block a user