用户要求这份记忆跟着仓库走,而不是只留在本机。 .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`)。
53 KiB
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 冲突:
- 206 层:浏览器对
immutable缓存的静态资源会发Range请求 →http.FileServer返回 206 并自带Content-Length;gzip 无法在首部提交后再改长度 → 长度不符,文件被截断。 - 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(cookierond_voter- IP 一起 sha256 取前 16 字节作 voter_key,清 cookie 也认同一来源,轻度防刷);clientIP 取 XFF。
apiVotes(GET /api/votes)、apiVote(POST /api/vote)、adminPlaceVote(POST /admin/place/vote)。
- IP 一起 sha256 取前 16 字节作 voter_key,清 cookie 也认同一来源,轻度防刷);clientIP 取 XFF。
- 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>不合法:用 HTML5form="id"属性把行内复选框关联到外部批量表单,可绕开。 omitempty会让 0 值字段(vd/u/dn)从 JSON 消失,grep 校验时要注意,别误判为没数据。- 内网 Git Bash
curl -o /dev/null在多命令&&链里偶发 exit 23 中断后续命令,改用-o 文件。
投票前移到地图气泡 + 备注只读展示 + 备注保存回跳(实测通过)
需求(用户原话要点)
- 点赞/点踩直接放到地图上点开的那个气泡里,属快速交互,不必再进详情页。
- 地图气泡:登录时显示「我的结论」(站长的 👍/👎),未登录显示「大家怎么说」(票数)。
- 顺带解决:在详情页输入备注但未提交时去点点赞点踩,会把备注框内容冲掉。
- 备注内容要能在气泡里直接看到(只读,不用编辑)。
- 提交备注后跳到
/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-formCSS。
- 备注表单加
- 顺手修:
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>再用 Pythonwebsocket-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 会歧义。
- 汇总 SQL 两层去重——
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提交, 会整页刷新 → 丢掉同一行备注框里未保存的编辑(与详情页同一病根)。改为 AJAXPOST /api/verdict(data-verdict-btn+data-src/data-v,toast 由[data-verdict-status]局部更新,不刷新页面)。 /api/verdict取参数:必须r.PostFormValue(...)(POST body),前端要放 body 不能放 query。rond-map.jspreview(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 两处坑(补上次的记录):
/json/version给的是 browser 级 WS,调Page.enable会报-32601 wasn't found; 要用/json列表里type=="page"那个webSocketDebuggerUrl。--window-size=390,844在--headless=new下不会给出 390 的真实视口 (量出来卡片宽 457)。要Emulation.setDeviceMetricsOverride(width,height,deviceScaleFactor,mobile=width<=500)。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两处):.kpis { grid-auto-rows: 1fr }—— 所有格子强行等高,这是兜底保证。.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只认这两个值。
- 补掉两个真窟窿(改黑名单后更要紧,黑名单的意义就是藏住的绝不漏):
placePage原来按 ID 直取地点、完全没走filterOf→ 访客拿到链接就能看隐藏区域的地点。现在返回 404。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时districtNULL 会触发三值逻辑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反向解析导出包与原始包逐项比对(envROND_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。
rawvisits2246→0(原始 GPS 采样点不导出,属预期)。 - 补录实测:新增地点 src_pk=324 + 到访 src_pk=825 → 导出包中
ZLOCATION(324)、ZVISIT(825)(含时间/ZUSERADDED=1/标签关联(1,825))、Z_PRIMARYKEYZ_MAX 正确 bump;改/删/清理均正常,计数回到 323/823。 - 服务:重新编译,运行于后台任务
N52YdB,端口 8080。 - 旧数据集需重传一次原始 .rondbackup 补抓
coredata_meta(内容相同也会补抓);未补抓时导出接口会明确提示。
注意
storage/已 gitignore;导出样本storage/export_clean.rondbackup保留备查。
修复:/admin/edit 「新增地点」下方地图空白(用户反馈)
两个叠加的根因(缺一不可,只修一个仍空白):
- 脚本时序:
layout.html的{{template "content" .}}(L140)在 Leaflet<script>(L148)之前渲染。编辑页内联脚本执行时window.L仍是undefined,if (!el || !window.L) return;直接退出 → 地图容器连leaflet-container类都没有。实测证据:tiles:0、容器无 leaflet 类、截图仅剩一个空边框 div。 - 瓦片 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 量数确认):
- 纵向偏移 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。 - 窄屏换行:输入框写了
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)
captureMeta的 DDL 抓取条件type='table' AND name LIKE 'Z%'→ 漏掉ACHANGE/ATRANSACTION/ATRANSACTIONSTRING(非 Z 前缀), 也完全没抓type='index'。buildSQLite只按硬编码createOrder建 11 张表 →Z_METADATA、Z_MODELCACHE、ZKEYWORD(8 行数据!)、ZACTIVITYGROUP、ZTRIP*、ZJOURNAL、ZUSERPHOTO等一律不建,也不写 Core Data 元数据行的内容。- 数据真丢失:
ZKEYWORD8 行、Z_6TAGS_2 行、ZRAWVISIT2246 行。 parse.go:441对 ZRAWVISIT 只SELECT count(*),从不读行 → PG 里也没存原始 GPS 采样点 (datasets.raw_visit_count=2246只是统计值),所以现状下无法补导。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),要用新导出的包覆盖导入。