Files
rond-with-you/audit-2026-09-21.md
T
tamakyi c001e64ee0 报告:补稳定性基线与 P0/P1 收尾记录
- 稳定性基线(覆盖率 ≈51%、不设环境变量时 95 PASS / 86 SKIP 的假绿、无 CI)
- scripts/verify.sh 的能力与实测闸门
- 全新库冒烟的变异验证(迁移块顺序错了只有新库会炸)
- web 整包 -race:161 PASS / 0 DATA RACE
- 收尾验收:244 用例 / 0 FAIL / 0 SKIP
2026-09-24 20:55:52 +08:00

72 KiB
Raw Blame History

rond-with-you 全量排查报告

  • 日期:2026-09-21
  • 范围:全部 77 条路由 / 23 个注册页面集 / 96 个 Go 文件(28,184 行)/ 24 个模板(6,920 行)/ 4 个前端脚本 + app.css(1,072 行)/ 15 个内部包
  • 方法:4 路只读深挖(安全面、后端逻辑与一致性、模板层、前端 JS/CSS)+ 对关键结论逐条回读源码复验 + 模板行为实测复现 + go build / go vet / go test 实跑
  • 标注:✅ = 本次已实测/回读坐实 🔍 = 静态推断,未复现

基线状态(实测)

检查 结果
go build ./... ✅ 干净
go vet ./... ✅ 干净
go test ./... ✅ 全绿 —— 但本机 62 个用例 SKIP / 59 个 PASS(缺 ROND_DSN/ROND_SRC/ROND_TEST_CONF/FOG_SRC/ROND_EXPORT 时静默跳过,DB 相关用例实际一条没跑)
html/index.html ✅ 早期演示原型,未被 go:embed 覆盖、routes 里无引用 ⇒ 已不被服务

⚠️ 「测试全绿」这条要打折看:internal/web、internal/rond、internal/stats、internal/fog 里比较重的用例(备份差异预演、合并、导出往返、迷雾预览、补全)全在 skip 名单里。按项目自己的规矩,必须带全套环境变量跑到 0 skip 才算验收过。


🔴 P0

P0-1 runJobs 子 goroutine 没有 recover,任一查询 panic 直接终止整个进程 ✅

位置:internal/web/parallel.go:43-57

go func(i int) {
    defer wg.Done()
    ...
    errs[i] = jobs[i].run(ctx)   // ← 这里 panic,没有任何兜底
}(i)

根因:server.go:317 的 recover 中间件只挂在请求 goroutine 上。Go 的规则是:任意 goroutine 里未被 recover 的 panic 会终止整个进程,不是返回 500。

影响:统计页/主页等用 runJobs 并发查询的页面,只要某一个 job 触发 panic(nil map 写、切片越界、空指针解引用——例如 *time.Time 为 nil 后 .Local()),整站进程退出,而不是这一页报错。这是可用性上最严重的一条:单个畸形数据可以打挂站点。

复现:在任一 job 里塞 panic("x"),访问 /stats ⇒ 进程直接退出(不是 500)。

修法:在 goroutine 内加 defer func(){ if r := recover(); r != nil { errs[i] = fmt.Errorf("job %s panic: %v", jobs[i].name, r) } }(),并记日志。顺手加一条回归:某个 job panic 时该页返回 500 而进程存活。

P0-2 会话 MAC 绑的是常量,改密码不会失效旧会话 ✅

位置:internal/auth/auth.go:195-201

func (s *Store) mac(payload, hash string) string {
    m := hmac.New(sha256.New, s.Secret)
    m.Write([]byte(payload))
    // 混入口令摘要,改密后旧 cookie 立即作废
    m.Write([]byte("|" + hash[:min(len(hash), 16)]))
    return b64(m.Sum(nil))
}

根因:hash 是 pbkdf2$sha256$210000$<salt>$<dk> 这种固定格式,前 16 个字符恒为 pbkdf2$sha256$21(迭代次数固定、前缀固定)。所谓「混入口令摘要」实际混进的是一个对所有人都一样的常量。于是 MAC 只绑定 uid|exp。

后果(与代码注释和安全预期相反):

  • 改密码后,旧 cookie 在 14 天内仍然有效(ChangePassword 也不清会话);
  • 换密码、换用户名都不会踢掉任何已发出的 cookie。

影响面:拿到过 cookie 的人(旧设备、备份、被窃)在站长改密后依然能进后台。SignPending2FA 走同一函数,同样不受口令变更约束。

修法:把「口令摘要」换成真正的口令指纹——例如 sha256(salt+dk) 的完整摘要,或让 users 表存一列 token_epoch,改密时 +1,MAC 里绑这个 epoch。同时给 ChangePassword/ChangeUsername 加「改完重建当前会话」。


🟠 P1

P1-1 页脚 {{d .Dataset.Last}} 在 last_visit_at 为 NULL 时报错,整段页面被截断 ✅

位置:internal/web/templates/layout.html:215

{{if .Dataset}} · 数据截至 {{d .Dataset.Last}}{{end}}

d 的签名是 func(time.Time) string(view.go:1338),而 Dataset.Last 是 *time.Time(server.go:494,库里 last_visit_at 可为 NULL,schema.sql:29)。

实测复现(本次跑了一个最小 html/template 用例):

传入 输出 错误
&time.Time{...} HEAD · 数据截至 2026-09-21END nil(模板会自动解一层指针)
(*time.Time)(nil) HEAD · 数据截至 就停了 dereference of nil pointer of type time.Time
(*Dataset)(nil) HEADEND nil({{if}} 挡住了)

可达性:refreshDatasetAgg(handlers_edit.go:2176-2185)用 LEFT JOIN 求 min/max(arrival),当该数据集一条到访都没有时 v.l 就是 NULL,直接写进 last_visit_at;而它被编辑路径调用了 9 次(:714/824/922/978/1080/1251/1292/1350/1428)+ 合并路径(backup_merge.go:705/720)。所以「把某个数据集删到一条到访不剩」即可触发。

后果:render 只 log 不报错(server.go:401-403),客户端拿到的是 HTTP 200 但页脚之后整段不存在 —— 滚动恢复脚本、HasMap 下的 Leaflet/rond-map.js 引入、TrackScript 全部丢失。表现是「地图页打开是空白」「后台翻页不回到原位置」,且服务端只有一行 log。

修法:{{if .Dataset.Last}}(或直接用 dtt)。建议顺手把 render 的失败从 log 升级为「已写出部分就记录 + 独立哨兵」,否则整类模板错误都只能靠翻日志发现。

P1-2 公开页面把 err.Error() 回显给匿名访客 ✅

位置:internal/web/server.go:447-450

http.Error(w, "服务器内部错误: "+err.Error(), http.StatusInternalServerError)

调用点包含公开页面:home(handlers_public.go:267,290-349)、mapPage(:393,404)、placesPage(:473-526)、placePage(:572-625)、timelinePage(:706-719)、reportPage(:758-825)。公开 JSON 接口同样:apiPoints:440、apiCityAgg:936、apiTrack:1058/1066/1083、apiGpx:1109/1121/1141。

后果:任何一次 SQL/IO 失败,匿名访客就能看到 pgx 原文(表名、列名、部分 SQL、文件路径、S3 响应体)。这是免费的结构情报。

修法:对外统一成常量文案 + 一个短错误 ID,详情只进日志;内网/debug 模式下再回显。

P1-3 movementsWhere 少了 6 类筛选条件,统计页口径自相矛盾 ✅

位置:internal/stats/stats.go:195-198

visitsWhere(:100-141)带 ActivityIDs、MinDwell、City、Province、TagID、Keyword、Regions、home/work;而 movementsWhere 只有 dataset / 时间 / home-work / Regions。

后果:Overview(:268)与 TransportStats(:550)走 movementsWhere ⇒ 按「城市 / 活动类型 / 标签 / 最短停留 / 关键词」筛选时,到访数、地点数变了,但「总里程、出行次数、出行方式分布」仍是全量。这正是项目里反复强调过的那类「到访藏了、里程还在」的漏网形态,只是这次漏的维度更多。

修法:给 movementsWhere 补齐与 movements 语义对应的条件(城市/区域按两端 places、活动/标签/停留/关键词按所属到访 EXISTS,仓库里已有 weatherWhere 的写法可直接照抄);补一条 stats_test.go 的对账:任一筛选维度的开/关,Overview.DistanceKm 必须随 Overview.Visits 同向变化。

P1-4 地图 tooltip 未转义,而同一份数据的 popup 已转义 ✅

位置:internal/web/static/js/rond-map.js

