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

1079 lines
72 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
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`
```go
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`
```html
{{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`
```go
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`
```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`
```sql
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`
```go
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`,编号 0~9 时为 **-1** ⇒ `s[-1]` 越界;
导出路径 `Encode` 里 `id := t[0] + t[1]*MapTiles`,网格第一列就是 0~9。
- `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, "/")` 即失败(并自查扫到的源文件数,
防止守卫假绿)。
## 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` 改名成非接口方法 —— 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` ⇒ 红。
## 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 参数绑错了位。**
那条断言是:
```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 ./...`;**中间提交没有单独跑全量**(实现与它的守卫是同一批落地的,
拆开跑会因守卫先于/后于实现而假红),最终状态是上面这个已验收的版本。