# 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$$` 这种固定格式,**前 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 = ['
' + esc(p.n || '未命名地点') ... // popup 有 esc 382: m.bindTooltip(p.n || '未命名', {...}); // ← 无 esc 772: ...bindTooltip((s.m || '行程') + ...) // ← 无 esc 818: }).bindPopup('' + c.city + '' + ... // ← 无 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` | `最南…{{f1 .South.Lat}}°N{{end}}` —— **缺 ``** ✅(浏览器会自修,但 `tb_stack_test` 的行列校验会静默跳过这一行) | | F2 | `admin/fog-mask.html:43` | `` 既无 `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` 补 ``(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` 缺 `` | ✅ | `tb_static_test.go` 行标签配平(`` 与 `` 计数) | | 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=/data/20260914_151649.rondbackup \ FOG_SRC=/data/Snapshot-20260914T154839+0800.fwss \ ROND_DSN=postgres://…@127.0.0.1:5432/rond_fog_test?sslmode=disable \ ROND_CLEAN_OUT=/acceptance-clean.rondbackup \ ROND_EXPORT=/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 ./...`;**中间提交没有单独跑全量**(实现与它的守卫是同一批落地的, 拆开跑会因守卫先于/后于实现而假红),最终状态是上面这个已验收的版本。