Files
rond-with-you/.workbuddy/memory/2026-09-21.md
T
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

29 KiB
Raw Blame History

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,守卫还自查扫到的文件数防假绿)。

不能无条件打:本机 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 块逐字节一致(未碰数据路径)。