1183 lines
78 KiB
Markdown
1183 lines
78 KiB
Markdown
# 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 ./...`;**中间提交没有单独跑全量**(实现与它的守卫是同一批落地的,
|
||
拆开跑会因守卫先于/后于实现而假红),最终状态是上面这个已验收的版本。
|
||
|
||
|
||
|
||
|
||
|
||
|
||
---
|
||
|
||
# 修复记录(第十二批 · P2:覆盖率口径与关键页冒烟)
|
||
|
||
## 一、先纠正一个测量错误:`rond` 不是 38.8%
|
||
|
||
看到「`rond` 只有 38.8%」本来准备补测试,取证后发现**数字是量错了**:
|
||
|
||
`go test -cover` 只统计**测试文件所在那个包**自己的执行。而 `internal/rond` 一半的代码
|
||
(`export.go` 1416 行)由 `internal/web` 的 `TestMovementCleanExport` 驱动;`geo`/`gpx`/
|
||
`notes`/`votes` 同理由 `web`/`stats` 驱动。按 `-cover` 量,这些包一律报 0%。
|
||
|
||
| 包 | `-cover`(假口径) | `-coverpkg=./internal/...`(真) |
|
||
|---|---|---|
|
||
| `internal/rond` | 38.8% | **82.2%** |
|
||
| `internal/geo` | 0% | **92.9%** |
|
||
| `internal/stats` | 62.2% | **83.7%** |
|
||
| `internal/web` | 53.2% | 53.2%(它自己不假) |
|
||
|
||
⇒ `verify.sh --cover` 改用 `-coverpkg=./internal/...`,并按包打印。**项目合计 62.3%**。
|
||
|
||
解析 profile 时有个坑:`-coverpkg` 下每个测试二进制都会给同一段代码写一条记录,
|
||
同一个 `(文件,行段)` 会出现多条(一条 `count=0`、一条 `count>0`)。**必须按块取 max**;
|
||
直接求和等于把语句数乘上二进制份数,每包都会显示成只有 `1/N`(第一版 awk 就这么错的,
|
||
`web` 印成 4.4%)。
|
||
|
||
## 二、真去补的时候,只补「线上会走到」的
|
||
|
||
把未覆盖行段拉出来逐条看,绝大部分是 `nil` 分支和 `if err != nil { return err }`——补那些是灌水。
|
||
真正「线上会走到、却一条用例都没有」的只有两处:
|
||
|
||
**① `BuildMergePlan` 的地点/活动冲突**(`placePick`/`activityPick` 长期 0%)。差异页线上确实会提交
|
||
这两类选择,而用例只造过「新增」。补 `merge_clash_test.go`(冲突 + 删除逐条勾选)后
|
||
`merge.go` 64.3% → **96.4%**。
|
||
变异验证:把地点判据从 `PickBackup` 改成 `PickDB` ⇒ 红在「只有选了『用实机』的地点该更新」;
|
||
把活动键从 `activityKey(cf.DB)` 改成 `cf.DB.Name` ⇒ 红在「有 UUID 的活动应按 UUID 对上」。
|
||
|
||
**② `internal/gpx` 整包零测试**(61 条语句,0/61)。它解析的是**用户上传的 XML**,
|
||
正是最该有测试的地方。补 `gpx_test.go` 8 条:`trk`/`rte` 两条分支、track 名退回 `metadata>name`、
|
||
`(0,0)` 空点丢弃、单点轨迹丢弃并报错、非法 XML、没有轨迹点、时间格式错不致命、里程数值。
|
||
⇒ **0% → 96.0%**。
|
||
变异验证:`(0,0)` 跳过改成 `if false` ⇒ 红在「该被丢掉,实际剩 3 个点」;
|
||
地球半径 6371→6300 ⇒ 红在「1 度纬度该约 111.19 km」。
|
||
|
||
## 三、顺手清掉两处死代码
|
||
|
||
两者都做过全仓核查(含模板——Go 模板能调方法,`{{.Total}}` 这种不能只搜 `.go`):
|
||
|
||
| 死代码 | 核查结果 |
|
||
|---|---|
|
||
| `rond.Counts.Total()` | 零引用。模板里的 `.Total` 都是各页 view 结构体自己的字段。 |
|
||
| `gpx.Track.BBox()` | 零引用。`handlers_topics.go` 里的 `fitBBox` 是无关变量,`gpx.Track` 从未传进模板。 |
|
||
|
||
**没有为它们写测试**——为了把 0% 填成 100% 而给死代码补用例,只会把死代码固化下来。
|
||
|
||
## 四、一条「实测否证」的记录
|
||
|
||
`geo.Haversine` 用 `math.Asin(math.Min(1, math.Sqrt(a)))` 夹了一道,`gpx` 那份没有,
|
||
SQL 里(`handlers_topics.go`)也没有。三条公式、两个半径(6371.0 / 6371.0088)。
|
||
|
||
本来准备按「缺夹子会出 NaN」去修,**实测不成立**:对跖点附近 `h` 最多到 `1+2^-52`
|
||
((45,0)→(-45,180) 就是这个值),而 `math.Sqrt(1+2^-52)` 会回舍成正好 `1.0`,
|
||
`asin(1.0)=π/2`,出不了 NaN。所以**没改代码**,只留一条 `TestLengthKmAntipodalIsFinite`
|
||
把「对跖点不出 NaN」钉住(将来换公式或去掉开方会立刻红),并在注释里写明这是实测结论。
|
||
|
||
半径差 6371.0 vs 6371.0088 的数值影响:146 km 上差 **0.20 米**,纯属口径不统一,不影响功能。
|
||
|
||
## 五、关键页冒烟:`scripts/smoke.py`
|
||
|
||
Go 侧模板守卫只能证明「模板里有那段文字」。白屏、JS 抛错、内容区块被压成 0 高,
|
||
**模板测试全是绿的**。冒烟按三种口径各走一遍(16 页):
|
||
|
||
1. **站长(浏览器)**:HTTP 200 + 落点路径没跳走 + 内容区块在且尺寸非 0 + 零 JS 报错
|
||
2. **访客(纯 HTTP)**:公开页 200;后台页必须 **303 跳登录**(绝不能 200)——`urllib` 不跟随跳转即可,
|
||
比开 Chrome 快一个数量级
|
||
3. **访客(浏览器)抽检** `/` 与地点详情:只在访客侧复现的 bug 藏在这里
|
||
|
||
接进 `verify.sh --smoke`(只跑冒烟)/ `--all`(全量 + 冒烟)。
|
||
|
||
三条实测踩出来的坑:
|
||
|
||
- **关键区块不能取 `nav.nav`**:它在 `layout.html` 里,内容块整个没渲染它照样在,等于没断言。
|
||
改成地图页取地图容器、其余取首个 `.card`(`#home-map` / `#map` / `#place-map` / `#taxonomy`)。
|
||
- **用 `.card` 而不是 `div.card`**:专题列表的卡片是 `<a class="card topic-card">`,
|
||
写 `div.card` 一个都选不中——而 `querySelector` 返回 null 时尺寸也是 0,打印出来跟
|
||
「元素被压成 0 宽」一模一样。第一版就据此差点去查布局 bug。日志已把两者分开印。
|
||
- **`--min-body` 只是兜「整页空白」**:专题列表正文天生 166 字(就一个专题卡片),阈值定 200 会假红。
|
||
另外「公开页面」是受总开关控制的,冒烟先探测 `/places` 的访客状态码再决定同类页的期望值。
|
||
|
||
**变异验证**:往 `timeline.html` 塞一句 `<script>throw new Error('smoke-mutation-probe')</script>`
|
||
(`debug=true` 下模板热加载,不用重启)⇒ 冒烟立刻红在「JS 报错」上,且**状态码、内容区尺寸全都正常**
|
||
——正好说明没有这条断言就会放过它。验完已还原(`git status` 无该文件)。
|
||
|
||
## 六、收尾验收
|
||
|
||
```
|
||
./scripts/verify.sh # 284 个顶层用例(含子测试更多)/ 0 FAIL / 0 SKIP
|
||
./scripts/verify.sh --all # 全量 + 冒烟 16 页全绿
|
||
./scripts/verify.sh --cover # 按包覆盖率 + 项目合计 62.3%
|
||
./scripts/verify.sh --fast # gofmt / vet / build 干净
|
||
```
|
||
|
||
`--fast` / `--smoke` / `--cover` / `--all` / 未知参数 → exit 2 的门都实测过。
|