- 稳定性基线(覆盖率 ≈51%、不设环境变量时 95 PASS / 86 SKIP 的假绿、无 CI) - scripts/verify.sh 的能力与实测闸门 - 全新库冒烟的变异验证(迁移块顺序错了只有新库会炸) - web 整包 -race:161 PASS / 0 DATA RACE - 收尾验收:244 用例 / 0 FAIL / 0 SKIP
72 KiB
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:201io.Copy(out, rc)——rc是 zip 条目解压后的流,无长度上限internal/fog/fog.go:134io.ReadAll(rc)、:199io.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 🔍 |
值得优化的地方
后端
- 统一「写操作必须一个事务」 —— 现在只有建地点+建到访、备份合并做到了;把删除/编辑路径(P1-5 那张表)补齐,并把「到访时间 → clamp 行程 → 聚合重算」串成一条链。这是最容易在导出时才暴露的一类回归。
- 错误分层:对外常量文案 + 错误 ID,对内完整日志(P1-2);顺带把「模板执行失败」从静默 log 改成可观测(P1-1 就是被这个掩盖的)。
render改成先渲染到bytes.Buffer再写响应 —— 渲染失败时还能返回一个干净的 500 页,而不是半个页面 + HTTP 200。一次性解决所有「模板字段打错/指针 nil」类问题。- 筛选条件抽一处:
visitsWhere/movementsWhere/weatherWhere三份手写 where 已经漂移(P1-3、D6)。建议用一个「条件 → SQL 片段」的表驱动生成,或者至少加一条对账测试:任一筛选开关下,Overview的几个数字必须同向变化。 - 给重查询加缓存:
placeCols的 6 个子查询、专题 N+1、导出全量哈希,都是「输入不变、结果不变」的,适合进程内 LRU(项目已有tileCache/maskCache/fillupCache三套现成模式可抄)。 - 连接池/并发参数复核:
parallelLimit=6与连接池 8/8 在统计页已经贴边(一页十几个查询),再加重查询容易排队。可以考虑「重查询单独限 2~3」而不是所有 job 共用 6。 - 日志分级:
logRequests现在只在debug或/admin打日志;生产环境需要「慢请求 + 5xx + panic」的结构化记录(P0-1 的 panic 现在只能靠进程消失来发现)。 - 补 skip 用例的前置检查:在
TestMain里缺环境变量就t.Fatal(而不是 skip),或在 CI 脚本里显式断言「0 skip」。否则「全绿」会一直是假信号(见基线表)。
前端
- 地图层收敛:把
rond-map.js里「异步回调无取消」的地方统一用AbortController+ 一个epoch计数(P1-4、F4、F7 都是同一个病根);preview/full统一走「有实例就复用、容器被换就remove()」。 - 转义统一:tooltip/popup 一律过
esc()(P1-4);模板侧把safe删掉(F17)。 - CSS 收敛:
app.css里的死代码(F13/F15)清掉;z-index统一到一套 token(Leaflet 是 1000,面板应在其上,F12);--nav-h这类尺寸别在别处硬编码 54px(F11)。 - 表格一致性:
fog-mask.html补tb-stack(F2),report.html补</tr>(F1);把wide_table_layout_test.go的「被.tw包住必须带 tb-stack」判据扩成「所有class="tb"的表都必须带 tb-stack 或显式豁免」,这样 F2 会被自动抓到。 html/index.html(856 行)已不被服务:要么删掉、要么移进archive/并在 README 注明,免得以后误改原型当正式页。- 交互细节:
admin/edit.html三个搜索表单的字段集统一(F16,让「重置」真的重置、搜索不丢上下文);place-image.js:99/photos.html:523的r.json()先判r.ok(服务端 500 时回 HTML 会抛 SyntaxError)。 - 可访问性:给纯图标按钮补
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 ⇒9。s[-1]越界; 导出路径Encode里id := t[0] + t[1]*MapTiles,网格第一列就是 0tileIDFromName的长度守卫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(tileNamefog.go:118、tileIDFromNamefog.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, "/") 即失败(并自查扫到的源文件数,
防止守卫假绿)。
S4 会话 cookie 无 Secure ✅
不能无条件打 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改名成非接口方法 —— ResponseControllerfeature 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)。用独立用户跑,跑完删掉, 不会碰到别的用例依赖的生效数据集。
变异验证(三处各自独立、都变红):
- 去掉窗口检查 ⇒
1e30→2001、1e12→2123 又被放行; - 去掉
NaN/Inf检查 ⇒NaN→2001 被放行(说明两个检查互补,缺一不可); - 地点循环去掉
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 ⇒ 红。
F14 窄屏 .nav-links a 的死内边距 ✅
.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) 的自检。
验证:
- 连跑 6 次全绿(原来 4 次里 1 次红)。
- 变异——让导出把
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/-status1100/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 ./...;中间提交没有单独跑全量(实现与它的守卫是同一批落地的,
拆开跑会因守卫先于/后于实现而假红),最终状态是上面这个已验收的版本。