340:  var parts = ['<div class="mp-n">' + esc(p.n || '未命名地点') ...   // popup 有 esc
382:  m.bindTooltip(p.n || '未命名', {...});                              // ← 无 esc
772:  ...bindTooltip((s.m || '行程') + ...)                               // ← 无 esc
818:  }).bindPopup('<b>' + c.city + '</b>' + ...                          // ← 无 esc
836:  .bindTooltip(t.name + ...)                                          // ← 无 esc

Leaflet 的 bindTooltip/bindPopup 字符串按 HTML 渲染。

后果:地点名、活动/交通方式名、城市名、GPX 轨迹名里只要出现 <、&,轻则渲染错乱,重则注入 HTML(写入方只有站长本人,所以更接近「自伤 + 渲染破坏」,但访客页会一起中)。同一文件里 popup() 已经用 esc() 转义了 —— 实现不一致本身就是证据。

修法:382/772/818/836 四处统一过 esc()。

P1-5 多处多语句写操作不在同一事务里 ✅🔍

位置:internal/web/handlers_edit.go

行 操作 半截风险
:699 先清 visits 再删活动 中间失败 → 活动还在、到访没了
:1235 删地点(3 条语句) 同上
:1411 删到访(3 条语句) ✅ 这一组项目自己有规矩:要连 movements、entity_raw 一起清并把 weather.visit_src 置空;不在事务里就可能只清掉一部分
:1332/:1342 改到访后 clampVisitMovements / saveVisitTags 在事务外 到访时间改了、行程没夹住 ⇒ 导出时违反真机硬不变量「位移不早于起点离开」
:1832/:1848 同上形态 —

对比:项目在「建地点 + 建到访」和「合并」这两处是严格用一个 withTx 的,说明标准已经定了,只是这些删除/编辑路径漏了。

修法:把这些路径也收进 withTx;clampVisitMovements 与 saveVisitTags 必须与到访更新同事务(否则导出会撞真机校验)。

P1-6 解压无上限(zip / zlib 炸弹) ✅

位置:

  • internal/rond/parse.go:201 io.Copy(out, rc) —— rc 是 zip 条目解压后的流,无长度上限
  • internal/fog/fog.go:134 io.ReadAll(rc)、:199 io.ReadAll(zr) —— zlib 解压后无上限

根因:上传侧只限制了压缩后体积(备份包 maxUploadBytes=512MB,handlers_admin.go:186;.fwss maxFogUploadBytes=64MB,fog.go:118)。一个几 MB 的高压缩比 zip/zlib 可以膨胀到几十 GB ⇒ OOM。

影响:需要站长会话(requireAdmin),所以是「配置/误操作打挂自己」而非远程 DoS。但 1C1G 的机器上,一个 64MB 的 .fwss 就足够换掉进程。

修法:解压用 io.LimitReader(rc, hardCap) 并校验累计写出量;zip 侧同时校验「解压后总大小 / 条目数 / 压缩比」上限。

P1-7 ^-?[0-9.]+$ 不是浮点校验,::float8 会炸 ✅

位置:internal/web/handlers_edit.go:2315-2319(suspectWGS)、internal/ingest/coord.go:40-43

AND (r.raw->>'ZLATITUDE') ~ '^-?[0-9.]+$'
AND abs((r.raw->>'ZLATITUDE')::float8 - ...) < 1e-9

这个正则允许 "1.2.3"、"."、".."、"1.2.3.4"(只要由数字和点组成),随后 ::float8 抛 invalid input syntax for type double precision。

后果:

  • 编辑页(handlers_edit.go:187 同样是无护栏的 ((r.raw->>'ZLATITUDE'))::float8)⇒ 一次 500,且只要那行数据在就永久 500;
  • ingest.BackfillCoordSource ⇒ 整次导入失败;main.go:42 的启动自愈也失败(只 log)。

可达性:🔍 entity_raw 的值来自 SQLite 导出的 JSON,正常是数值;但既然 suspectWGS 特意加了正则说明作者已经担心过非数值,那同一个正则用在两个地方都不成立这一点就是明确缺陷。建议修成正则 + 用 pg_input_is_valid(x,'float8')(PG 16+)或 NULLIF(x,'')::float8 包裹。

修法:抽一个 SQL 常量 numericOrNull(col) 统一使用;:187 那处也套上同一护栏。

P1-8 默认弱口令 + 登录无失败限速 ✅

位置:main.go:50-52

if adminPass == "" { adminPass = "rond-admin" }

handlers_admin.go:144 也只是提示「仍在用默认密码」,不强制改。另外登录只有一次性验证码(captcha.go:86-96 先删后比、原子一次性,这点做得对),没有失败次数限制 / 锁定 / 递增延迟。

后果:公网部署漏配 ROND_ADMIN_PASSWORD ⇒ 直接 admin/rond-admin 进后台。配合无失败限速,验证码是唯一的暴力破解阻力。

修法:无环境变量时生成随机口令并打印一次(或拒绝启动);登录失败按 IP+用户名做指数退避/短锁。


🟡 P2

安全

# 位置 问题 证据
S1 fog.go:445 vs fog.go:41 fogResetIndex() 无锁写全局 fogIndexV,与并发读构成数据竞争 ✅ 已修
S2 handlers_images.go:88-92 adminPlaceImageUpload 用 ParseMultipartForm(8MB) 但没有 MaxBytesReader;备份/GPX/迷雾/照片四处都有(handlers_admin.go:186,1119、fog.go:118、handlers_photos.go:105)⇒ 可无限写临时盘 ✅ 已修
S3 handlers_admin.go:944、handlers_vote.go:241-242 strings.HasPrefix(next, "/") 放行 //evil.com ⇒ 协议相对跳转。登录那条是安全的(handlers_admin.go:126 收紧成 HasPrefix(next,"/admin")),这两处没跟上 ✅
S4 server.go:374-383,386 会话 cookie 无 Secure(仅 HttpOnly+SameSite=Lax) ✅
S5 全部 POST /admin/* 无 CSRF token,只靠 SameSite=Lax;JSON 端点(/api/vote、/api/verdict)也无 Origin 校验 ✅
S6 handlers_vote.go:21-31 无条件信任 X-Forwarded-For 首段 ✅
S7 auth/totp.go:66-79、handlers_admin.go:1003 TOTP 校验不消耗验证码、login2FA 无限速 ⇒ ±1 窗口内可重放/爆破 ✅ 已修
S8 handlers_public.go:1104、handlers_admin.go:1182 /api/gpx 公开且不按 user_id 过滤(返回全表);adminGpxDelete 也不校验 user_id。单用户站点影响小,但多站长时会串数据 ✅
S9 handlers_export.go:206 Content-Disposition 里用未过滤的 datasets.original_name(来自上传文件名) 🔍

已核对不是问题:SQL 全参量化;ORDER BY 走白名单(stats.go:698-707、handlers_admin.go:765-773);serveMedia 经 LocalPath 的 path.Clean+.. 拦截;geocode.go/tiles.go/media.go 的 URL 皆为常量+值编码(无 SSRF);gzip.go 无绕过;验证码不可重放;logRequests 只记 path 不记 query。

数据一致性 / 静默失败

# 位置 问题
D1 handlers_edit.go:2147,2164,2176,454 saveVisitTags/refreshPlaceAgg/refreshDatasetAgg/activityFlags 全部丢弃错误 ⇒ 聚合缓存静默漂移
D2 backup_merge.go:732 writeRawRow 的 json.Marshal 失败只 log 后 return nil ⇒ 留档静默丢失,下次比对认不出人、导出的包失真
D3 rond/parse.go:238+ tags/activities/locations/visits/关联表/movements/weather 多处只 Close 不查 rows.Err() ⇒ 读到一半出错被当成导入成功
D4 rond/parse.go:34 appleTime 对 NaN/超大浮点直接 int64(sec)(Go 里是未定义/溢出)⇒ 脏时间;坐标无范围校验 🔍
D5 handlers_edit.go:1383 收藏状态回读出错时落进 else 分支,误报「已取消收藏」🔍
D6 handlers_public.go:1047 apiTrack 的白名单判定要求两端都命中,而 movementsWhere 是任一端 ⇒ 同一段里程在轨迹图层和里程统计里可见性不一致
D7 stats.go:711 Filter.Verdict 只在 Places 生效,Cities/ActivityCounts/ActivityStats 不认 ⇒ ?verdict= 时城市/类型分面与列表对不上
D8 handlers_admin.go:210 adminUpload 在 ingest.File 失败后不清理已写入的 stored 文件 ⇒ 磁盘残留

前端 / 模板

# 位置 问题
F1 report.html:71 <tr><td>最南</td>…{{f1 .South.Lat}}°N</td>{{end}} —— 缺 </tr> ✅(浏览器会自修,但 tb_stack_test 的行列校验会静默跳过这一行)
F2 admin/fog-mask.html:43 <table class="tb"> 既无 tb-stack、外层也无 .tw ⇒ 窄屏 4 列挤成一行且不横滑,与其余 26 张表的约定不一致 ✅
F3 view.go:234-244 → place.html:18 p.Back 为空时退回 p.BackQS,而 BackQS 已 QueryEscape 过一次,再转一次成 %252F... ⇒ 服务端解出 %2Fplaces… 不以 / 开头被丢弃 ⇒ 直接打开 /places/1 点「编辑」后,编辑页的「← 返回」消失 🔍
F4 map.html:237 setPoints/setTrack 只在 loadConfig().then 里定义;/api/map-config 慢于 /api/points 时 window.rondMap.setPoints 尚未定义 ⇒ TypeError 被 loadPoints 的 catch 误判成「加载失败」,弹错并重复拉取。全程无 requestId/AbortController 🔍
F5 rond-map.js:704,713,715 localStorage 读写无 try/catch(L218/227 有)⇒ 隐私模式下抛错会中断整段脚本,地图空白无提示 ✅
F6 rond-map.js:546-553 preview() 复用只认 _state[id],配置未回时 prev 为 undefined ⇒ 第二次 preview 的点被静默丢弃;photos.html:683 还同步置了 modalInited=true ✅
F7 rond-map.js:842,804,828 热力/城市/GPX 的异步回调无取消:勾选后立刻取消,回调仍 addTo(map) ⇒ 开关关着图层却在画;ensureHeat 失败只重置 heatLoading 不调 done ⇒ 热力图静默不出现
F8 photo-exif.js:114-118 EXIF 时间全 0(0000:00:00)⇒ new Date(0,-1,0) = 1899-11-30 且 hasTime=true,photos.html:338 直接当到达时间入库 ✅
F9 photo-exif.js:170-174,196 守卫 start+4 > length 在相等时放行 ⇒ getUint16(start+4) 越界抛 RangeError,用户看到英文 DataView 报错 ✅
F10 admin/fillup.html:137,229 JSON.parse(sessionStorage…) 未包 try(仅 L213 有保护)⇒ 脏数据中断整段脚本 ✅(代码在 82e6341 已修,第十一批补守卫)
F11 app.css:692 .timeline-day .dh{top:54px} 硬编码,而窄屏 --nav-h:50px(:112)⇒ 手机上有 4px 缝
F12 app.css:320,357,428 .map-panel/.map-legend{z-index:400}、.timeline-bar{650} 低于 Leaflet 控件的 1000(vendor/leaflet/leaflet.css:141)⇒ 左下比例尺/缩放按钮画在筛选面板之上
F13 app.css:162,874,875 .kpis{grid-template-columns} 三处死代码:.kpis 是 display:flex(:161)
F14 app.css:127 .nav-links a{padding:5px 7px} 被 :137 同特异性覆盖 ⇒ 只有 font-size 生效 ✅
F15 app.css:79,285,363-365,366,861-863 未使用类:.wrap-wide/.spacer/.maplink/.dot-marker/.opt-visit
F16 admin/edit.html:344-347 「到访」搜索表单只带 vq+back,一提交就把 q/src/sus 与行程的 mt/mp/mfrom/mto 全清掉;而「重置」反而保留全部条件(三个表单带参不一致)✅
F17 view.go:1367 safe 函数模板零调用(当前无风险,但留着就是个现成的转义出口,建议删)✅
F18 place.html:78 / :77,231,389 图片删除按钮只有「×」且无 aria-label;访客画廊图片 alt=""(对个人站点属可用性小瑕)✅

性能

# 位置 问题
P1 stats.go:657-668 placeCols 每行带 6 个相关子查询(top 活动 ×3 + is_home + is_work + has_image);MapPlaces 调用方上限 mapPointLimit = 4000(handlers_public.go:25)⇒ 单次地图请求最坏约 2.4 万次子查询 ✅
P2 handlers_topics.go:252,375 专题列表每项调 fillTopicStats(内含 2 条查询)⇒ 列表页 N+1 🔍
P3 handlers_admin.go:869 adminNotesBulk 逐条 Delete/SetNote/SaveVerdict,N 次往返 🔍
P4 handlers_export.go:196 导出先整包进 bytes.Buffer 再写,受 main.go:70 WriteTimeout 120s 约束;大数据集可能被截断且无提示
P5 handlers_export.go:156 findSourceArchive 对上传目录每个文件算 SHA256;基线缺失时编辑页每次加载都触发全量哈希 🔍
P6 fillup.go:33 fillupCache 全局 map 无淘汰上限,长期运行无界增长 🔍
P7 handlers_public.go:683,718 已在 runJobs 里查过 Weekday,:718 又同步查一遍,纯重复 🔍
P8 server.go:322 recover 内 http.Error:响应若已写出会追加错误体并报 superfluous WriteHeader 🔍

值得优化的地方

后端

  1. 统一「写操作必须一个事务」 —— 现在只有建地点+建到访、备份合并做到了;把删除/编辑路径(P1-5 那张表)补齐,并把「到访时间 → clamp 行程 → 聚合重算」串成一条链。这是最容易在导出时才暴露的一类回归。
  2. 错误分层:对外常量文案 + 错误 ID,对内完整日志(P1-2);顺带把「模板执行失败」从静默 log 改成可观测(P1-1 就是被这个掩盖的)。
  3. render 改成先渲染到 bytes.Buffer 再写响应 —— 渲染失败时还能返回一个干净的 500 页,而不是半个页面 + HTTP 200。一次性解决所有「模板字段打错/指针 nil」类问题。
  4. 筛选条件抽一处:visitsWhere / movementsWhere / weatherWhere 三份手写 where 已经漂移(P1-3、D6)。建议用一个「条件 → SQL 片段」的表驱动生成,或者至少加一条对账测试:任一筛选开关下,Overview 的几个数字必须同向变化。
  5. 给重查询加缓存:placeCols 的 6 个子查询、专题 N+1、导出全量哈希,都是「输入不变、结果不变」的,适合进程内 LRU(项目已有 tileCache/maskCache/fillupCache 三套现成模式可抄)。
  6. 连接池/并发参数复核:parallelLimit=6 与连接池 8/8 在统计页已经贴边(一页十几个查询),再加重查询容易排队。可以考虑「重查询单独限 2~3」而不是所有 job 共用 6。
  7. 日志分级:logRequests 现在只在 debug 或 /admin 打日志;生产环境需要「慢请求 + 5xx + panic」的结构化记录(P0-1 的 panic 现在只能靠进程消失来发现)。
  8. 补 skip 用例的前置检查:在 TestMain 里缺环境变量就 t.Fatal(而不是 skip),或在 CI 脚本里显式断言「0 skip」。否则「全绿」会一直是假信号(见基线表)。

前端

  1. 地图层收敛:把 rond-map.js 里「异步回调无取消」的地方统一用 AbortController + 一个 epoch 计数(P1-4、F4、F7 都是同一个病根);preview/full 统一走「有实例就复用、容器被换就 remove()」。
  2. 转义统一:tooltip/popup 一律过 esc()(P1-4);模板侧把 safe 删掉(F17)。
  3. CSS 收敛:app.css 里的死代码(F13/F15)清掉;z-index 统一到一套 token(Leaflet 是 1000,面板应在其上,F12);--nav-h 这类尺寸别在别处硬编码 54px(F11)。
  4. 表格一致性:fog-mask.html 补 tb-stack(F2),report.html 补 </tr>(F1);把 wide_table_layout_test.go 的「被 .tw 包住必须带 tb-stack」判据扩成「所有 class="tb" 的表都必须带 tb-stack 或显式豁免」,这样 F2 会被自动抓到。
  5. html/index.html(856 行)已不被服务:要么删掉、要么移进 archive/ 并在 README 注明,免得以后误改原型当正式页。
  6. 交互细节:admin/edit.html 三个搜索表单的字段集统一(F16,让「重置」真的重置、搜索不丢上下文);place-image.js:99 / photos.html:523 的 r.json() 先判 r.ok(服务端 500 时回 HTML 会抛 SyntaxError)。
  7. 可访问性:给纯图标按钮补 aria-label、访客画廊图补 alt(F18)。个人站点不用做全套 a11y,但这几处直接影响「能不能用」。

建议的修复批次

批次 内容 理由
第 1 批(先做) P0-1、P0-2、P1-1 一个能打挂进程、一个安全契约失效、一个静默截断页面;都是小改动
第 2 批 P1-3、P1-5、P1-7 数据口径与事务一致性,拖越久越难查
第 3 批 P1-2、P1-4、P1-6、P1-8 安全加固
第 4 批 P2 里的 F1/F2/F3/F13/F12 + 性能 P1/P2 前端一致性与性能

本报告只做排查,未改动任何代码。按项目惯例,动手前先出变更清单再分批落地。 后续落地情况见文末「修复记录」。


修复记录(2026-09-21 落地)

每条都做了变异验证:先把测试改成能捕获该 bug 的形态并确认变红,再还原确认变绿。

第 1 批 ✅

项 证据
P0-1 runJobs 子 goroutine 无 recover 子 job 内 recover,job panic 不再终止进程
P0-2 会话 MAC 绑常量 MAC 绑完整口令哈希,改密码后旧会话失效
P1-1 {{d .Dataset.Last}} nil 解引用 模板侧按指针判空,不再截断页面

第 2 批 ✅

项 证据
P1-3 movementsWhere 缺 6 类筛选 分面对账测试
P1-5 多语句写不在同一事务 全部改走 withTx;tx_atomicity_test.go 用 BEFORE UPDATE 触发器让 clamp 失败,断言到访时间确实回滚
P1-7 浮点正则不是校验 指数受限的 strconv.ParseFloat

第 3 批 ✅

项 证据
P1-2 serverError 回显 err.Error() server_error_test.go:匿名访客拿不到 SQL 细节,站长仍能看
P1-4 地图 tooltip 未转义 map_escape_test.go 静态守卫(另补了被漏掉的 legend 一处)
P1-6 解压无上限 rond/limit_test.go、fog/limit_test.go(zip bomb 用 66KB → 68MB 实测被拒)
P1-8 默认弱口令 + 登录无限速 auth/guard_test.go、login_guard_test.go;main.go 改为随机口令

第 4 批

项 状态 证据
F1 report.html 缺 </tr> ✅ tb_static_test.go 行标签配平(<tr> 与 </tr> 计数)
F2 fog-mask.html 缺 tb-stack ✅ 同上 + tb_stack_test.go 增目标
F3 BackQS 二次转义 ✅ pagelink_test.go:解一次即等于期望路径
F11/F12/F13/F15 CSS ✅ --z-map-overlay/--z-map-status 令牌、.dh{top:var(--nav-h)}、清死代码
性能 P1 placeCols 6 个子查询 ✅ 见下
性能 P2 专题列表 N+1 ✅ 见下

性能 P1:placeCols 相关子查询缺索引前导列

根因(实测坐实):idx_visits_place 是 (dataset_id, place_id),而 6 个相关子查询只写了 place_id = p.id —— 前导列缺失 ⇒ 每轮循环都要为库里每个 dataset 各下一次索引。

EXPLAIN ANALYZE(366 个地点) 修复前 修复后
Index Searches(每个子计划) 2371 366
Buffers(整条查询) 23737 5669
外层 actual time 53.4ms 31.5ms

等价性已证:366 行 × 20 列逐列比对 0 差异;「到访与所属地点 dataset 不一致」= 0 条 ⇒ 该谓词语义上冗余,纯粹是给计划器的提示。 回归 stats/placecols_test.go:①静态守卫按子查询分段检查 vN.dataset_id=p.dataset_id (教训:最初写成全文 Contains,被同名的另外两处 v2 顶包,变异跑不出红);②语义预言机用 Go 侧 独立数一遍 top 三轴与 is_home/is_work,已比对 275/325 个地点(其余并列跳过,SQL 的并列取法未定义)。

性能 P2:专题列表 N+1

topicContent 每专题 2 条查询(点位 + 天数),列表页就是 2N 次往返;而列表只需要汇总数字。 新增 fillTopicStatsBatch 用 UNION ALL 一条查询算完所有专题;顺带发现「天数」那条查询与点位查询 的到访集合完全相同,是纯重复。口径与详情页(topicPage → topicContent)完全一致。 回归 web/topic_stats_test.go:以详情页路径为预言机,3 轮(无区域 / 区域白名单 / 区域黑名单) × 5 个专题(全时段 / 前半段 / 后半段 / 区域圈定 / 圈不到)逐项比对通过。

修复途中新发现的 bug(原报告没有)

fog.tileName / tileIDFromName 对单数字编号越界 panic 🔴

  • tileName 的 M2 尾缀循环起点是 len(s)-2,编号 09 时为 -1 ⇒ s[-1] 越界; 导出路径 Encode 里 id := t[0] + t[1]*MapTiles,网格第一列就是 09。
  • tileIDFromName 的长度守卫 len(name) < 7 恰好放行「4 位 md5 + 1 位掩码 + 2 位尾缀」的 7 字节文件名,而 md5 是公开算法 ⇒ 构造一个 .fwss 上传即可触发 panic(DoS)。
  • 修复:新增 tileDigits(id)(不足两位补前导 0),编码/解码共用;解码守卫收紧到 len(name) < 8 (len(name) = 6 + len(digits))。真实快照编号 ≥2 位 ⇒ 输出字节不变。
  • 回归 fog/tilename_test.go:往返(含 0/1/5/9)、构造的 7 字节恶意名必须被拒、Encode 能处理 (0,0) 瓦片。 变异后两条都实测 panic(tileName fog.go:118、tileIDFromName fog.go:101)。

修复记录(第二批)

同样是「先让测试变红、再还原变绿」。新增 web/redirect_target_test.go、web/session_cookie_test.go、web/csrf.go + web/csrf_test.go、web/entity_raw_test.go。

S3 协议相对跳转(开放重定向)✅

排查发现同一份判据在仓库里有三份实现:view.go 的 backFromRequest 是加固过的正确版 (挡 //、控制字符、引号尖括号),而 handlers_admin.go 的表单 next 与 handlers_vote.go 的投票后跳转是手写的弱化版(只判 HasPrefix(next, "/"), 放行 //evil.com)。

修法:把判据抽成纯函数 safeInternalPath,backFromRequest 与那两处共用, 顺带把登录后的跳转也过一遍(保留 /admin 前缀限制)。 回归:①TestSafeInternalPath 表驱动(21 个拒绝样例,含 //evil.com、/\evil.com、 Origin: null 式伪协议、\n 头注入、NUL/DEL);②TestNoWeakRedirectPrefixCheck 静态守卫:internal/web 里再出现 HasPrefix(next, "/") 即失败(并自查扫到的源文件数, 防止守卫假绿)。

不能无条件打 Secure:本机 http 调试、以及本仓库的 CDP 验证流程都跑在 http 上, 打了浏览器就不回传 cookie,等于谁都登不进去。所以按「这次请求是否走 https」判断: 直连看 r.TLS,反代看 X-Forwarded-Proto(多段取首段)。login 因此多收一个 *http.Request。 回归:TestSessionCookieSecureFlag 覆盖直连 http / 直连 https / 反代 https / 反代多段 / 反代 http 五种,并断言 HttpOnly、Path、SameSite 没被顺手改掉。

S5 CSRF / Origin 校验 ✅

先取证:全部会改状态的路由都是 POST(无 GET 改状态)✓;会话 cookie 已是 SameSite=Lax, 跨站 POST 本就带不上 cookie。所以这层定位为纵深防御(防「将来有人把 SameSite 放宽」与同站不同源)。

不改模板:新增 csrfOriginGuard 中间件,只对 POST/PUT/PATCH/DELETE 生效, 判据只用浏览器一定会带的两个头 —— 有 Origin 必须是本站(Origin: null 一律拒); 无 Origin 看 Referer;两者都无则放行(跨站 POST 浏览器必带 Origin, 只有 Referrer-Policy 一起抹掉的情况,而那时 Lax 已把 cookie 拦在外面)。 拒绝时打日志带 Origin/Referer/Host 三个值,反代配错一眼能看出来。 部署注意:反代必须把原始 Host 传过来(nginx proxy_set_header Host $host); 若改写 Host 则需带 X-Forwarded-Host(已在候选里兜底)。 回归:TestSameOriginRequest(14 例)+ TestHandlerRejectsCrossOriginWrite 走完整中间件链 (含「GET 不受影响」)。变异验证做了两轮:①把守卫从链上摘掉 —— 只有接线测试变红(单元测试仍绿); ②让判据恒真 —— 两类都变红。

D2 留档静默丢失 ✅

writeRawRow 的 json.Marshal 失败原来只 log 后 return nil,调用方以为写成功, 合并照常提交 ⇒ 这一行的原始列永久缺失(下次跨备份比对认不出 UUID、导出还原不回去)。 改为 return fmt.Errorf(...),由 withTx 整笔回滚。 回归:TestWriteRawRowFailsLoudlyOnMarshalError 用 math.NaN() 触发真实序列化失败, 断言 ①必须返回错误 ②失败时不留下半截 entity_raw 行 ③正常值仍写得进去(否则断言可能只是 「这张表根本写不动」)。

修复记录(第三批)

本批做 S1 / S7 / S2 / D3 / P4。每条都先变异验证(改回旧写法必须变红)、再确认转绿。

S1 迷雾索引 data race ✅

fogIndexV 由普通指针改成 atomic.Pointer[fog.Index](fogIndex() 用 Load、 fogResetIndex() 用 Store),sync/once/LoadAll 的语义不变。 变异验证:把类型改回 *fog.Index 后 CGO_ENABLED=1 go test -race -run TestFogIndexConcurrentResetAndRead ./internal/web/ 报出 WARNING: DATA RACE(write @fog.go:453 fogResetIndex vs read @fog.go:45 fogIndex); 改回 atomic 后同命令干净通过。 环境:-race 需要 cgo + C 编译器,本机原本没有 gcc(scoop install mingw 装好后 CGO_ENABLED=1 才能跑)。

S7 两步验证限速 + 验证码一次性 ✅

  • login2FA 接上 loginGuard,键 "2fa:"+用户名(与口令那一环分开计数,互不干扰)。 中间令牌有 5 分钟有效期,不限速时 6 位码在窗口内是能被打穿的。
  • 新增 auth.CodeReplay:验证码校验通过后 Consume(uid, code) 登记一次, ±1 步窗口内(replayTTL = 90s)同一个码不能换第二个会话。重放不计入爆破计数 ——用户手抖重复提交也会撞上,不该因此被越锁越久。 回归:TestLogin2FARateLimited、TestLogin2FACodeIsSingleUse(走真实 login2FA)、 TestCodeReplay{RejectsReuseInWindow,ExpiresAfterWindow}。 变异验证:①去掉入口 RetryAfter —— 锁定期内递正确码登录成功 ⇒ 红; ②Consume 短路成 if false —— 同一个码第二次仍拿到 303 ⇒ 红。

S2 图片上传没有请求体上限 ✅

adminPlaceImageUpload 补上 r.Body = http.MaxBytesReader(w, r.Body, imageMaxBytes+1<<20)。 ParseMultipartForm(maxMemory) 只限制「内存里留多少」,超出的部分会无上限落到临时文件, 后面那个 io.LimitReader(file, …) 拦不住磁盘被写满。 回归:TestPlaceImageUploadRejectsOversizedBody。 变异验证:删掉这行 —— 9MB 请求体会一路解析成功、最后被单张 6MB 上限拦成 {"error":"图片超过 6MB"} ⇒ 红。

D3 rows.Err() 没查 ✅

rond/parse.go 新增 drainRows(rows, what)(先查 Err() 再 Close()), Parse 与 captureMeta 共 13 处循环收尾全部走它。只 Close 不查 Err 时不报错、不算错: 驱动/连接中途出错时 Next() 只返回 false,半张表被当成完整的交出去。 回归:internal/rond/parse_rows_test.go 的静态守卫——把 parse.go 里每个 for x.Next() 的变量名都找出来,逐个要求出现 x.Err() 或 drainRows(x,; 另外单独校验 drainRows 本体的确既查 Err() 又 Close()(防止空壳骗过前一条)。 变异验证:把 mrows 的 drainRows 换回 mrows.Close() ⇒ 红。 往返验收:823/323/8/30/791/557/2246 全等、坐标偏差 0.000000000、时间偏差 0s。

P4 导出下载 ✅

  • exportRondbackup 补 Content-Length(下载进度条),并用 http.NewResponseController(w).SetWriteDeadline(+10min) 只把这一条路由的写死线放宽 (全站 120s 是按页面响应定的,慢链路下拉几十 MB 不够)。整包仍先落内存, 「绝不写出半截文件」的性质不变。
  • gzipWriter 补 Unwrap() http.ResponseWriter:少了它,被 gzip 中间件包住的请求 调 ResponseController 一律拿到 ErrNotSupported(SetWriteDeadline/Flush/Hijack 全靠它逐层解包)。 回归:TestGzipWriterUnwrapsToUnderlyingWriter、TestExportSetsContentLengthAndExtendsWriteDeadline。 变异验证:①Unwrap 改名成非接口方法 —— ResponseController feature not supported ⇒ 红; ②把 SetWriteDeadline 换成 error(nil) —— 断言「导出没有延长写死线」⇒ 红。

修复记录(第四批)

本批做 S6 / D5 / D1 / P5,并把 N1(flaky)查到根因。每条都先变异验证(改回旧写法必须变红)、再确认转绿。

S6 X-Forwarded-For 无条件信任首段 ✅

clientIP(handlers_vote.go)改为只认最后一段:nginx 的 $proxy_add_x_forwarded_for 把客户端 发来的值原样接在前面、再把真实对端补在后面,所以第一段恰恰是最好伪造的那个。最后一段还必须能 netip.ParseAddr(IPv4-mapped IPv6 归一成 IPv4,同一客户端不该因写法不同而换身份),否则整个头作废、 退回 RemoteAddr——免得一句垃圾字符串被当成身份。

回归:TestClientIPTrustsOnlyProxyWrittenSegment(12 组)、TestClientIPIgnoresForgedPrefix。 变异验证:改回「取第一段」⇒ 多段用例变红。

副作用(已写进代码注释):投票匿名身份 = hash(cookie, IP),取法一变身份就变。

  • 正常单反代、客户端没伪造时首段 == 末段,身份不变;
  • 反代前面再叠 CDN(而反代没配 real_ip)时,末段会变成 CDN 自己的 IP,所有访客被叠成同一个来源。 不会把谁认成谁(cookie 仍各不相同),只是那层「同源」识别退化成全员共用。真要多层代理,判据要改成 「按已知跳数从右往左取」,不能写死最后一段。

D5 收藏状态回读失败被误报成「已取消收藏」✅

adminVisitBookmark 原来的「先 UPDATE、再单独 SELECT 回读」换成 UPDATE ... RETURNING bookmarked 一条语句:没有第二次读,也就没有「读失败只能猜」这个分支。

回归:TestVisitBookmarkTogglesInOneStatement(静态:必须有 RETURNING bookmarked、不许再有 SELECT bookmarked 与 err == nil && on)+ TestVisitBookmarkFlashMatchesRow(连点两次,提示必须与库里 实况一致;不存在的记录不许回成功提示)。 变异验证:改回「UPDATE + 独立 SELECT + err == nil && on」⇒ 静态守卫红。

D1 聚合刷新全部丢弃错误 ✅

saveVisitTags / refreshPlaceAgg / refreshDatasetAgg / activityFlags 全部改成返回 error:

  • 事务内的调用点(改地点 / 改到访 / 删活动等)一律上抛,让 withTx 回滚;
  • 已提交之后的调用点走新增的 logAggFailure——只 log.Printf、不改判请求。这里报错反而更糟: 数据其实已经落库,用户会以为没保存、再点一次就多建一条,比计数滞后严重得多;但也绝不能静默。

顺带补上 handlers_edit.go 里一处 s.saveVisitTags(...) 返回值本来就没人接的裸调用(它在事务内, 出错是静默的);handlers_photos.go 的 refreshDatasetAgg 也接进 logAggFailure。

回归:agg_error_test.go 三条 —— ① 用已取消的 context 调这四个助手,必须都返回非 nil (旧签名是 void / 纯 bool,错误直接丢,测试根本没法断言);② 静态扫描禁止 s.refreshDatasetAgg( 这类语句形式的裸调用;③ 钉住三个助手的签名必须以 ) error 结尾。 变异验证:① refreshDatasetAgg 里改成 return nil(吞掉错误)⇒ ①红;② 在某处改回裸调用 ⇒ ②红 (报出精确到 handlers_photos.go:236)。 顺带全仓扫了一遍同类写法:QueryRowContext(...).Scan(...) 的语句形式、以及 ex.*Context( 丢弃返回值的 写法,在生产代码里已为 0。

P5 找备份包时对每个文件白算 sha256 ✅

findSourceArchive 里新增 pickArchiveBySha:先按 datasets.size_bytes 筛、再算摘要。 uploads 里可能堆着几十份几百 MB 的备份,每份都要完整读一遍才比得出 sha256——而 sha256 相同就一定字节数 相同,大小对不上的根本不必读。基线缺失时编辑页每次加载都会走这条路,1C1G 上体感明显。 库里没记 sha 的退化路径不筛(保持「取最新的那份」的旧行为,否则会因为记不到 size 而一份都选不中、 导出直接失败)。

hash 做成参数是为了让用例能数「到底算了几次摘要」:只看最终返回哪个文件,带不带这道筛选完全一样。

回归:archive_pick_test.go 五条(大小不符只算 1 次 / 大小都符不许跳过 / 无 sha 时保持旧行为 / 目录与读不到的文件要跳过 / findSourceArchive 必须查 size_bytes 且走 pickArchiveBySha)。 变异验证:删掉那道 fi.Size() != size ⇒ 红并报「只该对大小相符的那份算摘要,实际算了 3 次(白读了 2 份整包)」。


修复记录(第五批)

S8 GPX 轨迹的两个入口都不认属主 ✅

/api/gpx(公开,无鉴权)原来把 gpx_tracks 全表吐出去,adminGpxDelete 也只按 id 删—— 单用户站影响小,但属于「谁的轨道都能读、能删」的错误模型,改成按属主收口:

  • apiGpx:先取当前生效数据集的 user_id(is_active AND status='done' 取最近一份), 取不到就返回空集;有则 WHERE user_id=$1。别再走一遍「活跃数据集」那套派生逻辑(分散在 三处、口径迟早飘),这里只要属主一个值。
  • adminGpxDelete:DELETE FROM gpx_tracks WHERE id=$1 AND user_id=$2,再用 RowsAffected()==0 回「轨迹不存在」——不能只改 WHERE 不看影响行数,否则删别人的轨道会静默成功(0 行)却提示已删。

回归:gpx_owner_test.go 两条 —— ① 行为:公开接口不得漏出他人轨道、删他人轨道要拒且真的没删、 删自己的要成;② 静态:两个 handler 的 SQL 文本里必须带 user_id、delete 必须查 RowsAffected。 变了一次形:给静态守卫「喂注释」——把判据写成 strings.Contains(body,"user_id") 时, 一句中文注释就能让它假绿(SQL 真被删掉也照过)。已改成只扫描反引号里的 SQL 文本,并加 「一段 SQL 都没扫到 ⇒ 直接 Fatal」的自检。变异验证:删掉 WHERE user_id=$2 ⇒ ②红。

S9 下载文件名可以逃出引号 ✅

四个 Content-Disposition(geojson / gpx / rondbackup / fwss)原来直接把 datasets.original_name 拼进头部——名字里带 " 或换行就能撑破引号、注入头部。新增两个助手:

  • downloadDisposition(name):ASCII 兜底名(filename=,非字母数字换 _)+ RFC 5987 的 filename*=UTF-8''…(真名,逐字节百分号编码)。浏览器优先取后者,老浏览器退回前者。
  • rfc5987Escape:只放行 A-Za-z0-9 与 .-_~,其余一律 %XX。
  • asciiSafe 把 " \ ; : = * ? < > | 全换成 _;hasAlnum 保证兜底名不为空。

回归:download_name_test.go 三条 —— ① 十个恶意/畸形名字渲染后必须恰好两个引号、零 CR/LF、 filename= 段无危险字符、filename* 解码后逐字节等于原名;② 中文名/超长扩展名不能把扩展名吃掉; ③ 全包扫描:任何 Content-Disposition 都必须经 downloadDisposition(。两处自身 bug 被这条用例抓出来: 最初的正则漏了 -(合法名 places-20260921 被判形状不符)、asciiSafe 漏了 =(a;b=c 能溜过)。 变异验证:把某个调用点改回裸拼 ⇒ ③红;从 asciiSafe 黑名单删 = ⇒ ①红。

D7 verdict 筛选全域一致 ✅

Filter.Verdict 原来只在 Places 里生效,Cities / ActivityCounts / ActivityStats 不认—— 于是 ?verdict= 之后「地点数按标记过滤、城市/活动分面对不上」,是筛选条自己跟自己打架。

按用户选定的**「全域一致」口径修:把 verdict 条件下沉到 visitsWhere**(place_key 坐标键 EXISTS(place_votes …)),所有走 visitsWhere 的统计自动一致;hasVisitConds() 同步加 || f.Verdict != 0(否则「只有 verdict 这一个条件」时会被当成「无条件」而跳过 where 分支); Places 里那段重复的特殊-case 删掉(已被 subsumed)。

place_key 的表达式抽成 placeKeyExpr(alias)——原来这段坐标键散在三处,D7 又要在第四处用, 继续复制粘贴迟早四处不一致。

回归:verdict_scope_test.go 两条 —— ① Verdict=99(无人命中)⇒ 14 个统计口径全空; ② 只给 p1/p2 投 1 ⇒ visits=3、dwell=110、places=2、cities=2、provinces=1、movements=2、distance=12.00、weather=1。 fixture 是自建封闭数据集(坐标段 89.9/100.x、独立 user、故意不设 is_active),避免并行测试 按坐标键串味。变异验证:① 去掉 visitsWhere 里的 verdict 下推 ⇒ ①红;② 从 hasVisitConds 删 || f.Verdict != 0 ⇒ 只有 movements/weather 清空、其余仍露 ⇒ ②红(精确指出漏了哪些口径)。


修复记录(第六批)

D4 脏备份能写出「伪时间」与越界坐标 ✅

实测复现(临时探针调 appleTime):旧实现直接 int64(sec),而 Go 对超范围浮点的 整数转换是回绕不是饱和——

输入 旧结果
NaN / ±Inf 2001-01-01(恰好回绕到基准点,看着完全正常)
1e30 / -1e30 2001-01-01
1e12 2123-10-28
-1e12 1878-03-08

要命的是这些值「看着正常」:ingest 会拿所有到达时间做 min/max 写进 datasets.first_visit_at / last_visit_at,一条脏行就能让整条时间轴从 2001 年起算。 而且只卡结果没用——回绕后可能又落回合法区间;必须先卡输入秒数再乘。

改法:

  • appleTime:NaN/±Inf 或秒数落在 [1970, 2100) 之外 ⇒ nil。归 nil 正好接上既有的 「站点模型装不下」契约——到访/天气按 skip() 跳过并留档,导出时原样补回,不丢数据。
  • 新增 validCoord:NaN/±Inf/越界(±90 / ±180)判无效;读 ZLOCATION 的循环里归一成 (0,0)——与管线里 COALESCE(NULL,0) 的既有「无坐标」约定一致(place_key/contentHash 也因此对脏行稳定,不会每导一次就换个键)。
  • 位移的脏时间保留行、时间置空(movements.started_at 本就可空、导出走 sql.NullTime), 不额外引入「无声消失的行程」。

回归:

  • internal/rond/dirty_test.go:appleTime 脏值全拒 / 合法值微秒级不失真 / 窗口边界放过 / validCoord 边界 / 读 ZLOCATION 必须过 validCoord 的静态守卫 / 以及真的建一个 Core Data 版 sqlite 走一遍 Parse 的端到端用例(断言越界坐标归零、脏时间到访为 nil、 脏时间位移保留行但时间为空)。
  • internal/ingest/dirty_import_test.go(DB):脏到访不进 visit_count、被留档为 skipped、 且 first_visit_at 等于合法那条的时间(不是 0001/2001)。用独立用户跑,跑完删掉, 不会碰到别的用例依赖的生效数据集。

变异验证(三处各自独立、都变红):

  1. 去掉窗口检查 ⇒ 1e30→2001、1e12→2123 又被放行;
  2. 去掉 NaN/Inf 检查 ⇒ NaN→2001 被放行(说明两个检查互补,缺一不可);
  3. 地点循环去掉 validCoord ⇒ 静态守卫 + 端到端 (999,-999)/(1e30,0) 双双变红。 ingest 侧再变两次:改成「补零时间」⇒ 条数变 2、first_visit_at 变 0001-01-01;只跳过不留档 ⇒ 留档数变 0。

修复记录(第七批)

F4/F7 异步回包把已经关掉的图层又画回来 ✅

症状:勾选图层 → 请求发出 → 立刻取消勾选 → 请求回包 → 图层照画。开关显示「关」,地图上却有东西。 四个出口都中招:setGpx / setHeat / setCityAgg(rond-map.js),以及模板里的行程轨迹 refreshTrack(map.html)。共同的写法是:取消时把 xxxLayer = null 就 return, 在途那次请求没人作废,回包一路 addTo(map)。

改法:每次调用发一个递增序号,回包时对不上就作废。关键的顺序是 序号必须在 if (!on) return; 之前递增——否则「取消」这条路径压根不会改变序号,在途请求照样合法。 序号还顺带解决「连点两次筛选 / 切底图」时旧回包盖掉新回包(baselayerchange 里重取走的也是同一条路)。

  • rond-map.js:var gpxSeq = 0, heatSeq = 0, citySeq = 0;,三个 setter 各 var seq = ++xxxSeq;, 回包首行 if (seq !== xxxSeq) return;。
  • map.html:refreshTrack 同样加 trackSeq(.then 与 .catch 两个分支都要挡——失败分支若不挡, 过期回包会把用户新勾上的开关改成取消态)。

回归:map_async_cancel_test.go。判据是两个异步分支的相对顺序,放在源码文本上: 按大括号配平取出函数体,断言「++xxxSeq 出现在 if (!on) return; 之前」且 「seq !== xxxSeq 出现在 addTo(map) / setTrack(d.segs…) 之前」,四段(三个 setter + refreshTrack)逐个查。 变异 3 次全红:把递增挪到 return 之后 / 删掉 gpx 的作废检查 / 删掉模板里的作废检查。

CDP 实测(A/B,比静态守卫更硬):用 --inject 给 /api/track、/api/gpx、/api/cities 的回包各加 900ms 延迟把竞态窗口撑开,探针「勾选 → 等 120ms(请求还在飞)→ 取消 → 等 2.2s」, 数地图上 path.leaflet-interactive 的条数:

before after delta
有作废检查 2 2 0
去掉作废检查 2 758 756

即:开关明明关着,756 条轨迹线被画了上去。两版都 errs=0——这正是它一直没被发现的原因: 不报错、只是画错。


修复记录(第八批)

D8 上传失败后把半成品留在 uploads 里 ✅

adminUpload 是「先落盘、再解析」:os.Create(stored) → io.Copy → 然后 rond.Open / rond.Parse / ingest.Import。这三步任一失败都直接 return, 没人删那个已经写好的文件;只有「sha 完全相同」那条早退分支里有 os.Remove。 坏包反复上传就在 uploads 里堆垃圾 —— 而 findSourceArchive 恰恰要遍历这个目录 (P5 刚给它加了「先比大小再算 sha」就是在躲这里的成本)。

改法:落盘之后挂一个 keepStored 标志 + defer,只有「新导入成功」那一支把它置为 true (那份上传件要留给导出当基线源,findSourceArchive 按 datasets.sha256 找的就是它)。 顺带修掉一条同类泄漏:st.Identical(内容一致、只是重导了一次)那支原本也把重复件留在了盘上。

回归:upload_cleanup_test.go(DB)。投一个名字像备份包、内容是垃圾的文件,断言 ① 303 带 err=;② 上传目录没有新增条目。带前提守卫:必须从 Location 里读出 「解析备份失败」——否则若落在更早的分支(类型不对就退),上传目录压根没动,断言只是空过。 变异:把清理改成空操作 ⇒ 红并报出泄漏的文件名 1789988541-zz-broken.rondbackup。

P8 recover 在响应已写出时追加错误体 ✅

recover 无条件 http.Error(w, "服务器内部错误", 500)。若 handler 已经写了半个页面才 panic: http.Error 会二次 WriteHeader(Go 记一条 superfluous response.WriteHeader), 错误体接在半个页面后面 —— 用户看到「半页正文 + 一句服务器内部错误」,日志里还多一条噪音。

改法:recover 用一层 headerSent 包装 ResponseWriter(WriteHeader/Write 任一发生即置 sent),命中 panic 时先看它:已写出就只记日志,不再动响应。包装层实现了 Unwrap()—— 少了它 http.NewResponseController 在它这里拿不到 SetWriteDeadline,导出那条 10 分钟写死线会静默失效 (这正是 P4 给 gzipWriter 补过的同一个坑)。

回归:server_error_test.go 三条 —— ① 写了半页再 panic:状态码保持 200、正文不含错误文案; ② 什么都没写就 panic:仍是 500 + 人话;③ 用现成的 deadlineRecorder 确认 ResponseController 能穿过这层包装。变异 2 次全红:还原成无条件 http.Error ⇒ ①红;删掉 Unwrap ⇒ ③红 (feature not supported)。


修复记录(第九批)

D6 轨迹图层与里程统计的区域判定不一致 ✅

(原报告写的是「可见性口径不一致」;查清后具体是区域判定的连接方式不同,与迷雾遮罩无关。)

同一套区域筛选下,两边的判定是:

白名单(只看这些区域) 黑名单(排除这些区域)
轨迹图层 apiTrack regionSQL("pf") + regionSQL("pt") ⇒ 两端都命中 NOT(pf) AND NOT(pt) ⇒ 两端都不在
里程统计 movementsWhere EXISTS(... pmv.id IN (from,to) ...) ⇒ 任一端命中 NOT EXISTS(任一端命中) ⇒ 两端都不在

黑名单下两种写法恰好等价,白名单下才分叉:一段「一端在北京、一端在上海」的位移, 白名单只选北京时,图层画不出来(缺上海那端)、总里程却把它算进去了。而且图层这一侧的 「两端都要在」才是安全的那一侧 —— 白名单的用意就是不暴露区域外的坐标。

改法:把 movementsWhere 的区域条件改成对两端各判一次、两端 AND(与 regionSQL 的拼法一致),顺带把 IN 换成 COALESCE(...) = $n:district 常为 NULL, NULL IN (...) 得 NULL,黑名单的 NOT (...) 也跟着变 NULL ⇒ 整行被误删 (regionSQL 早就用 COALESCE 绕过了这个坑,这是两处实现的第三点分叉)。

回归:region_consistency_test.go 是对账测试,不是各写一遍 SQL 互比——那样只是把漂移 抄两遍。它自建一个封闭数据集(3 个地点、2 段位移:一端在外 10km / 两端在内 20km), 一边跑真实 regionSQL 拼出来的图层谓词,一边跑真实 stats.Overview,断言两者相等。 变异:把 movementsWhere 还原成旧的「任一端命中」⇒ 红,报 轨迹图层与里程统计口径不一致:图层 20.00 km,统计 30.00 km。


修复记录(第十批)

P6 fillupCache 无界增长 ✅

fillup.go 的 fillupCache 是全局 map,只写不清——站点换了几轮数据集之后会一路涨下去。 改成带上限的 FIFO:条目超过 fillupCacheMax(2048)就丢最旧的四分之一(fillupOrder 记插入顺序),比整表清空温和,不会一次性把所有高德建议都丢掉;覆盖写同一个键不重复记账 (否则 order 会比 cache 长,淘汰时错删)。 守卫 fillup_cache_test.go:写 1.5×上限,断言条目 ≤ 上限、order 与 cache 等长、最新在、最旧被淘汰。 变异:把阈值抬到 ×1000 ⇒ 红(「应被限制在 2048 以内,实际 3072」)。

P7 统计页「周几」统计查了两遍 ✅

handlers_public.go 里 d.Weekday 已经在 runJobs 的 weekday 任务里查过, 渲染前又同步查了一遍——第二个纯属多余,白跑一次查询。删掉后者。 守卫 stats_weekday_once_test.go:handlers_public.go 里 s.q.Weekday( 必须恰好出现一次 (>1 = 又重复查了;0 = 写法变了、守卫需要同步更新)。 变异:把那次调用加回去 ⇒ 红(「应恰好出现 1 次,实际 2 次」)。

P3 adminNotesBulk 逐条往返 ✅

删除 / 清空 / 设结论三个分支原本都是 for _, id := range ids 里逐条调 store: 勾 50 个地点就是 50(清空是 100)次数据库往返,1C1G 的机器上体感很明显。改成一条语句:

  • 新增 notes.DeleteBulk / notes.SetNoteBulk / votes.SaveVerdictBulk;
  • 用 p.src_pk = ANY($3::bigint[])(pgx stdlib 直接吃 []int64,不必 pq.Array);
  • SetNoteBulk 两条语句等价于逐条 SetNote(..., ""):先删「别名与 rond 备注都为空」的行, 再把其余行的手写 note 清空(顺带把 dataset_id/src_pk 刷成当前数据集的值,与逐条 upsert 对齐)。

守卫 admin_notes_bulk_test.go:①静态扫描——adminNotesBulk 函数体里不许再出现逐条的 s.notes.Delete(/s.notes.SetNote(/s.votes.SaveVerdict(,且必须出现对应的三个 *Bulk 调用; ②端到端——三个地点依次走「设结论 / 清空 / 取消结论 / 删除」,每一步都断言全部被勾的地点生效。 变异:①把 delete 分支退回逐条循环 ⇒ 静态守卫红;②给批量 INSERT 加 LIMIT 1 ⇒ 端到端红 (「三个地点都该设上结论,实际 1」)。


修复记录(第十一批)

本批把原报告「前端 / 模板」里剩下的 9 条一次清完(F5/F6/F8/F9/F10/F14/F16/F17/F18)。 全是前端脚本 / 模板 / CSS,不碰数据库与导出路径 ⇒ 导出与迷雾快照的数值与前一批逐项相同。

F5 地图脚本里的 localStorage 裸访问 ✅

rond-map.js 有一处读写没包 try(lsGet/lsSet 那两处本来是有的)。隐私模式 / 禁 Cookie 的 浏览器里 localStorage 读写会抛,异常从那行冒出去会打断整段脚本——地图白屏、图层不出来, 而且控制台之外看不出来。统一加 lsGet/lsSet 兜底,其余读写全改走这两个函数。

守卫 frontend_fixes_test.go::TestMapLocalStorageGoesThroughGuard:裸 window.localStorage. 只许剩 lsGet/lsSet 里那 2 处。 变异:把 lsGet(BASE_PICK) 退回裸调用 ⇒ 红(「应只剩 2 处,实际 3 处」)。

F6 配置未回来时第二次 preview() 的点被丢掉 ✅

preview() 复用只认 _state[id]:/api/map-config 慢于第一次 preview() 时 _state 里 还没有条目,第二次(照片导入页点「重新反查」)的点就被静默丢弃——地图上少一批点,不报错。 改为在 if (live) 之前就把本轮点集记进 previewQueue[id],配置回调里以最新一次为准取回并删除。 admin/photos.html 的 openPointMap 一并收口成统一走 window.rondMap.preview(),删掉了 modalInited 那条「只有当 _state 已建好才画点」的分支。

守卫 TestMapPreviewQueuesPendingPoints(previewQueue 赋值必须早于 if (live)、回调里必须消费、 不许再出现 previewPending)。变异:删掉那行赋值 ⇒ 红。

F8 EXIF 全 0 时间会变成 1899-11-30 ✅

0000:00:00 经 new Date(0, -1, 0) 归一成 1899-11-30 且 hasTime=true,照片导入页会把它 当到达时间写进库。改为赋给 res.shot/hasTime 之前先做范围校验(年 1900–2200、月/日/时/分/秒各自 上下界),超范围一律当「没有时间」。

守卫 TestExifRejectsOutOfRangeDate。变异:把条件换成 if (true) ⇒ 红。

F9 findExifItemID 越界读 ✅

旧守卫 start + 4 > length 在恰好相等时放行,紧接着的 getUint16 越界抛 RangeError, 用户看到的是一句英文的 DataView 报错。改为读 count 之前按版本把宽度也卡住 (ver === 0 ? p + 2 > len : p + 4 > len)。

守卫 TestExifItemIDChecksBoundsBeforeRead。变异:删掉该行 ⇒ 红。

F10 补全页 sessionStorage 读写无兜底 ✅

(代码在更早的提交 82e6341 里就已修好,本轮补上守卫,把这条从「尚未处理」里清掉。) JSON.parse(sessionStorage…) 与 sessionStorage.setItem 现在全部包在 try 里。

守卫 TestFillupSessionStorageIsGuarded:每个 JSON.parse(sessionStorage / sessionStorage.setItem 出现处往前 160 字符内必须有 try {。 变异:还原成裸 JSON.parse ⇒ 红。

.nav-links a{padding:5px 7px} 与后面「窄屏触控」块同特异性、写在前面 ⇒ 被盖掉,纯死代码。 删掉内边距,只留 font-size: 13px(内边距由那块给)。

守卫 TestNavLinksNarrowRuleHasNoDeadPadding。变异:另加一条 .nav-links a { padding: … } ⇒ 红。

F16 三个筛选表单带参不一致 ✅

「到访」搜索表单原来只带 vq+back,一提交就把 q/src/sus 和行程的 mt/mp/mfrom/mto 全清掉;而「重置」反而保留全部条件。三个表单现在都把另外两组条件用隐藏域原样带上。

守卫 TestEditSearchFormsCarryAllFilters(逐个 form method="get" action="/admin/edit" 扫 8 个参数名)。 变异:把「到访」表单里 name="q" 的隐藏域去掉 ⇒ 红(「第 2 个筛选表单少了 name="q"」)。

F17 safe 模板函数 ✅

模板零调用,当前没风险,但留着就是个现成的 HTML 注入出口;删掉。

守卫 TestNoSafeEscapeFunc。变异:把 "safe": 加回去 ⇒ 红。

F18 纯图标按钮缺 aria-label、图片 alt 为空 ✅

图片删除按钮(×)补 aria-label="删除这张图片"(服务端渲染与 JS 新建两处都补), 访客画廊图片补 alt(地点名 + 「的图片」)。

守卫 TestPlaceImageControlsAreLabelled。变异:把 aria-label 改短 ⇒ 红。

顺带(同批前端小修)

  • static/js/place-image.js、admin/photos.html:fetch 回包先判 r.ok 再 r.json() ——原来 500 会把 HTML 当 JSON 解析,抛一句看不懂的 SyntaxError。
  • place.html 的 #imgs-admin 补 data-name,供脚本取地点名。

尚未处理

(空) —— 原报告 P0(2 条)/ P1(8 条)/ P2 前端(F1–F18)/ P2 性能(P1–P8) 已全部落地并有守卫;第十一批收尾时逐条复核过「尚未处理」清单,无遗留。

N1 TestPlaceUpdateBatchActivity 偶发 245/246 —— 已定位并修掉 ✅

根因不是导出,是测试自己把 SQL 参数绑错了位。

那条断言是:

SELECT count(*), count(*) FILTER (WHERE ZACTIVITY_=?) FROM ZVISIT WHERE Z_PK IN (?,?,…)

FILTER 里的 ? 在 SELECT 列表,是整条语句的第一个占位符;而代码是先追加全部 pks、最后才 追加 actSrc(args = [pks…, actSrc])。于是:

  • FILTER 绑到了 pks[0] 而不是 actSrc ⇒ converted 只有碰巧 pks[0] == actSrc 时才等于 246;
  • IN 列表被顶掉一位,变成 pks[1:] ∪ {actSrc} ⇒ 当 actSrc 不在包内 ZVISIT.Z_PK 里时命中 245 行, 正是那次「只有 245/246、缺 []、pks 内重复 []」的签名——缺的其实是 pks[0],而它正好被 actSrc 顶掉了,所以「缺失列表」是空的。这个组合本身就是错位的指纹,而不是丢行。

修法:actSrc 排到参数列表最前,ph/args 在同一个循环里成对生成,并加一条 strings.Count(q,"?") != len(args) 的自检。

验证:

  1. 连跑 6 次全绿(原来 4 次里 1 次红)。
  2. 变异——让导出把 ZVISIT.ZACTIVITY_ 写成站点内部 id 而不是 rond 的 src_pk (export.go 的 actPK = sp → actPK = v.ActivityID.Int64)⇒ 立刻红,报 导出包里只有 0/246 条到访的活动换成了 #1(站点内部 id 75881)。

也就是说:修好之前,那条断言是靠巧合才绿的,在正确性上等于没起作用;现在它真的守住了 「导出的活动编号必须是 rond 的 src_pk」。

先前的怀疑方向(datasetFor 全局取活跃数据集、缺 tiebreaker)已排除:包内 ZVISIT 一直是 824 行, 一份都没少。


验收状态(2026-09-21 收尾)

ROND_TEST_CONF=conf/app-test.ini \
ROND_SRC=<abs>/data/20260914_151649.rondbackup \
FOG_SRC=<abs>/data/Snapshot-20260914T154839+0800.fwss \
ROND_DSN=postgres://…@127.0.0.1:5432/rond_fog_test?sslmode=disable \
ROND_CLEAN_OUT=<abs>/acceptance-clean.rondbackup \
ROND_EXPORT=<abs>/acceptance-clean.rondbackup \
go test -count=1 -v ./...

243 PASS / 0 FAIL / 0 SKIP,exit=0(12 个包全部 ok;第五批 218、第六批 224、第七批 225, 第八批 D8/P8 +4、第九批 D6 +1、第十批 P3/P6/P7 +4、第十一批前端 F5/F6/F8–F10/F14/F16–F18 +9)。 两段式导出:823/323/8/30/791/557/2246 全等、坐标偏差 0.000000000、时间偏差 0s; 迷雾快照 8701 块 / 106 个其它层条目(575753 格)逐字节一致 ——第十一批只动前端脚本 / 模板 / CSS,不碰数据路径,数字与第六批起逐项相同。

⚠️ 两个「假绿」坑重申:①只设 ROND_EXPORT、不设 ROND_CLEAN_OUT 跑全量,会留下 1 SKIP (TestMovementCleanExport 自己跳过)——要 0 skip 就六个变量全带上,让 stage 1 也在这一轮里跑; ②ROND_CLEAN_OUT 指向一个还不存在的新文件名时 internal/rond(字母序在 web 前)会读到 不存在的包,导出往返 4 条一起假红。


完整审查(2026-09-23 收尾)

一、变更规模

项 数
修改文件 48(+1,806 / −431)
新增文件 38(37 个测试 + internal/web/csrf.go)
新增守卫用例 82(分布见下)
落地问题 P0 ×2、P1 ×8、P2 前端 F1–F18 ×18、P2 性能 P1–P8 ×8 —— 全清

守卫分布:web 22 个文件 / 49 例,auth 3 / 10、fog 2 / 6、rond 3 / 8、stats 2 / 4、 ingest 1 / 1。

二、逐项核对:报告已无遗留

  • 「尚未处理」清单(F5/F6/F8–F10/F14/F16–F18)逐条复核:第十一批修掉 8 条; F10 的代码在更早的提交 82e6341 里就已修好(fillup.html 的两处 JSON.parse(sessionStorage…) 早已包在 try 里),本轮只补了守卫 ⇒ 清单清空。
  • F11/F12/F13/F15 复核后确认在本轮之前的批次已修(--nav-h 变量化、--z-map-overlay/-status 1100/1150、.kpis 死代码删除、5 个未使用类删除),原报告表格里漏标 ✅,已回填。

三、跨模块不变式复查(本次静态复核)

不变式 结论
附属数据主键 = 坐标键 ✅ 统一走 notes.placeKeyExpr,notes / votes / stats / handlers_images 共用同一表达式
区域判定「两端各过一遍再 AND」 ✅ apiTrack 用 regionSQL("pf")+regionSQL("pt");movementsWhere(stats.go:294)同样两端各判
统计的两套 where ✅ visitsWhere / movementsWhere 都在,活动/城市等只长在到访上的条件由 hasMovementVisitConds 分流
调试残留 ✅ Go 无 fmt.Print/TODO;JS 无 console.log/debugger(alert 是正常错误提示)
gofmt / go vet ✅ 干净(无 CRLF 假警)
既有测试是否被削弱 ✅ 逐条看过三处「有删除行」的 diff:place_activity_test.go 删的是 N1 那个参数绑错位的写法;wide_table_layout_test.go 是收紧(判据从「在 .tw 里」扩成「用了 tb 基类」);backup_diff_test.go 只删注释
新增临时产物 ✅ storage/、data/、conf/app-test.ini、.workbuddy/ 均在 .gitignore

四、抽查复核(对前批「变异验证」结论的独立复验)

随机挑 3 条较早批次的守卫,重新注入原始 bug,确认仍然能红:

守卫 注入的 bug 结果
TestTrackAndMileageAgreeOnRegionFilter(第九批 D6) movementsWhere 退回「任一端命中」 🔴 红,报文与第九批记录逐字相同:图层 20.00 km,统计 30.00 km
TestGpxQueriesCarryUserID(第五批 S8) adminGpxDelete 的 SQL 去掉 user_id=$2 🔴 红:SQL 里没有 "user_id":轨迹没按属主圈定
TestGpxOwnershipScoping(第五批 S8,端到端) 同上 🔴 红:删别人的轨迹应当回「不存在」,实际 "轨迹已删除" / 越权删除(id=29)

三条全部还原后转绿,且 git diff --stat 与注入前逐字相同 ⇒ 还原干净。

五、结论

  • 12 个包 ok,243 PASS / 0 FAIL / 0 SKIP;导出与迷雾数值与前一批逐项相同。
  • 全部 18 条前端 / 8 条性能 / 10 条 P0+P1 均有守卫,且守卫逐条做过变异验证(本轮另抽查 3 条复验通过)。
  • 已无未处理项;新增 82 条守卫构成回归网。

⚠️ 第六批首跑出现过 4 SKIP(stats 报「没有天气数据可测」):新增的 ingest 用例在共用库里 Import(会把临时数据集的 is_active 切成 true),而 stats 夹具是 WHERE is_active LIMIT 1 (不带 user 过滤)⇒ 被它选中;加上清理写成 defer d.Close() + t.Cleanup(删)(Cleanup 在 defer 之后跑、连接已关、删除静默失败)留下 5 个残留用户永久污染。已改为派生专用库并修正清理顺序。

⚠️ 环境变量必须四件套齐全,且 ROND_DSN 要指应用测试库 rond_fog_test(名字含 test): 指到 postgres 维护库会多出 TestChangeUsername 失败(关系 users 不存在)+ 6 个 stats 用例 skip (没有生效数据集)+ TestVisitorHomeStore skip(库名不含 test)——本轮就先踩了一次,误以为是回归。


稳定性基线(2026-09-24 实测)

修完之后要回答「现在稳不稳」,先量四个数:

指标 实测
语句覆盖率(go test -cover ./...,全套环境变量) 按源码行数加权 ≈51%(web 53.2% / stats 62.2% / rond 38.8% / fog 52.9% / db 53.4%)
假绿规模(不设环境变量跑 web+rond+stats) 三个包全 ok、exit=0,但 95 PASS / 86 SKIP(原始基线 62 SKIP ⇒ 随守卫增多而变大)
路由触达 92 条注册路由里 41 条在测试中被直接引用(高估缺口:不少 handler 用函数直调覆盖)
自动化 无 CI / 无 Makefile / 无 linter —— 0-skip 验收原本完全靠人记住六个变量

结论:已知缺陷已关闭且有网,但「一半代码没被跑过 + 没有 CI」这两条当时的回答是"证明不了"。

收尾:让「验收」不再依赖记忆(2026-09-24 落地)

scripts/verify.sh —— 一键验收

能力 说明
六件套环境变量 自己从 conf/app-test.ini 取 DSN(口令不进脚本)
拒跑非测试库 库名不含 test 直接报错退出(防「指到 postgres 维护库」那类假 skip)
拒绝假绿 数 SKIP(含子测试),非 0 就失败退出
两段式导出 先跑 TestMovementCleanExport 产纯净包,再全量(免手工两步)
模式 --fast(gofmt+vet+build,~20s)/ --race(自动把 mingw 加进 PATH)/ --cover / --pkg / --run

实测闸门:DSN 指 postgres → 拒跑;缺 ROND_SRC → 拒跑;未知参数 → exit 2。

全新库冒烟(internal/db/fresh_db_test.go)

空库 → Open 自动建库 → Migrate 跑两遍(验幂等)→ 断言 21 张表 / 迁移块补出来的列 / 三张附属表的主键已搬到坐标键 / 4 个索引 → 再用 stats.DB{DB: d} 真打 18 个读查询(空库上不该报错)。

变异验证:把 CREATE INDEX idx_place_images_key 挪到 CREATE TABLE place_images 之前 ⇒ 新库报 关系 "place_images" 不存在 (SQLSTATE 42P01);同一条语句在老库上 psql -c 只提示 「已存在,跳过」⇒ 正是「开发库无症、新库起不来」这个签名。

web 整包 -race

./scripts/verify.sh --race --pkg ./internal/web/ ⇒ 161 PASS / 0 FAIL / 0 SKIP / 0 个 DATA RACE。 (此前只有迷雾索引那一条用例验过 race。)

仓库卫生

删 6 个一次性探针(colprobe/diffprobe/exportprobe/fogprobe/idprobe/sqlprobe,git 历史可翻)与 误入库的 __pycache__/*.pyc;.gitignore 补 __pycache__/、*.pyc;tools/README.md 说明留下的 4 个工具。改动分 3 个提交落地(后端 / 前端 / 脚本与工具),工作区已干净。

收尾验收

./scripts/verify.sh            # 六件套 + 0-skip 判定 + 两段式导出,一条命令

244 个顶层用例(含子测试 271)/ 0 FAIL / 0 SKIP,12 个包全 ok; 往返包 823/323/8/30/791/557/2246 全等、坐标偏差 0.000000000、时间偏差 0s、迷雾 8701 块一致。 --fast(gofmt + vet + build)干净。

提交:b4c2a84 后端 → d5f6dfa 前端 → d2e4392 脚本与工具 → ff59412 脚本补丁。 每个提交都过了 go build ./...;中间提交没有单独跑全量(实现与它的守卫是同一批落地的, 拆开跑会因守卫先于/后于实现而假红),最终状态是上面这个已验收的版本。