# 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 筛选、行内保存、批量设置结论/清空备注(保留别名)/删除 全部正确。 ### 踩坑 - 嵌套 `
` 不合法:用 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`** - 备注表单加 `` → 保存后跳回本页 (`/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=` 再用 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`(``,只读变体),末尾 `.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 = "||" + base64_raw(hmac_sha256(secret_key, "||" + 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:后台「访客可见区域」说明文案移动端换行不美观 - 原模板用行内 `只` / `隐藏`,导致移动端 `隐藏` 被单独 orphans 到一行,像被「分开展示」。 - 修法: - `internal/web/templates/admin/settings.html` 去掉行内 ``,改为自然文案: - 白名单:访客只能看到这些区域的记录 - 黑名单:这些区域的记录对访客隐藏,其余照常开放 - 关键短语加 `` 保持同框:「只能看到这些区域」「对访客隐藏」。 - `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 `