Files
tamakyi 3f1b737a78 记忆:把 .workbuddy/memory 纳入版本控制
用户要求这份记忆跟着仓库走,而不是只留在本机。

.gitignore 里挡路的是 `.workbuddy/`。改成反忽略六行,并且**只放行 .md**:
别用 `!.workbuddy/memory/**`——它会把 memory 下任何文件都放行,本项目真发生过
`web.test.exe` 混进暂存。复核用 `git check-ignore -q --no-index` 逐条打靶:
memory/*.png、memory/sub/x.md、memory/MEMORY.md.bak、plans/a.md 全 IGNORED,
17 个 .md 全 included;`git add -An .workbuddy/` 恰好 16 个文件。
其余 .workbuddy 内容(计划、本地设置、临时产物)继续忽略。记忆里没有真凭据
(唯一命中是 2026-09-14.md 里的 `password=rond-admin`,那是项目默认口令)。

顺带做两件整理:

1. 取证了注入上限。以前按「字节数」判断 MEMORY.md 是否超限是错的:
   WorkBuddy 的 `getWorkingMemoryContent()` 用 `MAX_MEMORY_CHARS = 1e4` 比 JS
   `content.length`,即**字符数**(中文 1 字算 1);超了 substring 截断、尾部静默丢失。
   本 details 与每日日志都不参与注入,只有 MEMORY.md 受约束。
   现已从 6604 字符收到上限的 66%,并把这条上限写进索引抬头。
2. 把「备份合并」「导出/恢复三条铁律」两段长文迁进 MEMORY-details.md 的卅一/卅二节,
   索引只留关键词指针。行尾统一为 LF(仓库 .gitattributes 是 `* text=auto eol=lf`)。
2026-09-30 18:31:08 +08:00

374 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 块逐字节一致(未碰数据路径)。