# 2026-09-21 ## 记忆维护 - `MEMORY.md` 超限(25KB)被注入截断 ⇒ 已收敛:把 details 未覆盖的新知识(跨备份 UUID、坐标键主键、合并执行、迷雾三段式、tb-stack、活动/类别、补全页交互、导出排除顺序)并入 `MEMORY-details.md`(新增第 19–27 节,36.7KB),`MEMORY.md` 重写为 ~6.5KB 索引(只留「违反了会静默出 bug」的判据)。 ## 全量排查(产出 `audit-2026-09-21.md`) 范围:77 条路由 / 23 个页面集 / 96 个 Go 文件 / 24 模板 / 4 前端脚本 + app.css。方法:4 路只读深挖 + 关键结论回读复验 + 模板行为实测 + `go build`/`vet`/`test` 实跑。 ### 新确认的 P0(本次实测坐实) - **`internal/web/parallel.go:43-57` `runJobs` 子 goroutine 无 recover** ⇒ 任一 job panic **终止整个进程**(`server.go:317` 的 recover 只管请求 goroutine)。可用性最严重。 - **`internal/auth/auth.go:199` `hash[:min(len(hash),16)]` 恒为常量**(`pbkdf2$sha256$21`)⇒ MAC 只绑 `uid|exp`,**改密码不失效旧会话**(注释声称相反)。`SignPending2FA` 同。 ### 新确认的 P1 - `layout.html:215` `{{d .Dataset.Last}}`:`d` 收 `time.Time` 而 `.Last` 是 `*time.Time`。**实测**:非 nil 会被模板解一层指针、正常工作;**nil 时报错并截断输出**。`refreshDatasetAgg`(LEFT JOIN)在数据集零到访时写 NULL,编辑路径调用 9 次 ⇒ 可达。后果是 HTTP 200 + 页脚之后整段不渲染(含 Leaflet/脚本引入),服务端只有一行 log。 - `server.go:447` `serverError` 回显 `err.Error()`,调用点含 `home`/`mapPage`/`placesPage`/`placePage`/`reportPage` 等**公开页**与公开 JSON 接口。 - `stats.go:195` `movementsWhere` 比 `visitsWhere` 少了 City/Province/ActivityIDs/MinDwell/TagID/Keyword ⇒ 按这些维度筛选时「总里程/出行方式」仍是全量。 - `rond-map.js` tooltip 未转义(382/772/818/836)而 popup 已 `esc()`(340-348)——同文件内不一致。 - 多语句写操作未包事务:`handlers_edit.go:699/1235/1411/1332+1342/1832+1848`(建地点+建到访、合并两处反而是对的)。 - 解压无上限:`rond/parse.go:201`、`fog/fog.go:134/199`(上传只限压缩后体积)。 - `^-?[0-9.]+$` 不是浮点校验却紧跟 `::float8`:`handlers_edit.go:2315`(suspectWGS)、`coord.go:40`;`:187` 那处连正则都没有。 - `main.go:50-52` 默认口令 `rond-admin`,登录只有一次性验证码、无失败限速。 ### 其他值得记的 - **`go test ./...` 全绿是假信号**:本机 62 SKIP / 59 PASS(缺 5 个环境变量),`internal/web`/`rond`/`stats`/`fog` 的重用例全在 skip 里。 - `html/index.html`(856 行)已不被服务(`go:embed` 与 routes 都无引用)。 - ✅ 已核对**不是**问题:SQL 全参量化、`ORDER BY` 白名单、`serveMedia` 路径穿越、无 SSRF、验证码不可重放、无模板 XSS 与 405、26 张 tb-stack 表类名成对且 data-label 全齐。 ## 修复落地:第 1–4 批 + 途中新发现 1 个 panic **纪律**:每条都先做变异验证(先确认测试能变红,再还原确认变绿)。全量套件(含 `-p 1` 验收)0 失败。 ### 新增回归 `web/tx_atomicity_test.go`、`web/server_error_test.go`、`web/map_escape_test.go`、`web/tb_static_test.go`、`rond/limit_test.go`、`fog/limit_test.go`、`auth/guard_test.go`、`web/login_guard_test.go`、`stats/placecols_test.go`、`web/topic_stats_test.go`、`fog/tilename_test.go`。 ### 性能 P1:placeCols 的相关子查询缺索引前导列(已实测) `idx_visits_place` 是 `(dataset_id, place_id)`,6 个子查询只写 `place_id=p.id` ⇒ 每轮为库里每个 dataset 各下一次索引。 Index Searches 2371→366、Buffers 23737→5669、actual time 53.4ms→31.5ms。跨 dataset 的到访 0 条 ⇒ 该谓词语义冗余。 ### 性能 P2:专题列表 N+1 → 一条 UNION ALL 新增 `fillTopicStatsBatch`(顺手发现「天数」查询与点位查询的到访集合完全相同,纯重复);删掉 `fillTopicStats`,列表页与详情页共用 `topicAreaCond`。回归以详情页 `topicContent` 为预言机(3 轮 × 5 专题)。 ### 新发现 bug:`fog.tileName`/`tileIDFromName` 单数字编号越界 panic - 导出:`Encode` 里 `id := t[0]+t[1]*MapTiles`,第一列就是 0~9 ⇒ M2 尾缀循环起点 `len(s)-2 = -1` ⇒ `s[-1]` 越界。 - 导入:长度守卫 `len(name)<7` **恰好放行**「4 位 md5 + 1 位掩码 + 2 位尾缀」的 7 字节名,md5 公开可算 ⇒ 构造 .fwss 上传即 panic(DoS)。 - 修复:`tileDigits(id)` 不足两位补前导 0、编解码共用;守卫收紧到 `len(name)<8`。真机编号 ≥2 位 ⇒ 导出字节不变。 ### 两个踩过的坑(会静默浪费时间) - **`time.Time` 的 `==` 会连内部 `*Location` 指针一起比**:pgx 每次 Scan 都新建 Location ⇒ 「值相同」也判不等(366 行里假报 325 行差异)。比对数据库行一律 `json.Marshal` 后比字符串。 - **静态 SQL 守卫必须按子查询分段**:`placeCols` 里 v2 出现 3 次,全文 `Contains` 会被另外两处顶包,变异跑不出红。要先按 `FROM visits (vN)` 的匹配位置切段再判。 ### 尚未处理 P2 的 S1–S9 / D1–D8 / F4–F10、F14、F16–F18,性能 P3–P8。 建议优先:S5(CSRF token / JSON 端点 Origin 校验)、S4(cookie 无 `Secure`)、S3(两处 `//evil.com` 协议相对跳转)、D2(`writeRawRow` 的 marshal 失败只 log ⇒ 留档静默丢失)、P4(导出整包进 buffer,受 `WriteTimeout 120s` 约束)。 ## 修复落地(第二批):S3 / S4 / S5 / D2 ### S3 协议相对跳转 ✅ 同一份判据在仓库里有**三份实现**:`view.go` 的 `backFromRequest` 是加固过的正确版, `handlers_admin.go` 的 next 与 `handlers_vote.go` 的投票后跳转是**手写的弱化版**(只判 `HasPrefix(next, "/")`,放行 `//evil.com`)。 ⇒ 抽成纯函数 `safeInternalPath`,三处 + 登录跳转共用。回归:`redirect_target_test.go` (21 个拒绝样例 + 静态守卫 `TestNoWeakRedirectPrefixCheck`,守卫还自查扫到的文件数防假绿)。 ### S4 会话 cookie 补 Secure ✅ **不能无条件打**:本机 http 调试与 CDP 验证流程都跑在 http,打了 Secure 谁都登不进去。 按「本次请求是否 https」判断:`r.TLS != nil`,或 `X-Forwarded-Proto`(多段取首段)。`login` 签名多了 `*http.Request`。 回归:`session_cookie_test.go` 五种来源 + 断言 HttpOnly/Path/SameSite 未被顺手改掉。 ### S5 CSRF 纵深防御 ✅ 先取证:**全部改状态的路由都是 POST**(没有 GET 改状态)✓,cookie 已是 `SameSite=Lax`(跨站 POST 本就带不上 cookie)。 故定位为纵深防御。新增 `csrf.go` 的 `csrfOriginGuard`(只对 POST/PUT/PATCH/DELETE 生效): 有 `Origin` 必须本站(`null` 一律拒)→ 无则看 `Referer` → 都没有则放行(跨站 POST 浏览器必带 Origin)。 **一行模板都不用改**。拒绝时日志带 Origin/Referer/Host,反代配错一眼可见。 部署前提:反代要传原始 Host(或用 `X-Forwarded-Host` 兜底)。 回归:`csrf_test.go`(14 例单元 + 走完整中间件链的接线测试)。变异两轮:①摘掉中间件(只有接线测试红)②判据恒真(都红)。 ### D2 留档静默丢失 ✅ `writeRawRow` 的 `json.Marshal` 失败原来只 log 后 `return nil` ⇒ 调用方以为写成功、合并照常提交、原始列永久缺失。 改为上抛由 `withTx` 整笔回滚。回归:`entity_raw_test.go` 用 `math.NaN()` 触发真实序列化失败, 断言「必须报错 + 失败不留半截行 + 正常值仍写得进去」(第三条防断言假绿)。 ### 下一批建议 🔴 **S1**(`fogResetIndex()` 无锁写,全报告唯一真 data race)、🔴 **S7**(TOTP 不消耗验证码 + `login2FA` 无限速,`auth.Guard` 现成可复用)、 🟠 **S2**(图片上传缺 `MaxBytesReader`,一行改动)、🟠 **D3**(`parse.go` 不查 `rows.Err()`)、🟠 **P4**(导出整包 buffer + WriteTimeout)。 --- # 第三批(S1 / S7 / S2 / D3 / P4)✅ ### 先说两个会让验证白跑的坑 1. **`-race` 需要 cgo + C 编译器**,本机原本没有 gcc(`go env CC` 写着 gcc 但 PATH 里没有)。 解法:`scoop install mingw`(装到 `~/scoop/apps/mingw/current/bin`), 跑法:`export PATH="$HOME/scoop/apps/mingw/current/bin:$PATH"; CGO_ENABLED=1 go test -race ...`。 2. **用 `newMovementFixture` 的新测试会静默 SKIP**(它同时要 `ROND_TEST_CONF` 与 `ROND_SRC`)。 只带 `ROND_TEST_CONF` 时结果是 `ok`,看起来全绿其实一条没跑——这轮就这样骗过了一次变异验证 (变异后仍「通过」,一打印 body 才发现根本没进函数)。**验收一律用 `-v` 数 SKIP,别只看 ok。** ### S1 迷雾索引 data race ✅ `fogIndexV` 普通指针 → `atomic.Pointer[fog.Index]`(`Load`/`Store`)。 **这条是全报告唯一真正的 data race,也是第一次真跑出 -race 报告**: 变异回普通指针后 `-race` 报 `WARNING: DATA RACE`(write @fog.go:453 `fogResetIndex` vs read @fog.go:45 `fogIndex`), 恢复 atomic 后干净通过。回归 `fog_race_test.go`(8 读者 + 1 重置者)。 ### S7 两步验证限速 + 验证码一次性 ✅ `login2FA` 接 `loginGuard`,键 `"2fa:"+用户名`(与口令环分开计数);新增 `auth.CodeReplay` (`Consume(uid, code)`,`replayTTL = 3*totpStep = 90s` 覆盖 ±1 步窗口)。 重放**不计入爆破计数**(用户手抖重复提交也会撞上,不该越锁越久)。 阈值是第 5 次失败才上锁(`guardFreeTries=5`)⇒ 测试里「几次才锁」要写成循环探测,别硬编码次数。 变异两轮:摘掉入口 `RetryAfter`(锁定期内递正确码能登录成功)、`Consume` 短路成 `if false`(同码拿到 303)——都红。 ### S2 图片上传缺请求体上限 ✅ 补 `r.Body = http.MaxBytesReader(w, r.Body, imageMaxBytes+1<<20)`。 判据要认准:`ParseMultipartForm` 的 `maxMemory` 只限内存,超出**无上限**落临时文件; `mime/multipart` 内部另有 `maxMemory+10MB` 的松量,所以拿 9MB 去打 8MB 是能过的——必须大到越过上限。 变异后文案变 `图片超过 6MB`(走的是单张 6MB 上限那条),据此判红。 ### D3 rows.Err() ✅ `parse.go` 加 `drainRows(rows, what)`,13 处循环收尾全走它。 守卫 `internal/rond/parse_rows_test.go`:抓出所有 `for x.Next()` 的变量名,逐个要求 `x.Err()` 或 `drainRows(x,`; 再单独校验 `drainRows` 本体确实既查 `Err()` 又 `Close()`(防空壳)。 往返验收全等(823/323/8/30/791/557/2246,坐标 0.000000000、时间 0s)。 ### P4 导出下载 ✅ 导出补 `Content-Length` + 用 `http.NewResponseController(w).SetWriteDeadline(+10min)` **只放宽这一条路由**; `gzipWriter` 补 `Unwrap() http.ResponseWriter`(少了它 ResponseController 一律 `ErrNotSupported`)。 注意:测试里用 `httptest.ResponseRecorder` 会打日志「无法延长写超时」,那是预期的降级路径,不是 bug。 ### 验收 - 全量 `go test -count=1 -v ./...`:**209 PASS / 0 FAIL**;只带单次 env 时 5 SKIP(全是两段式导出的), 预置 `ROND_CLEAN_OUT`/`ROND_EXPORT` 产物后 0 SKIP。两段式单独跑也全绿。 - `gofmt -l` 干净、`go vet` 干净、无残留 MUTATION 标记。 ### ⚠️ 待查:`TestPlaceUpdateBatchActivity` 偶发失败(4 次全量里 1 次) 症状:`导出包里只有 245/246 条到访`(`place_activity_test.go`)。 **已排除本轮改动**: - 导出链路完全不经过 `rond.Parse`(只有 `backup_diff`/`backup_merge`/`handlers_admin` 上传用),D3 无关; - `f.export` 读的是 recorder 的 body,zip 若被截断 `rond.Open` 会直接失败(中央目录没了), 所以 P4 的 `WriteHeader`/`Content-Length`/`buf.WriteTo` 也无关; - 那一轮和上一轮的唯一差别是 3 个模板/JS 文件的 CRLF→LF,影响不到到访条数。 **已加诊断**:失败时打印「包里缺哪些 Z_PK / pks 自身有无重复 / 包内 ZVISIT 总行数」, 三种成因(少写一行 / 两行撞号 / 选错了数据集)一眼可分。 **怀疑方向(未证实)**:`datasetFor` 是**全局**取活跃数据集 (`WHERE is_active AND status='done' ORDER BY uploaded_at DESC LIMIT 1`,**没有 tiebreaker**), 而用例的 `pks` 来自 fixture 自己的 `dsID`;若有两份导入同时 `is_active` 且 `uploaded_at` 同秒, 导出可能取到另一份「几乎一样」的数据集 —— 差异正好是这种个位数级别。 下次复发时看新诊断输出即可定性。 ### 收尾 - 全量 `./internal/web/`(带两段式产物,CRLF 归一后重跑):**113 PASS / 0 FAIL / 0 SKIP**。 - 全量 `./...`:**214 PASS / 0 FAIL / 0 SKIP**。 --- ## 第四批:S6 / D5 / D1 / P5 + N1 定位(2026-09-21 下午) 顺序:先把上一批留下的 **D1 编译中断**修完(`activityFlags` 签名改 3 返回值、`fillup.go:384` 补错误处理、 `handlers_photos.go:236` 接 `logAggFailure`),再依次做 S6/D5/P5。 ### S6 `X-Forwarded-For` 只认最后一段 ✅ `clientIP` 改为「末段 + `netip.ParseAddr` 校验,不合法退回 `RemoteAddr`」。 回归 `client_ip_test.go`(12 组表驱动 + 伪造前缀不改身份);变异回「取首段」⇒ 红。 副作用写进注释:投票身份 = `hash(cookie, IP)`,取法变即身份变;单反代没伪造时首末段相同、无影响; 反代前再叠 CDN 时末段变 CDN 的 IP ⇒ 访客被叠成同一来源(cookie 仍各异,不会认错人)。 ### D5 收藏切换一条语句 ✅ `UPDATE ... RETURNING bookmarked`。静态守卫(不许再有 `SELECT bookmarked` / `err == nil && on`)+ 行为用例(连点两次,提示与库内实况必须一致;不存在的记录不许回成功提示)。 ### D1 聚合刷新不再吞错 ✅ 四个助手统一返回 error:事务内上抛、提交后走 `logAggFailure`(只记日志不改判请求)。 回归 `agg_error_test.go`:取消 context ⇒ 必错;裸调用静态扫描;签名必须以 `) error` 结尾。 变异两处(`return nil` 吞错 / 改回裸调用)分别打红。顺带全仓确认再无「语句形式丢弃返回值」的 DB 调用。 ### P5 `findSourceArchive` 先按大小筛再算摘要 ✅ 新增 `pickArchiveBySha(files, sha, size, hash)`;`sha=''` 的退化路径不筛。 `hash` 做成参数是为了让用例**数「算了几次摘要」**——只看返回哪个文件新旧写法没有区别。 变异删掉大小筛 ⇒ 红并报「实际算了 3 次(白读了 2 份整包)」。 ### ⚠️ 自查:静态守卫的 cwd 依赖(本轮自己踩出来的坑) `TestVisitBookmarkTogglesInOneStatement` 用 `os.ReadFile("handlers_edit.go")` 单跑绿、跟夹具混跑红 ——因为 `newMovementFixture` 会 `os.Chdir` 到仓库根,而 `-run` 选中的用例集不同,cwd 就不同。 `TestNoWeakRedirectPrefixCheck` 的 `filepath.Walk(".")` 同理,更阴:不报错,但扫的子树从 `internal/web` 悄悄变成整仓。 **修法**:新增 `internal/web/testpaths_test.go`(`moduleRoot(t)` / `pkgFile(t,name)`,从 `runtime.Caller(0)` 往上找 `go.mod`),三处守卫全部改用它;`Walk` 起点改成仓库根并把「自查文件数」 从「只在 found==0 时查」改成**无条件查**。验收跑了「单跑」+「跟夹具混跑」两遍,并在包外 (`internal/config/config.go`)注入一次探针确认整仓覆盖真的生效。 → 已同步到技能 `rond-fix-verify`。 ### ★ N1 定位:不是导出丢行,是测试把 SQL 参数绑错了位 症状「导出包里只有 245/246 条到访(缺 [],pks 内重复 [])」在第四轮全量里复现。 根因:断言 SQL 是 `SELECT count(*), count(*) FILTER (WHERE ZACTIVITY_=?) FROM ZVISIT WHERE Z_PK IN (?,…)`, `FILTER` 的 `?` 是**第一个**占位符,而 `args` 是 `[pks…, actSrc]` ⇒ FILTER 绑到 `pks[0]`, IN 列表变成 `pks[1:] ∪ {actSrc}`,于是条数偶尔少 1、`converted` 只有碰巧 `pks[0]==actSrc` 时才成立。 (「缺失列表为空」正是错位的指纹:缺的 `pks[0]` 被 `actSrc` 顶替了。) 修法:`actSrc` 放最前、`ph`/`args` 同循环成对生成 + `Count(q,"?") != len(args)` 自检。 验证:连跑 6 次全绿;变异 `export.go` 的 `actPK = sp` → `actPK = v.ActivityID.Int64` ⇒ 稳定红 (`导出包里只有 0/246 条到访的活动换成了 #1(站点内部 id 75881)`)。 **意味着这条断言以前是靠巧合绿的,等于没守住「导出写的是 rond 的 src_pk」。** 先前怀疑的 `datasetFor` 全局取数据集已排除(包内 ZVISIT 始终 824 行)。 ### 验收(本轮收尾) - 全量 `go test -count=1 -v ./...`:**211 PASS / 0 FAIL / 0 SKIP,exit=0**(12 包全 ok)。 - 两段式:`TestMovementCleanExport` → `TestExportRoundtrip`,823/323/8/30/791/557/2246 全等、 坐标 0.000000000、时间 0s;迷雾 8701 块一致。 - `gofmt -l` 干净、`go build`/`go vet` 干净、`export.go` 与 HEAD 无差异(变异已完全还原)。 - ⚠️ 本轮踩了一次环境变量:`ROND_DSN` 指了 `postgres` 维护库 ⇒ `TestChangeUsername` 失败 (关系 users 不存在)+ 6 个 stats skip + `TestVisitorHomeStore` skip。**必须指 `rond_fog_test`。** - 清理:删掉临时 `tmp-verify/`(含日志与中间包)与探针文件。 ### 遗留 - **D7 待用户定语义**:`Filter.Verdict` 只在 `Places` 生效,`Cities`/`ActivityCounts`/`ActivityStats` 连 `Filter` 都没接。直接加会让「点被藏了、轨迹还在」——需要先决定「verdict 过滤是否也管轨迹」。 - 其余:S8、S9、D4、D6、D8、F4–F10、F14、F16–F18、P3、P6–P8。 --- ## 第五批(S8 / S9 / D7)——按用户「全部,但先从安全入手」 ### S8 GPX 轨迹两个入口都按属主收口 - `/api/gpx`(公开无鉴权)原来全表吐;`adminGpxDelete` 只按 `id` 删。改成:取生效数据集的 `user_id`(`is_active AND status='done'` 最近一份)→ 取不到返空,`WHERE user_id=$1`; delete 用 `WHERE id=$1 AND user_id=$2` + **查 `RowsAffected()==0`**(只改 WHERE 不看行数 ⇒ 删他人轨道静默「成功」)。 - 守卫 `gpx_owner_test.go`(行为 + 静态)。**静态守卫差点假绿**:写成 `strings.Contains(body,"user_id")` 时,一句中文注释就能糊过去 ⇒ 改成**只扫反引号里的 SQL 文本** + 「一段 SQL 都没扫到就 Fatal」。 变异:删 `WHERE user_id=$2` ⇒ 红。 ### S9 下载文件名可逃出引号 - 四个 `Content-Disposition`(geojson/gpx/rondbackup/fwss)裸拼 `datasets.original_name` ⇒ 带 `"` 或换行即可注入头部。新增 `downloadDisposition`(`filename=` ASCII 兜底 + `filename*=UTF-8''…`)、 `rfc5987Escape`、`asciiSafe`、`hasAlnum`;四个出口全部改走它。 - 守卫 `download_name_test.go` 三条。**反过来抓出实现自身两处 bug**:正则漏 `-`(合法名判不符)、 `asciiSafe` 漏 `=`(`a;b=c` 能溜过)——都修了。变异:某调用点改回裸拼 ⇒ 红;`asciiSafe` 删 `=` ⇒ 红。 ### D7 verdict 筛选全域一致(用户定「全域一致」口径) - 原来 `Filter.Verdict` 只在 `Places` 生效,`Cities`/`ActivityCounts`/`ActivityStats` 不认。 把 verdict 条件**下沉到 `visitsWhere`**(`place_key` 坐标键 `EXISTS(place_votes…)`),所有走 `visitsWhere` 的统计自动一致;`hasVisitConds()` 同步加 `|| f.Verdict != 0`;`Places` 那段重复 特殊-case 删掉。`place_key` 表达式抽成 `placeKeyExpr(alias)`(原来散在三处)。 - 守卫 `stats/verdict_scope_test.go`:fixture **自建封闭数据集**(坐标 89.9/100.x、独立 user、 **故意不设 is_active**,避免并行测试按坐标键串味);`Verdict=99` ⇒ 14 口径全空;只投 p1/p2 ⇒ visits=3/dwell=110/places=2/cities=2/provinces=1/movements=2/distance=12.00/weather=1。 变异:去掉 visitsWhere 下推 ⇒ 红;从 hasVisitConds 删 verdict ⇒ 只 movements/weather 清空 ⇒ 红。 - 全量回归结果见下方「第五批验收」。 ### 第五批验收 - 全量 `go test -count=1 -v ./...`(四件套齐全、DSN 指 `rond_fog_test`):**218 PASS / 0 FAIL / 0 SKIP,exit=0** (第四批 211 + 本批 S8/S9/D7 共 7 条)。两段式 823/323/8/30/791/557/2246 全等、坐标 `0.000000000`、时间 `0s`、迷雾 8701 块一致。 - 已更新 `audit-2026-09-21.md`(新增「修复记录(第五批)」+ 重写「尚未处理」表,删掉 S8/S9/D7/P5;验收数字改 218)。 - 已更新 `.workbuddy/memory/MEMORY.md`(GPX 属主 / Content-Disposition / verdict 全域一致三条硬规矩)与技能 `rond-fix-verify`(静态守卫三反模式)。 --- ## 第六批(D4)——脏备份的伪时间与越界坐标 ### 实测复现(临时探针调 appleTime) Go 对超范围浮点转 int64 **是回绕不是饱和**:`NaN/±Inf/1e30/±1e30 → 2001-01-01`(恰好回绕到基准点, 看着完全正常)、`1e12 → 2123-10-28`、`-1e12 → 1878-03-08`。 要命的是 `ingest` 拿所有到达时间做 min/max 写 `datasets.first_visit_at/last_visit_at` ⇒ 一条脏行让 整条时间轴从 2001 年起算。**只卡结果没用**(回绕后可能又落回合法区间),必须先卡输入秒数再乘。 ### 改法 - `parse.appleTime`:`NaN/±Inf` 或秒数超出 `[1970,2100)` ⇒ nil(接上既有「装不下就 skip+留档」契约)。 - 新增 `parse.validCoord`:NaN/±Inf/越界 ⇒ 读 `ZLOCATION` 时归一成 `(0,0)`(与 `COALESCE(NULL,0)` 一致, 顺带让 `place_key`/`contentHash` 对脏行稳定)。 - 位移的脏时间**保留行、时间置空**(本就可空、导出走 `sql.NullTime`),不制造「无声消失的行程」。 ### 回归 + 变异 - `internal/rond/dirty_test.go`:纯函数 + 静态守卫 + **真的建一个 Core Data 版 sqlite 走一遍 `Parse`**。 - `internal/ingest/dirty_import_test.go`(DB,独立用户跑完即删):脏到访不进 visit_count、留档为 skipped、 `first_visit_at` 不被污染。 - 变异 5 次全红:去窗口检查 / 去 NaN 检查(两者互补) / 去地点归一化(静态+端到端双红) / ingest 改「补零时间」/ ingest 只跳过不留档。 - 已更新 `audit-2026-09-21.md`(新增「修复记录(第六批)」+ 尚未处理表移除 D4)。 ### ⚠️ 自查:新写的 ingest 用例把统计用例「串」成了假 skip(本轮自己踩出来的) 第六批全量首跑 `220 PASS / 0 FAIL / 4 SKIP`——4 条 stats 用例报「没有天气数据可测 / 找不到可用省份」。 根因两层: 1. `stats` 夹具是 `SELECT id FROM datasets WHERE is_active LIMIT 1`,**不带 user 过滤**;我的 ingest 用例 `Import` 时会把临时数据集的 `is_active` 切成 true ⇒ 运行期间别的包可能选中这份几乎空的数据集 ⇒ skip。 2. 更糟的是**清理没生效**:`defer d.Close()` 在 `t.Cleanup` 之前执行(defer 先跑),Cleanup 里 `d.Exec(DELETE FROM users …)` 拿到的连接已经关了,静默失败 ⇒ 库里留下 5 个 `__dirty_*` 用户、 5 个生效数据集,把所有 stats 用例永久污染。 **修法**:派生**专用库**(`ROND_DSN` 库名加 `_ingest` 后缀、`db.Open` 自动建)+ 把 `d.Close()` 也放进 `t.Cleanup` 并**先注册**(后进先出 ⇒ 删用户时才还连着)。清掉残留用户后重跑,共享库 `users=1/active=1`。 → 已同步到技能 `rond-fix-verify` 与 `MEMORY.md`。 ### 第六批验收 - 全量 `go test -count=1 -v ./...`:**224 PASS / 0 FAIL / 0 SKIP,exit=0**(12 包全 ok;第五批 218 + D4 新增 6)。 - 两段式导出数字与第五批完全一致(823/323/8/30/791/557/2246、坐标 0.000000000、时间 0s、迷雾 8701 块) —— D4 只拦脏值,干净备份路径零变化。 - 验收数字已写入 `audit-2026-09-21.md`「验收状态」。 --- ## 第七批(F4/F7)——异步回包把已关掉的图层又画回来 ### 症状与根因 勾选图层 → 请求发出 → 立刻取消 → 回包 → **图层照画**(开关显示「关」,地图上却有东西)。 四个出口全中:`setGpx`/`setHeat`/`setCityAgg`(`rond-map.js`)+ 模板里的行程轨迹 `refreshTrack`(`map.html`)。 共同写法是取消时 `xxxLayer = null` 就 return,**在途那次请求没人作废**。 ### 改法 每次调用发递增序号;关键是**序号必须在 `if (!on) return;` 之前递增**(否则取消这条路径不改序号, 在途请求照样合法),回包首行 `if (seq !== xxxSeq) return;`。序号顺带解决「连点筛选/切底图」的旧回包盖新回包。 `refreshTrack` 的 `.then` 与 `.catch` 两个分支都要挡。 ### 验证 - 守卫 `map_async_cancel_test.go`:按大括号配平取函数体,断言**两个异步分支的相对顺序** (递增早于早退、作废检查早于画)。变异 3 次全红。 - **CDP A/B(比静态守卫硬)**:`--inject` 给 `/api/track|gpx|cities` 回包各加 900ms 延迟撑开竞态窗口, 探针「勾选 → 120ms → 取消 → 2.2s」后数 `path.leaflet-interactive`: 有守卫 `2→2 delta=0`;去掉守卫 `2→758 delta=756`(**开关关着画了 756 条线,且 errs=0**)。 - 临时的 inject/探针脚本写在 `storage/_*`,跑完已删。 - → CDP 手法(延迟注入 + 数 DOM 做 A/B)已同步到技能 `rond-web-verify`。 ## 第八批(D8 / P8) ### D8 上传失败后把半成品留在 uploads 里 `adminUpload` 先落盘再解析(`rond.Open`/`Parse`/`ingest.Import` 任一失败都直接 return), 原来只有「sha 完全相同」那条早退分支才 `os.Remove` ⇒ 坏包反复上传堆垃圾,而 `findSourceArchive` 正要遍历这个目录。改法:落盘后挂 `keepStored` + `defer`,只有「新导入成功」那一支置 true (那份上传件是导出的基线源);顺带修掉 `st.Identical` 分支的同类泄漏。 回归 `upload_cleanup_test.go`(DB):投垃圾 `.rondbackup`,断言 303 带 err + 目录无新增; **带前提守卫**(Location 必须含「解析备份失败」,否则落在更早分支时断言会空过)。 变异:清理改空操作 ⇒ 红并报出泄漏文件名。 ### P8 recover 在响应已写出时追加错误体 `http.Error` 无条件调用 ⇒ handler 写了半页才 panic 时会二次 `WriteHeader` (`superfluous response.WriteHeader`)并把错误体接在半个页面后面。 改法:`recover` 用 `headerSent` 包装 ResponseWriter(`WriteHeader`/`Write` 任一即置 sent), 已写出就只记日志。包装层必须实现 **`Unwrap()`**(又踩到 P4 给 gzipWriter 补过的同一个坑: 少了它 `NewResponseController` 拿不到 `SetWriteDeadline`,导出的 10 分钟写死线静默失效)。 回归 `server_error_test.go` 三条(半页+panic / 未写+panic / 用现成 `deadlineRecorder` 验 Unwrap)。 变异:还原无条件 http.Error ⇒ 红;删 Unwrap ⇒ 红(feature not supported)。 ### 第八批验收 - 全量 `go test -count=1 -v ./...`:**229 PASS / 0 FAIL / 0 SKIP,exit=0**(12 包全 ok;第八批新增 4 条)。 - 两段式导出与第六批逐项相同(823/323/8/30/791/557/2246、坐标 0.000000000、时间 0s、迷雾 8701 块) —— 七、八两批都没碰数据路径。 - ⚠️ 变异验证会在 `storage/uploads-test/` 留一个泄漏文件(清理被改成空操作那轮), 已清掉;正常测试轮不留(用例自带断言)。 ## 第九批(D6)——轨迹图层与里程统计的区域判定不一致 原报告写「可见性口径不一致」,查清后具体是**区域判定的连接方式**不同(与迷雾遮罩无关): `apiTrack` 是 `regionSQL("pf") + regionSQL("pt")` ⇒ 白名单下**两端都要命中**; `movementsWhere` 是 `EXISTS(... pmv.id IN (from,to) ...)` ⇒ **任一端命中**。 黑名单下两种写法恰好等价,白名单下才分叉:一端在北京、一端在上海的位移,白名单只选北京时 图层画不出来(缺上海那端)、总里程却算进去。图层那侧「两端都要在」才是安全侧 (白名单的用意就是不暴露区域外坐标)。 **改法**:`movementsWhere` 的区域条件改成对两端各判一次、两端 AND(与 regionSQL 同款), 并把 `IN` 换成 `COALESCE(col,'') = $n` —— `district` 常 NULL,`NULL IN (...)` 得 NULL, 黑名单的 `NOT (...)` 跟着变 NULL ⇒ **整行被误删**(regionSQL 早用 COALESCE 绕过,这是第三点分叉)。 **回归**:`internal/web/region_consistency_test.go` 是**对账测试**(不是各写一遍 SQL 互比): 自建封闭数据集(3 地点 / 2 段位移:一端在外 10km、两端在内 20km),一边跑**真 regionSQL** 拼的 图层谓词、一边跑**真 `stats.Overview`**,断言相等。变异:还原成「任一端命中」⇒ 红, 报「图层 20.00 km,统计 30.00 km」。 → 已更新 `audit-2026-09-21.md` 与 `MEMORY.md`(区域判定必须两端 AND + COALESCE)。 ### 第九批验收 - 全量 `go test -count=1 -v ./...`:**230 PASS / 0 FAIL / 0 SKIP,exit=0**(12 包全 ok;第九批新增 1 条对账用例)。 - 两段式导出 823/323/8/30/791/557/2246 全等、坐标 0.000000000、时间 0s;迷雾 8701 块逐字节一致(未碰数据路径)。