From d2e4392a881c8cd3eb14fadfeab5406f0cb38f69 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Thu, 24 Sep 2026 20:38:18 +0800 Subject: [PATCH] =?UTF-8?q?=E9=AA=8C=E6=94=B6=E8=84=9A=E6=9C=AC=E3=80=81?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=95=B4=E7=90=86=E4=B8=8E=E6=8E=92=E6=9F=A5?= =?UTF-8?q?=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/verify.sh:把「带齐六个环境变量、且必须 0 skip」这条纪律固化下来 * DSN 自己从 conf/app-test.ini 取,不把口令写进脚本 * 库名不含 test 就拒跑(指到维护库会多出「users 不存在」+ 一堆假 skip) * 数 SKIP,非 0 直接失败 —— 不带环境变量时 go test 依然回 ok,最容易被当成通过 * 支持 --fast / --race / --cover / --pkg / --run - tools/:删掉 6 个一次性探针(git 历史里可翻)和误入库的 __pycache__, 只留在用的 4 个工具并补 README;.gitignore 补 __pycache__ / *.pyc - audit-2026-09-21.md:全量排查报告(含 11 批修复记录与收尾审查) --- .gitignore | 2 + audit-2026-09-21.md | 1013 +++++++++++++++++ scripts/verify.sh | 137 +++ tools/README.md | 20 + .../photo_coord_audit.cpython-313.pyc | Bin 26042 -> 0 bytes tools/colprobe/main.go | 89 -- tools/diffprobe/main.go | 438 ------- tools/exportprobe/main.go | 40 - tools/fogprobe/main.go | 133 --- tools/idprobe/main.go | 171 --- tools/sqlprobe/main.go | 57 - 11 files changed, 1172 insertions(+), 928 deletions(-) create mode 100644 audit-2026-09-21.md create mode 100644 scripts/verify.sh create mode 100644 tools/README.md delete mode 100644 tools/__pycache__/photo_coord_audit.cpython-313.pyc delete mode 100644 tools/colprobe/main.go delete mode 100644 tools/diffprobe/main.go delete mode 100644 tools/exportprobe/main.go delete mode 100644 tools/fogprobe/main.go delete mode 100644 tools/idprobe/main.go delete mode 100644 tools/sqlprobe/main.go diff --git a/.gitignore b/.gitignore index c4cd51f..700b337 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,5 @@ # 开发期的临时探针/抓取产物(不属于运行时) /tmpfog/ +__pycache__/ +*.pyc diff --git a/audit-2026-09-21.md b/audit-2026-09-21.md new file mode 100644 index 0000000..ef9107b --- /dev/null +++ b/audit-2026-09-21.md @@ -0,0 +1,1013 @@ +# 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)——本轮就先踩了一次,误以为是回归。 + + + diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100644 index 0000000..69e1271 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# +# 一键验收:把「必须带六个环境变量、且必须 0 skip」这条纪律固化下来。 +# +# ./scripts/verify.sh # 全量验收(默认,约 8 分钟) +# ./scripts/verify.sh --fast # 只做 gofmt / vet / build(约 20 秒) +# ./scripts/verify.sh --race # 全量 + -race(更慢,需 mingw 在 PATH) +# ./scripts/verify.sh --cover # 全量 + 覆盖率 +# ./scripts/verify.sh --pkg ./internal/web/ --run 'TestMapPreview' # 定点跑 +# +# 退出码:0 = 全绿且 0 skip;非 0 = 有失败或有跳过。 + +set -u -o pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +ABS="$ROOT" +command -v pwd >/dev/null && ABS="$(pwd -W 2>/dev/null || pwd)" + +CONF="${ROND_TEST_CONF:-conf/app-test.ini}" +ROND_SRC="${ROND_SRC:-$ABS/data/20260914_151649.rondbackup}" +FOG_SRC="${FOG_SRC:-$ABS/data/Snapshot-20260914T154839+0800.fwss}" +OUT="$ABS/storage/.verify" +CLEAN_OUT="$OUT/acceptance-clean.rondbackup" +LOG="$OUT/verify.log" + +FAST=0; RACE=0; COVER=0; PKGS="./..."; RUN="" + +while [ $# -gt 0 ]; do + case "$1" in + --fast) FAST=1 ;; + --race) RACE=1 ;; + --cover) COVER=1 ;; + --pkg) PKGS="${2:?--pkg 后面要给包路径}"; shift ;; + --run) RUN="${2:?--run 后面要给正则}"; shift ;; + *) echo "未知参数:$1" >&2; exit 2 ;; + esac + shift +done + +fail() { echo "✗ $*" >&2; exit 1; } +ok() { echo "✓ $*"; } + +echo "== 仓库:$ROOT" + +if [ "$FAST" = 1 ]; then + unformatted="$(gofmt -l internal/ main.go 2>&1)" + [ -n "$unformatted" ] && fail "gofmt 不干净(注意 CRLF 会假警):$(echo "$unformatted" | tr '\n' ' ')" + ok "gofmt" + go vet ./... || fail "go vet" + ok "go vet" + go build ./... || fail "go build" + ok "go build" + exit 0 +fi + +# --- 六个环境变量 --------------------------------------------------------------- +[ -f "$CONF" ] || fail "找不到 $CONF" +[ -f "$ROND_SRC" ] || fail "找不到备份包 ROND_SRC=$ROND_SRC" +[ -f "$FOG_SRC" ] || fail "找不到迷雾快照 FOG_SRC=$FOG_SRC" + +if [ -n "${ROND_DSN:-}" ]; then + DSN="$ROND_DSN" +else + DSN="$(grep -m1 -iE '^[[:space:]]*dsn[[:space:]]*=' "$CONF" | sed 's/^[^=]*=[[:space:]]*//' | tr -d '\r\n')" +fi +[ -n "$DSN" ] || fail "没能从 $CONF 取到 dsn(也不在环境里),别把口令写进脚本" + +# 指到维护库 postgres 会多出「users 不存在」+ 一堆 stats 假 skip,直接拦住 +DBNAME="$(printf '%s' "$DSN" | sed -E 's#^[^/]*//[^/]*/([^/?]+).*#\1#')" +case "$DBNAME" in + test|*test*) : ;; + *) fail "ROND_DSN 指向的库名是 '$DBNAME',不含 test —— 验收必须用应用测试库(rond_fog_test)" ;; +esac + +export ROND_TEST_CONF="$CONF" ROND_SRC FOG_SRC ROND_DSN="$DSN" +export ROND_CLEAN_OUT="$CLEAN_OUT" ROND_EXPORT="$CLEAN_OUT" + +mkdir -p "$OUT" +echo "== 库:$DBNAME 备份:$(basename "$ROND_SRC") / $(basename "$FOG_SRC")" + +GOFLAGS_EXTRA="" +[ "$RACE" = 1 ] && GOFLAGS_EXTRA="$GOFLAGS_EXTRA -race" +[ "$COVER" = 1 ] && GOFLAGS_EXTRA="$GOFLAGS_EXTRA -cover" + +if [ "$RACE" = 1 ] && command -v gcc >/dev/null 2>&1; then :; else + for d in "$HOME/scoop/apps/mingw/current/bin"; do + [ -d "$d" ] && export PATH="$d:$PATH" + done +fi +if [ "$RACE" = 1 ] && ! command -v gcc >/dev/null 2>&1; then + fail "-race 需要 C 编译器:scoop install mingw,或把 mingw 的 bin 加进 PATH" +fi + +# --- 第一段:先产出一份「纯净往返包」,第二段要读它 ------------------------------ +if [ -z "$RUN" ] && [ "$PKGS" = "./..." ]; then + echo "== 第一段:导出纯净往返包" + if ! go test -count=1 -run '^TestMovementCleanExport$' -v ./internal/web/ 2>&1 | tee "$OUT/stage1.log" | grep -E '^(--- (PASS|FAIL|SKIP)|ok|FAIL)'; then + fail "第一段导出失败,见 $OUT/stage1.log" + fi + grep -q '^--- PASS' "$OUT/stage1.log" || fail "TestMovementCleanExport 没跑起来(被 skip 了?)" + ok "纯净往返包已生成:$(basename "$CLEAN_OUT")" +fi + +# --- 第二段:全量(或定点)------------------------------------------------------ +RUNARG="" +[ -n "$RUN" ] && RUNARG="-run $RUN" +echo "== 第二段:go test -count=1 -v $GOFLAGS_EXTRA $RUNARG $PKGS" +# shellcheck disable=SC2086 +go test -count=1 -v $GOFLAGS_EXTRA -timeout 2400s $RUNARG $PKGS >"$LOG" 2>&1 +RC=$? + +PASS=$(grep -c '^--- PASS' "$LOG" || true) +FAILN=$(grep -c '^--- FAIL' "$LOG" || true) +SKIP=$(grep -c '^--- SKIP' "$LOG" || true) +BADPKG=$(grep -c '^FAIL' "$LOG" || true) + +echo "== 结果:PASS=$PASS FAIL=$FAILN SKIP=$SKIP 非ok包=$BADPKG" +echo "== 日志:$LOG" + +if [ "$SKIP" != 0 ]; then + echo "-- 以下用例被跳过(=没跑。门控测试缺环境变量时 go test 依然回 ok,别当通过看)--" + grep -n -B1 '^--- SKIP' "$LOG" | head -40 +fi +if [ "$FAILN" != 0 ] || [ "$BADPKG" != 0 ]; then + echo "-- 失败明细(前 40 行)--" + grep -n -B2 '^--- FAIL' "$LOG" | head -40 +fi + +if [ "$RC" != 0 ] || [ "$FAILN" != 0 ] || [ "$BADPKG" != 0 ]; then + fail "有失败" +fi +if [ "$SKIP" != 0 ]; then + fail "有 $SKIP 个用例被跳过 ⇒ 这次不算验收过" +fi +ok "全绿且 0 skip(PASS=$PASS)" diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..063adaf --- /dev/null +++ b/tools/README.md @@ -0,0 +1,20 @@ +# tools + +留在这里的都是**还在用**的工具;一次性探针用完就删(历史里能翻到)。 + +| 工具 | 用途 | +|---|---| +| `verify_export_structure.py` | 结构级对比:导出的 `.rondbackup` / `.fwss` 与真机原版比表、列、行数、悬空引用。导出改动后必跑。 | +| `verify_rondbackup.py` | 逐字段对比两个 `.rondbackup`(含 `Z_METADATA` 等字节哈希),比上面那个更细。 | +| `photo_coord_audit.py` | 照片导入的坐标体检与批量换算(GCJ-02 / WGS-84)。 | +| `audit_db.sql` | 库内一致性审计:`psql "" -f tools/audit_db.sql`。 | + +## 验收 + +`../scripts/verify.sh`(封了六个环境变量,会数 SKIP,拒绝假绿): + +```bash +./scripts/verify.sh # 全量,0 skip 才算过 +./scripts/verify.sh --fast # gofmt + vet + build +./scripts/verify.sh --race --pkg ./internal/web/ +``` diff --git a/tools/__pycache__/photo_coord_audit.cpython-313.pyc b/tools/__pycache__/photo_coord_audit.cpython-313.pyc deleted file mode 100644 index d38a98f42d464f062d4ce4b414e183a733f23241..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26042 zcmdUXdt6l4_3t_Je)E3&7(tX#0Z}lbf}-ME)H#k~K*`7qFcD_(4C5W}1y@JNFn_ioV?b&_HzmSSi| zw2@|M?k!?P+*`~_xVMy*a&H+c(h&Jlix-xJj26E$86CnbCI;b5CKh2f6NhjX6OV8*<=i%?VM?6Pwz4PM8 z%dNNio*!wyJa)8W!d;kREK{c-!PE6?l8E7z~l4?llyxb+$-=xmQ_?WzsA^B3rcubdn{^Rruh z$A+Ki&@U{S|LsMK=Qp7Qlrwhp*^xK8N6xnEYgwzg#cHWBw;=Pa%jXb%`?cQTcTVyh zG;->vkv)RSjU2y7s@2|l>*^1N+s=i^mtIz@H0>Iwbz%F z=vjx$Zpk4vo~K6#1fSeeJt_8`SJ7R-%@5P9cVCN&qQ3UgobhM~9-tV0`;8EaksrT2 z+Rf+3fDOO)y^)_B*Dol{Ur?wYKJm`2z8|BQ`TF6G=WhM%IEGNA;(LQ{0@^&*+B*E~ zcR)BG4QI2y6cx8)$j@CFz49(Pbgav*FVT;_(lz?z<&p374qpY_b3lonxc$^SW8d}Y zb8~I>+D4bfn(MM3u(Gx~o7JKpX$P6~a5PEi?ah;{{mk&X=eY*u}y%Vv4J z#j!6l*th7KTn?jCzj_Ie+llOZ9ns?J9L@%_9Vrf1i@wfwD6iIefU97Z9^HBSweur= zPlHy6-OmpnBW*M{H#Z(ujgdK8Q7en!5tzLc;VJjr2u@N}LGDCRm2_oRWqDB$)-E&2 z5~v+=rzoFtiV~=GQz^=Ekf8};fu{_XNQ${?y>337utAgQT0+X6DDqjsOQB_JdZ~H#=`+g%i(5gK-$b=d;-$CMsvM0Ahp@iX#v_65FIFVDuFfB z?}3t4ORBfDG+5a+wnnS7dabRc!L_%VP*UzeeCHl^xT+@~@_>%it)`i^)>+w_1BEPE zLuTXOxdK5eHKd7kCwA`WDe`L;x0ZjDl1yxnsR^QxV5!oQZ~6WG#Ppb9+#)!j=+)JdwEf?PlmnSd2DBCIwN zM$AgS!wq=YgkhSs<7K$QxIBF(@c#^8840Ods${-I&oYuy=fS9%J z4~XiT1Cm;Y#TpPdIqL)R2CLa(Wt{<;-Fnc)HaZEuAZf6%_zuLf!SZU5-DL%}O^K*P z-o(_0kjF{pMJqKJlW-3o6q}5o!XOJMYHIA}CTmSiKvh%I^`tz3U4BP4Q}T^i;9CW#<7V7~u^qU^G-pXtWPks#RdRALN^ zF_%qpBbjm&9!6Bf$&4ggke$E-LOj8oM0iHjoMJ{|qI1XR6YY(k6kf{6!X-?jsx(4X zj4{Gj_+9B_!-;kOTjupNHNvTlm6fx*?CZg?x z_s!(^V^y{Q&!|k)GFnG5YLk?_7ly{gO3$F?Q6UZr>aRoo>ENS!YV*ewRU)2F)d41A z+)@Rs;kS`Yx(Sb*GB3l3D@{t{lnF`r8PR^_*S25zp!QEEG^Z?6^6loo>*y*%^4x=Z zU@t-HVV9a@jDn1~NwNgvY|cVG^gI++o=o8@{DyR`6!PSP>V;7uJ;ax^mfmcqB7eE4 zU`icb7wkQQrouo6B;bVRBPx^A%I22UTkTkDR&mSt5iMCJYMfT5lW@@^npF;a3(|A9 zV+lGkgRHx@TFhIAJ^xVd!Gi~L2_ww~pRw8r53&Hayv5dJ#X3GlxK}-2|GzAEY4LUY z(%U1iKRJB<_1ka#K>sJqQ3|})kPwivW_!IgAOjvq2m!gknB>MFhfmKvV31w^K@ zfV{@hY_(h2fP}oUj)IrCoM@JO-<>bI>x8Y z5E4qgb1QCYw%idZlyipSQcq1eF{QKCJ;fKduy_ApO6sXYCk~xY^q4Q$x@|8{@161X ztk-9~nd41a@`ad+FZ9PP{5+1*rT zRq4{77c24oUbU6w>!q`Q^0q{bCy5zo4}w z)XiQami~)aitkz?dkGH_1PKA&yMh3Mq7Y9RBcXz^n0j;s1X+zIJegqg!i3BPZc~T} z+C?Ujz|zcMf>j}L)u@<&1V4dCWK{ocJR=gY3-6f}Bc4eaBo#-dk(IV2GuxS2wWoCV zoaG11jV}DosoImddyXNKC42q=h2>hrVvaejX12B=AhSAa&5$bP4J}RFq*J);!6<1x zwmHoZQl)zvA)f{$1b={Ta+3Z6Y4bEK#m=J9!frv_cKi#%p+eQ6Jf`%Y;I^Wwkc?o$`Yo)RcCkquZH*V!Ghxs; zX&;lH5!oeGp&>JdR{C%SKqg|7C;}$5Du-bNjR*L@R4BoW4vfeZ9kawFjkdNkQlY(} zc42Q5!b}J*wBj4F&P;qjaI&a)Lz*1{{}!6-0mv}?V-gD~_}WBry)->;9%aP?s)-p)?*c<5iIgX{* zTJK;F^P3nxsr1N^6Y%@Cu<|788nM84{3L&!X-Wy8}Uho;OA~Cst(ER&US-dcLe9IFV(ZBC1=-qymdAvxD_IT>~%`LUh^8%T`U28l$z#khVgK_G+F zVYOSZIaW5C>#a3}V+F*JFxX8TF#0PXMg;?6v=?74ti#O#QIli*wwPyInr1WWwAOGt zLMOWq2zKG$IgMMA2UVKZRbOZ6I!Ln{-H*;4bf6?AOi2VjL6S@mTv~9p7sI z)<>GeL0xQ{ct{u9VLN8?rk3_g-)Dc>a=pc`+teohM6DZ&PdsIK!Qh>;qCe}GhU`$E^1|15;eIBROs@mxLiIgs>V^8~@&T_vt zvrY6#@lv<(RON}vPVH*W`@l`^6wkbBMd!Hq%~7;&Yv zP{b{kA)f3)YI8m2rVKd_A_sOeVSd3#DtGWXg!yBot4J-l2QxU@o-AUgB;+FUH?%*h zkoE_qIDW*)$ueP*ECnfWJRcIPNd*lL^dFptM`>4?lmgGN0BXx~1kXC`iI5_R%5PLW zR-t6Sg#Z&X;l<%J-!F^55oL4 zJ3h1m=Nh?a2U*R6V+>l@jB$>rJv3zhK87WP33ArD@WhG3MKmOOIL?q zdv@gHTetdtG<>!fYSH1gQ&4OZ<*WOZ+uwg>_}MGN=gy5?Ju&Ql4jRP#+wZxKFm=%A#9-jrjVV>eLj zk)&*AglcIy;YcOf7FP+P0w$5u+<^k5Ph}^5A~z3z6xA=bOIE>qi>N0<9Nt^VeDKqdsUGCoRIn-U%C-G%3I~CVe-X`_NmV6SE zIH-yHI4-%ZY)}*1v95icdz<^viK^deW_*sVQp%?aU5B<^>$cuhO!-6|KZJeRhV~5u znkhaFir?ln?YddD$6I6dSJip9*ZZc{-_$gW1G=f1HmK0J>2_tCxjt8haNM?ra6pbHzmML7 z28?!LJ~n3&t)m@UM}$(7gltwANiO6}6VCp~Tackl(#g}B4**)0iy@5I3Xvs$!b5Zu zc0~kC^zD*D;Bd#s)bv*jm@^OPsmO!!q-1`Km$)H03a4L=43u=~e)6N=DK>;TS0o z84)A-`l@C|EXd)_mN1w?No3c{$RaRB9YhQgSI-DI1*$pWAvh!Pe*?UVe*`Z-?4&eC z$qj}Ug!qlxVT#<8ev=lcB3c}^XD~szIz+&I1H6;AKplZ+vhp+&i256p$7mmnHv(c3 zjM!RX(nXH&WbMFoh^Zix0ne`fQT~Hk5sXo+s6X9Bo?k5=U%x?`Ap+PVB@&(dqPT>pJMu z%<4(#*?%#mx8P!iPgB_I?A!MG!Tz|{AMGv<)!VXDqnz1@0rnMJ#V>C-(BD3@F(T@ba}mr*Cg*Mt|A9vaG{`Oz)yuR~tjlXcSFL$#~x7oYR=+hc`gg(U%9+mYa%c+JJ8anrT7IYnW zRdPvwQSO_b?@ylZ)6MT)crEeW)GMj|Gv2rSvi^Gg^|{`yM&C-KznJkYWPCctyR*`# zt@Kt^`?S?Q1!-$cY@34LghVn>IXaf(eS<;XhnbHkaKJE+69;)*Bv0hNBVi)wG!j2> zk>~H=dXfAy>Re-jJU61mNoGdmoM0Y|SN0EcJ#NutltpmqqX3{LIlOA{sFER$?Yd=hx+fwJ6Sux^)x}Q*UzqCp8+|tM@i5|f_4%R zzQ!IS!Eprj_@}7;y(`#f@n)YR0bxMwO9&?NtNLjCs{SN_TomM2aqhDA^_}8QbC=ww zneNHzp}S}Ily%SbX>xnTz2?{DeKTHH`!q}YYTsY|%k@8B|3R(S$o!_xzuM$0HTg8A z37qBsFGf{#t?=E&R~Gk|-4I=0^TGCi-TAAXzSZ0POHICoCZEp4vrq5tJwELopJGoW zpW=sM#lyihrnPLv*jr>2=8%94n?c5Qkna~s@B#_k2yhxkjFq6saySj@u)-}sn*$=N zl|4lOPZL0qr4j#FlGsMJf{SoAMc-n{gG&dvdlMY`TH|}Is5u-->mh%)D6D)4L8dCnu;*Axx4PwmP?0zbf{1C%HzIS zORrhpt-oCFn^o>hUE_;ibA$G4Hrx@@noaZ_ik5Do`K2Oq??I+2w>SD9plc!~L!XZ* z{)v9<6BckE2pQ5WH(MVBTdC*vT&M+k9td3!3Eg}&0zR^JJTh{CBxgjC0u_FNIY;D* z&ay)12oHn|oeU6BIRZL*Z4}XJ6WS&yu~17U;EA%7VEGNU4`<`icUz(T;J8IoOmKsN zN-F9LTi;$G`eWY;j#=7ngA#)C4=NY4#b&lU`8oJKloXJdA?tC|(V$|_kr;Y!fUXVD zmH=&p3|N5w1(?y^hNc9#tZXlm$@FtxM1ZNMh{Md##&uL5 ztL~g}yymRsT;u7+o`N%uHc=?LE8DNtLlT?!{dtfNUH;4>BnmUlZ9b9ghk~HYry;OLr_=V!L z+t2Mfz00%zOtn8@j!zAN&!;Z%N(%UC6eLwtoMg&1MPq9X;3e?ZtF66Nw>00nh%6;!nR6W(+K zhF6>6wV**u&2@{C|B&NY2-jC zBMdKmnN$MN8exby@eyijSDVyEC{rgsFa-*Zk6mNZ7-vjOM|(mCPLrgER29wt-J})T zY=&%Y(h0vT#w+3@%7_h{CQZ^if5TdgvxSz2-@+&v4dFQ9i_r*e6MEAO z>NUm8AuY@npe89@E94d4e|PCRA?LW#V^Ml6jv?j><%i!UWfkdu?lHwMF#-ig#KwkW zL6y;HE>LPlcTk?CjrAIt7S4p;v$t>>h0i%?JX! zc_~cR1VgJt*FHcK-Tl!k{KbLMGq1tEotVJ4x599JxbKJhwObi|$j+S{TAVp^yA8(w zFX4s(&LH%Q^taw22Hv<{5V|-p&XWJq<*|3%!9xnNZ-oJtbbS-F@GkIFinnOjZ-s@e z;~)+onr+Q`n;q6tjg9(dF0#TPJtE0_w7LF>6rq*88mzWj{#A#hW_!IYyA5j`r#m5? z${&Wno{qA`f>Ei30KJowYi(l4v9<*?qRSC&kOU-kutbg!Wu-0VeO9|6H6Y%) zeiQo!$*W%9M2wz!D=n6b;cE!HFI!z85tkrvG7XDl@>4MX62Syoa?sqk4+j^>Y=y-a z8Vb8G1x_@a#Mqe}Ns#bS2iEh21g*T@+G0Hfl;oqvRtHHNALNw7!JQ$u;Pk_V6K6J{ zLPxPJ92#~nva;VLK`{bD9I-WHom|z~D+H~$xy2cfJNMa;)e=y!4o6E3KwyZ5FSeC{ zz+?vY*s$2Ov)9RQkri5``hchrf_bspRV^b z3{1;!uV`ECHVq|D=`@{ca*GC2(ujrFi$?}h=J-U z<0l^XtoEnmx+Ql+v2im7)25wE?@C8hnqeTVz?W9wPh03-IhdM$PSvII6!}vP?y|wD z)6SW?OrC->m7e_7IfvO{e^$RQ*u;bdShW&@DYP@71Ip zws*!w*DF){L_Wh3U;2`3rv3%}=IcdQt8c9Km2CB;Z*{Nw`!l4ZcNUy0>MHWYomu?i zxAP;*0>q@3Z`iT z3JrlccDwSXq!qL>XT<*$`A!oN9kz!FB?OU5iGLSXppKYYRubJlF$O6sNknGr=elcYJ&*o zFLoH|*en$=-Zviv=c#>^#NvR|?l@?#Fi3bF^Dlr7sCE!TKAs`|fMl2A4hXr)fJ0Yu zRmU0T7}$+8a7OwZui!a_g1f$8t2@m88#4Rx?|c~n7==hIoeo=)vq_yUkE!c%f7-kr zvp-?}TiMs7eb)Z+%Z>iU8*gm$FWAz?+>=o;N!ahJwQbh+;!g2z)ft$eMcv9C=C}I! zgD_3WbQgc9new?5a6#Z9b-a54Gzi6=dp${AHorQjhw0hyTlJy|;9yFcjKAEEqcq8c zvZsF(GmTL1^uKca@5%hKrPTGMs+9`y&*e12TC(;CPiQYcpQFEs-C>Zz1VHjO60(}` zFt`uIi1I>$z&M)V?bF3331^><12lmuxDCe+*#Aoz31^=I+6$jPg5IRWo+T_?gzp0w z$??L3hwRnA-s()i#98i#HaTH5VZlBm+KsD`NikmRwbCX9cEg}dj-YT43AZ*P7Um-w z?!qA~o>_}?IopJiT!{$y>x8_4gg$7+z}k_(K7_!aWQn*4>Lj*R0cFS>oHIXQiKKu9 z*a>Shtj=bOlXa4uzu0Et3YNJ5lQxt!n&L0IX;#z{lY}y&l$gvN;HOlC4JldN9;E&%$JbS{ zdP>Ir9nbb7V#mIcfXK1Wpx`Dg`v+3)9}&P1h`YnVPugFQ7_3YCT@Kg*Nu9X&(E?Ri zc(&R|J|hVj0xBUFLHJoq?qBc%FDy)Jozc`{Dks!pyu}ZD7vRv-P)->tjcKcn>sZ#l zY(PE3r=BqwpWt41VlGt0$=!$h%>Am%O@lE>o#OV#J!^WH?oESSPB ze@6d?>$#uHC0Vk2ltd%{LP05XPaJ5gf9jv^C@HBDauN)OrY-5)?weNDzsS4i5nr0w zCrNIFqIysj*D<$!uKQ6>reBrax{9!ahyC*DgE5J2n`f3UIma85<5lDY*#x3rsyo$q zqH!R3jxTvm&%#T^7mIs$_~$O`Gy3MP@F$mfW6Hb=sMuoqTsOp5AAc#CT*MfN%l5@( zduqw$i(aXJ)}mh4H*1MMZmCyo@=8qJZ6^MMXCCO2&=ZRC>Z6}U{^6}C;&B#wpcQ04Rm2;8xup= z$Jf9@MZhE$izdaD1Wy4eOzPLvC=C$lUkj1J$eAf$+p4KyJ4a?3Gd)z}C@XhpglMP) z<3r;d`iQEQ)CQ&m990Cq~2vUFrthovjF#j+!gnY1<%@ATMXGFswj32oj0UKFK z$VL{}9IKg3Zb6}=-J)B9(?w2AngaGLf!!=hA_^J16tY!FZOLR+%@Ar3zCnQ25Uw%d ziGU2Lehay!PG}ENBjYYN9qqoH&==_Zclia`2xM5#xYJ& z0c@o~iQo>7e-}#R<|W|?AyLW{y0c;vZN~LQZl=cCV@$dz?dE5F>{67>VT#hDRYWbu zJxoD5Y#$Rs9#n+d+2bN$7EA&Y9|5y)5}1UD`B`X+GsT+{CUS+UB|>eCWcM-Q!M=+& zk}b!ChbdwffyX~oEWusr#HtlSvT>#G6@CLpVyICB2eYc)yXHVWiddFa;~{8}sg$`2hyVU}&->0;uo?uhm@kq zTZdU^*GA;ufU?K65Re-sq@CmHkKbJF@#gA{H&^3A`S_AHW7ZK$j^Ns%!w+UXS)t5W zog&KL6b*7bPPA~HFmB;@Xl7wwAAxUkxYhNMhiBuVDk7os$r_eoPp;Z1U`Kx$1Rl;E zV-{c=j+M!tYS%*3272M2=qvcdzAF;Aaat9|^Xqe((t`b-#?ws^AqzZQ>FM?iQ@YS@ zGmZmH$&+w*Z8;5!3*rh=To5DoYiph;lABj;=SzX6W{2<;go^6`sX z2|(XC;pF0~5yz04bK1&~l#1G7vJ!4sFWjDjZaR36N*m=XCNgevPaJE-6?DiG$5w&P za3GqFnonW2R1tju_n4*#ZH0e|G<$|Uo%2*NrECh0Rc9d%Ut!M_lb2Z0%a>=^U2#Ol zJEYp~%0m%BM_8WAxEnS6-B#iV>xWNH>Z=Nhb_MY)0cSfF_*t?xHXl(4?yCZ7D0w2odQFgFB}suFl%vt!%jC5*K7VQW`%evbbi(hOJ^}&GV~JMZ zVy!c~8e7;u0TU@IsI}jVpDyAtg7^Bxp9*tMye^F$eQx;tbGUjh{th_I6|oG$Ah-n3;^+4l8YDoB;}57%tnh+ARAcNi(; z`~~=KgDW~DjXm|wNav+UZ(;^9OVl+H#Y#D!Tt^bPx(B^41s!x5Xb$d(Jm`;$oR>OS zYfTI5vbx9?X!Ptka<=_U7re2NZo{>@W1iu*F5;~2=oQ|j7uQwnUqa1HjOM`1MTEH9 z?|v`RSsF%Q#M1|-cPIk)^x&WF_D|jx+}^c~z5M)G*TvgMe{}mTa=DI>jlPpJnk9(R3DYd+WGr`MBe8w{*AO=1$yrG;IqR|{}is|A_ormoy1)nxm?)J z-vV^4CV4sEcsThdb_lR1K+^CvPlV+GZ<&DXe9Fiv53xze2RMf7)|F>~Zo(;6#9eai z!pNx6ZoObrK3Hutlg)9B~Xq*c?MgZonAP0qUd_%q}6w?8Fa=>u4OLg$tX5Ock7O zsf(h3C~rQ0Zvz4q4%vgJ>dW8+$9kxlEe)hJnQiv))s0X>4kp{Z4CEQ3S}AW#@##Xh z*|V#AS#OaqYw;=i1bw>j%XKdH%}Ur=Vy|B=o52<32Q7<{u>5h~}5Xb>ww1S>^g zARk1t%E}S^N7ji9={Kv>9Mr@a?#hx-R~GxSmIb?#t1fq|7QP;bUpoyF4!LS1RQ%aH zXNluQ-ru4S0qW%mo@Wk-4$udWD7lNZ@MBm{Rsc@PZL`Bu8mN?TQ)sofq3VCamW9G4 z;n54dF7ET(^TT~l@}FD{_)l&=xyWJO=<~hsc{zIJ)!{2YjEuP2K_Z}v2%ef@q6dM- z$MJC3lvAS@uEITI&gkoJ8paI}8BkC)b&nKB<^g~Hg9AJ`5+rrzXc}zzB)*;KR4Pc$ zie-)Fro9$(X&`w5H3#Dh$OsT(ZKZn3dK!MTk=5e|Y=^}AtcN+551eNQ7dZtAbvySf z)aQJf(BWJM40HZaF$g0k+b{vSiOx|fM;Zgb#r{tcjFI4fk>GzLID+Gl`E~5SYZkP$9JdJUq_?_N}v=RIY z(<$7+!tW@NXQIV%1kNU+5H^XFQ~=x~ZSLV?=Yk!|Nfn$+FTR66yLN`hD6H@pLykV+ zmX&WFMsX|_Xjq~IHOvnv%xpb>cC0I7>s?K7IJT7>C9?qyar}i;E{?qh@POQEKLG!u zcJ2U~eT1O-7ZR`HJ$nGy$sJP#Wbiu3+Q8EZ2up&e2waRHM%6%!s&O_G$C~gr!X73N ztj*x(? zlI&6hhG_wj8J-|R&1b(wl57Y9@}RF>zI7jvC|IQ(bn;8&C*-#n%#fRc+}a4QXby{m zV=KhG4$fGl_8MZZf)^}S$O{%L0lJP=fg%F(AW;&BrLb_b@IKLAA?5!!5)f9;uH=eo zbksYi;Y*@7wKUm=HciJUtgRK^V`^&cwrer0Cs%0Y?j=$0q8xYv1F zybJcWZt+QKg{Td$iY}=xsxIj+>UzJ`zt^9$elX2+M@&f*KZmi&PWsbe)(xIReevFv z)g<%oU}kUP>{o0177nIaLK*5p8PdBrZ=tu;Od|IZf2k$yCC8Vyu7-oSj^g%W_{)NOx6W05wZ3&V4ir0< zb}Z$Lz^eVa-Co7+TiUeFnHSXEYQJ_~>za?!^?u#j)3e;$JPq9&duI+TSmVRr%rzbR zyJoen_3G9RNt9mIsv&KxJNbB3=gfihJYRa=TjJieZ)&`n5}2c@OYajyGnnV-V&QW^ zu0HzM(Z`0;vplOWZ0z3XPoGb`Eet8NUfuSal~vyAz5dEtueH&;v&pAxx~Z^#s@Ary z{#cs`r*_HHI`{jN4ejbS`H(K@CF|L8_kt<(S?$fBM3=OxKEVjkV}{>ec1A7*tf@-O9kW&bM`k zmsDZ5uX2w+wx&%Aj|v^?b~Ua5C2geL`}wzL=P!)CY~QRmj@&Q~>a#p$-LhVI!+U*E zfB9W80sJ#XD>u=fE8#B(7obw7_|-)(w76$@(z+h+arvefc9flHXC4+B-k6OaWlZ!I;;uq}OtD{6+*%H|5`X)2)Hn{g6-^|_IBi?}SQgq^a z8g!xDK;Mc>>6G{~AG#U0nE1xf62BLnbZ1VN+Oy4bpnF#@-8<`b<)D6U@5UP&y%yG& z>h!j_ya)d2bF3b8nFz16m7>Aaw9eTu5H0gqyVv!Yd!-i}22-bb;ylIOQ+%npJqP;e zKEq{=FMoA^-HrX;ZI!<|;#;%pj$E8th4B(=t7+nfqxcIYrCLefkEf(6uWG6X*I>F8 zK2^?5iD6KSi)QWx$9I7a39&94jEi?KK9PZ&(y}gFkGLnk)6SQRlCm!=U_P+^`aJK} zZC+!QZ)>%8PmTAHdf%P~Z>$ZIBIZ$!O7@Xbbt(5{ltSHB_|#$df~UUCVV{0J%VT-j z;M2{$shCG*?vjorT*5;)6^jQIx=++_>w#M2-c;n_=cmcAoNL?Bx)#LVInzTA#Adcj z;D4#$j!2@+9g0owEWD7>oe^0-e{5MBY?t(�!sgKh|5;yT32#^+Wum@F#C*U)#3Q zE&elX>U4ve1mYvon_=lRBj}a&#q||kPW8<%^BT#OYVYp7zN%WEvG&6Z%T0}SoL3U> z)aCsJ@7La#`JN4zy1h)5ZyUP8?A>ei)>^%L>bxt7FO%PD8a~y;cWiFo>`kBBxeb9w z>T&wg=ib!J`v=ew*C>%bPi`vG!**9oPAq{9*XJ2jT-N<580LRo`>9ITk<*^z-s4kc z!CzO9j1%0oo{fF+A1asi)6q$oN?a+rPf^NMqL1TJCnMeL*A;y;`pPfE^;TMEt}ivG zXGYIv-<&0VWqsDm>-x<%q}Ll@rkJ{w(1UgxM}=u$$SGCHeGNy1CGRKn?|g5{2Tm`u z>o<>it82U~9`UIjxhXM!tW9X6f3MI2C;Z>8RLmmCOF>B-VQGKa^aZCiz924B@Ypn(Aqi zU#H1PxJXl-CHsv`hVX+JRdtT!gQ+njT%f7WmHa1>4B>xXuu@jNKtc3iNq?UuCEs&ZyNl(-Jv3RuYih6=AbXh!0b-^mfA>ICrhFIBMYyI*rJj*+jyC1J$NR-a*_<5_yYY>mUIU33*>~L^Z(6NJJp-M!@X@5>-tC zGYN&$iw;5gUS0nJ{Qt0L0E9U&$oUlYj`%0mU-j<#s8%3LyBSoJ3HqA%zQ(V5q*Z=jCZQAEvrgrm$Qy_+^u-t6rx0;R zN5QNsuRU)-mF-hyd-nNN3kYliU2=Dxh%UOTN~L4&FG{52@8+e@o9MfRQhLGN*ep8r x?j~AIXWX5lptX0CHS`Yp?zCE(Hqv)CiDBc_cEm4E`$(dAdJRM^Njld#{|9 ") - os.Exit(2) - } - archive, dsn := os.Args[1], os.Args[2] - var datasetID int64 - fmt.Sscanf(os.Args[3], "%d", &datasetID) - - tmp, err := os.MkdirTemp("", "diffprobe") - must(err) - defer os.RemoveAll(tmp) - - sqlDB, err := rond.Open(archive, tmp) - must(err) - b, err := rond.Parse(sqlDB) - sqlDB.Close() - must(err) - - pg, err := sql.Open("pgx", dsn) - must(err) - defer pg.Close() - ctx := context.Background() - must(pg.PingContext(ctx)) - - fmt.Printf("=== 备份 %s ===\n", archive) - fmt.Printf("备份: 到访 %d / 地点 %d / 位移 %d / 天气 %d / 活动 %d / 标签 %d / 原始定位 %d\n", - len(b.Visits), len(b.Locations), len(b.Movements), len(b.Weather), - len(b.Activities), len(b.Tags), b.RawVisitNum) - var bFirst, bLast sql.NullTime - for _, v := range b.Visits { - if v.Arrival == nil { - continue - } - if !bFirst.Valid || v.Arrival.Before(bFirst.Time) { - bFirst = sql.NullTime{Time: *v.Arrival, Valid: true} - } - if !bLast.Valid || v.Arrival.After(bLast.Time) { - bLast = sql.NullTime{Time: *v.Arrival, Valid: true} - } - } - fmt.Printf("备份时间跨度: %s ~ %s\n", ts(bFirst), ts(bLast)) - - fmt.Printf("\n=== 库内 dataset #%d ===\n", datasetID) - var name string - var cnt, pcnt, raw int - var gFirst, gLast sql.NullTime - must(pg.QueryRowContext(ctx, `SELECT original_name, visit_count, place_count, raw_visit_count, - first_visit_at, last_visit_at FROM datasets WHERE id=$1`, datasetID). - Scan(&name, &cnt, &pcnt, &raw, &gFirst, &gLast)) - fmt.Printf("%s: 到访 %d / 地点 %d / 原始定位 %d,跨度 %s ~ %s\n", name, cnt, pcnt, raw, ts(gFirst), ts(gLast)) - - diffVisits(ctx, pg, datasetID, b) - diffPlaces(ctx, pg, datasetID, b) - diffMovements(ctx, pg, datasetID, b) - diffWeather(ctx, pg, datasetID, b) - diffActivities(ctx, pg, datasetID, b) - diffTags(ctx, pg, datasetID, b) -} - -func ts(t sql.NullTime) string { - if !t.Valid { - return "-" - } - return t.Time.Local().Format("2006-01-02 15:04") -} - -type visitRow struct { - pk, placeSrc, actSrc int - arrival, departure *time.Time - remark, emoji string - bookmarked, userAdd bool -} - -func diffVisits(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) { - fmt.Printf("\n---------- 到访 ----------\n") - rows, err := pg.QueryContext(ctx, `SELECT v.src_pk, COALESCE(p.src_pk,0), COALESCE(a.src_pk,0), - v.arrival, v.departure, COALESCE(v.remark,''), COALESCE(v.emoji,''), v.bookmarked, v.is_user_added - FROM visits v LEFT JOIN places p ON p.id=v.place_id LEFT JOIN activities a ON a.id=v.activity_id - WHERE v.dataset_id=$1`, dsID) - must(err) - db := map[int]visitRow{} - for rows.Next() { - var r visitRow - var arr time.Time - var dep sql.NullTime - must(rows.Scan(&r.pk, &r.placeSrc, &r.actSrc, &arr, &dep, &r.remark, &r.emoji, &r.bookmarked, &r.userAdd)) - r.arrival = &arr - if dep.Valid { - r.departure = &dep.Time - } - db[r.pk] = r - } - rows.Close() - - bk := map[int]rond.Visit{} - for _, v := range b.Visits { - bk[v.SrcPK] = v - } - - var onlyBackup, onlyDB, bothDiff, same []int - for pk, v := range bk { - r, ok := db[pk] - if !ok { - onlyBackup = append(onlyBackup, pk) - continue - } - if vdiff(v, r) != "" { - bothDiff = append(bothDiff, pk) - continue - } - same = append(same, pk) - } - for pk := range db { - if _, ok := bk[pk]; !ok { - onlyDB = append(onlyDB, pk) - } - } - sort.Ints(onlyBackup) - sort.Ints(onlyDB) - sort.Ints(bothDiff) - - fmt.Printf("同号且一致: %d\n", len(same)) - fmt.Printf("仅备份有(实机新增,库里没有): %d\n", len(onlyBackup)) - fmt.Printf("仅库里有(站点侧新增/实机已删除): %d\n", len(onlyDB)) - fmt.Printf("同号但内容不同(冲突): %d\n", len(bothDiff)) - - // 按到达时间做多重集匹配:判断「同号冲突」到底是同批数据换了主键,还是两批无关数据 - byTime := map[string][]int{} - for pk, r := range db { - if r.arrival != nil { - byTime[r.arrival.UTC().Format(time.RFC3339)] = append(byTime[r.arrival.UTC().Format(time.RFC3339)], pk) - } - } - matched, miss := 0, 0 - pair := [][2]int{} - for pk, v := range bk { - if v.Arrival == nil { - continue - } - k := v.Arrival.UTC().Format(time.RFC3339) - if list := byTime[k]; len(list) > 0 { - matched++ - pair = append(pair, [2]int{pk, list[0]}) - byTime[k] = list[1:] - } else { - miss++ - } - } - fmt.Printf("\n[时间对齐] 备份到访按「到达时刻」能在库内找到同刻记录: %d 条,找不到: %d 条\n", matched, miss) - sort.Slice(pair, func(i, j int) bool { return pair[i][0] < pair[j][0] }) - fmt.Printf("[时间对齐] 同刻对的 (备份pk→库pk) 前 12 组: ") - for i, p := range pair { - if i >= 12 { - break - } - fmt.Printf("%d→%d ", p[0], p[1]) - } - fmt.Println() - nDiff := 0 - for _, p := range pair { - if p[0] != p[1] { - nDiff++ - } - } - fmt.Printf("[时间对齐] 同刻但主键不同: %d / %d\n", nDiff, len(pair)) - - show := func(label string, pks []int, f func(pk int) string) { - if len(pks) == 0 { - return - } - fmt.Printf("\n[%s] 共 %d 条,前 8 条:\n", label, len(pks)) - for i, pk := range pks { - if i >= 8 { - break - } - fmt.Printf(" #%d %s\n", pk, f(pk)) - } - } - show("仅备份有", onlyBackup, func(pk int) string { - v := bk[pk] - return fmt.Sprintf("到访 %s 离开 %s 地点src=%s", ft(v.Arrival), ft(v.Departure), ps(v.LocationSrcPK)) - }) - show("仅库里有", onlyDB, func(pk int) string { - r := db[pk] - return fmt.Sprintf("到访 %s 离开 %s 地点src=%d 站点新增=%v 备注=%q", - ft(r.arrival), ftp(r.departure), r.placeSrc, r.userAdd, r.remark) - }) - show("冲突", bothDiff, func(pk int) string { - return vdiff(bk[pk], db[pk]) - }) -} - -func vdiff(v rond.Visit, r visitRow) string { - var d string - if !eq(v.Arrival, r.arrival) { - d += fmt.Sprintf("到达 %s→%s ", ft(v.Arrival), ft(r.arrival)) - } - if !eq(v.Departure, r.departure) { - d += fmt.Sprintf("离开 %s→%s ", ft(v.Departure), ft(r.departure)) - } - if pv(v.LocationSrcPK) != r.placeSrc { - d += fmt.Sprintf("地点 %s→%d ", ps(v.LocationSrcPK), r.placeSrc) - } - if av(v.ActivitySrcPK) != r.actSrc { - d += fmt.Sprintf("活动 %s→%d ", ps(v.ActivitySrcPK), r.actSrc) - } - if v.Remark != r.remark { - d += fmt.Sprintf("备注 %q→%q ", v.Remark, r.remark) - } - if v.Emoji != r.emoji { - d += fmt.Sprintf("表情 %q→%q ", v.Emoji, r.emoji) - } - if v.Bookmarked != r.bookmarked { - d += fmt.Sprintf("收藏 %v→%v ", v.Bookmarked, r.bookmarked) - } - return d -} - -func diffPlaces(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) { - fmt.Printf("\n---------- 地点 ----------\n") - type prow struct { - name, poi, cc, prov, city, dist, sub, thor, tz string - lat, lon float64 - } - rows, err := pg.QueryContext(ctx, `SELECT src_pk, COALESCE(name,''), COALESCE(poi_category,''), - COALESCE(country_code,''), COALESCE(province,''), COALESCE(city,''), COALESCE(district,''), - COALESCE(sublocality,''), COALESCE(thoroughfare,''), COALESCE(timezone,''), - COALESCE(lat,0), COALESCE(lon,0) FROM places WHERE dataset_id=$1`, dsID) - must(err) - db := map[int]prow{} - for rows.Next() { - var pk int - var r prow - must(rows.Scan(&pk, &r.name, &r.poi, &r.cc, &r.prov, &r.city, &r.dist, &r.sub, &r.thor, &r.tz, &r.lat, &r.lon)) - db[pk] = r - } - rows.Close() - - bk := map[int]rond.Location{} - for _, l := range b.Locations { - bk[l.SrcPK] = l - } - var onlyB, onlyD, diff []int - for pk, l := range bk { - r, ok := db[pk] - if !ok { - onlyB = append(onlyB, pk) - continue - } - if l.Name != r.name || l.POICategory != r.poi || l.Province != r.prov || l.City != r.city || - l.District != r.dist || l.Sublocality != r.sub || l.Thoroughfare != r.thor || l.Timezone != r.tz || - l.CountryCode != r.cc || !near(l.Lat, r.lat) || !near(l.Lon, r.lon) { - diff = append(diff, pk) - } - } - for pk := range db { - if _, ok := bk[pk]; !ok { - onlyD = append(onlyD, pk) - } - } - sort.Ints(onlyB) - sort.Ints(onlyD) - sort.Ints(diff) - fmt.Printf("仅备份有: %d,仅库里有: %d,同号内容不同: %d\n", len(onlyB), len(onlyD), len(diff)) - for i, pk := range onlyB { - if i >= 6 { - break - } - fmt.Printf(" 仅备份 #%d %s (%s %s %s)\n", pk, bk[pk].Name, bk[pk].Province, bk[pk].City, bk[pk].POICategory) - } - for i, pk := range onlyD { - if i >= 6 { - break - } - fmt.Printf(" 仅库内 #%d %s (%s %s)\n", pk, db[pk].name, db[pk].prov, db[pk].city) - } - for i, pk := range diff { - if i >= 6 { - break - } - l, r := bk[pk], db[pk] - fmt.Printf(" 冲突 #%d 备份[name=%q city=%q %.6f,%.6f] 库[name=%q city=%q %.6f,%.6f]\n", - pk, l.Name, l.City, l.Lat, l.Lon, r.name, r.city, r.lat, r.lon) - } -} - -func diffMovements(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) { - fmt.Printf("\n---------- 位移 ----------\n") - rows, err := pg.QueryContext(ctx, `SELECT src_pk, COALESCE(transport_src,0), - COALESCE(from_visit_src,0), COALESCE(to_visit_src,0), started_at, ended_at - FROM movements WHERE dataset_id=$1`, dsID) - must(err) - type mrow struct { - t, f, to int - s, e *time.Time - } - db := map[int]mrow{} - for rows.Next() { - var pk int - var r mrow - var s, e sql.NullTime - must(rows.Scan(&pk, &r.t, &r.f, &r.to, &s, &e)) - if s.Valid { - r.s = &s.Time - } - if e.Valid { - r.e = &e.Time - } - db[pk] = r - } - rows.Close() - var onlyB, onlyD int - for _, m := range b.Movements { - if _, ok := db[m.SrcPK]; !ok { - onlyB++ - } - } - for pk := range db { - found := false - for _, m := range b.Movements { - if m.SrcPK == pk { - found = true - break - } - } - if !found { - onlyD++ - } - } - fmt.Printf("备份 %d 条 / 库内 %d 条;仅备份有 %d,仅库里有 %d\n", len(b.Movements), len(db), onlyB, onlyD) -} - -func diffWeather(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) { - var n int - must(pg.QueryRowContext(ctx, `SELECT count(*) FROM weather WHERE dataset_id=$1`, dsID).Scan(&n)) - fmt.Printf("\n---------- 天气 ----------\n备份 %d 条 / 库内 %d 条\n", len(b.Weather), n) -} - -func diffActivities(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) { - fmt.Printf("\n---------- 活动类型 ----------\n") - rows, err := pg.QueryContext(ctx, `SELECT src_pk, name FROM activities WHERE dataset_id=$1`, dsID) - must(err) - db := map[int]string{} - for rows.Next() { - var pk int - var n string - must(rows.Scan(&pk, &n)) - db[pk] = n - } - rows.Close() - for _, a := range b.Activities { - n, ok := db[a.SrcPK] - if !ok { - fmt.Printf(" 仅备份 #%d %q\n", a.SrcPK, a.Name) - } else if n != a.Name { - fmt.Printf(" 冲突 #%d 备份=%q 库=%q\n", a.SrcPK, a.Name, n) - } - } - for pk, n := range db { - found := false - for _, a := range b.Activities { - if a.SrcPK == pk { - found = true - } - } - if !found { - fmt.Printf(" 仅库内 #%d %q\n", pk, n) - } - } -} - -func diffTags(ctx context.Context, pg *sql.DB, dsID int64, b *rond.Backup) { - var n int - must(pg.QueryRowContext(ctx, `SELECT count(*) FROM tags WHERE dataset_id=$1`, dsID).Scan(&n)) - fmt.Printf("\n---------- 标签 ----------\n备份 %d 条 / 库内 %d 条\n", len(b.Tags), n) -} - -func eq(a, b *time.Time) bool { - if a == nil || b == nil { - return a == nil && b == nil - } - return a.Equal(*b) -} - -func pv(p *int) int { - if p == nil { - return 0 - } - return *p -} - -func av(p *int) int { return pv(p) } - -func ps(p *int) string { - if p == nil { - return "-" - } - return fmt.Sprint(*p) -} - -func ft(t *time.Time) string { - if t == nil { - return "-" - } - return t.Local().Format("01-02 15:04") -} - -func ftp(t *time.Time) string { return ft(t) } - -func near(a, b float64) bool { - d := a - b - return d < 1e-9 && d > -1e-9 -} - -func must(err error) { - if err != nil { - fmt.Fprintln(os.Stderr, "错误:", err) - os.Exit(1) - } -} diff --git a/tools/exportprobe/main.go b/tools/exportprobe/main.go deleted file mode 100644 index 5c5f116..0000000 --- a/tools/exportprobe/main.go +++ /dev/null @@ -1,40 +0,0 @@ -// 一次性探针:调用站点的导出逻辑产出一份 .rondbackup,用于核对 -// 「站点导出 → 导入 rond → rond 再导出」这条链路上 UUID 与 Z_PK 是否还连贯。 -package main - -import ( - "context" - "database/sql" - "fmt" - "os" - "strconv" - - _ "github.com/jackc/pgx/v5/stdlib" - - "rond-with-you/internal/rond" -) - -func main() { - if len(os.Args) < 5 { - fmt.Println("用法: exportprobe ") - os.Exit(2) - } - dsID, err := strconv.ParseInt(os.Args[2], 10, 64) - must(err) - db, err := sql.Open("pgx", os.Args[1]) - must(err) - defer db.Close() - - f, err := os.Create(os.Args[4]) - must(err) - defer f.Close() - must(rond.ExportRondbackup(context.Background(), db, dsID, os.Args[3], f)) - fmt.Printf("已导出到 %s\n", os.Args[4]) -} - -func must(err error) { - if err != nil { - fmt.Fprintln(os.Stderr, "错误:", err) - os.Exit(1) - } -} diff --git a/tools/fogprobe/main.go b/tools/fogprobe/main.go deleted file mode 100644 index 496956b..0000000 --- a/tools/fogprobe/main.go +++ /dev/null @@ -1,133 +0,0 @@ -// 一次性探针:把一份 .fwss 与 PG 里的 fog_blocks 逐块比对,量化「叠加 / 替换」两种模式的影响。 -package main - -import ( - "database/sql" - "encoding/hex" - "fmt" - "os" - "strconv" - "strings" - - _ "github.com/jackc/pgx/v5/stdlib" - - "rond-with-you/internal/fog" -) - -func main() { - if len(os.Args) < 3 { - fmt.Println("用法: fogprobe [userID]") - os.Exit(2) - } - uid := int64(1) - if len(os.Args) > 3 { - uid, _ = strconv.ParseInt(os.Args[3], 10, 64) - } - data, err := os.ReadFile(os.Args[1]) - must(err) - inc, incCells, err := fog.Decode(data) - must(err) - fmt.Printf("快照 %s: %d 块 / %d 格\n", os.Args[1], len(inc), incCells) - - pg, err := sql.Open("pgx", os.Args[2]) - must(err) - defer pg.Close() - rows, err := pg.Query(`SELECT gbx, gby, bitmap FROM fog_blocks WHERE user_id=$1`, uid) - must(err) - local := map[[2]int][]byte{} - localCells := 0 - for rows.Next() { - var gx, gy int - var bm []byte - must(rows.Scan(&gx, &gy, &bm)) - local[[2]int{gx, gy}] = bm - localCells += popcount(bm) - } - rows.Close() - fmt.Printf("库内: %d 块 / %d 格\n", len(local), localCells) - - var newBlocks, newCells int - var novBlock, overlap int - var overlapGain int - for _, b := range inc { - k := [2]int{b.GX, b.GY} - old, ok := local[k] - if !ok { - newBlocks++ - newCells += popcount(b.Bitmap) - continue - } - novBlock++ - gain := 0 - for i := range b.Bitmap { - gain += poptab[b.Bitmap[i]&^old[i]] - } - if gain > 0 { - overlap++ - overlapGain += gain - } - } - var lostBlocks, lostCells, lostFull int - for k, bm := range local { - found := false - for _, b := range inc { - if b.GX == k[0] && b.GY == k[1] { - found = true - break - } - } - if !found { - lostBlocks++ - lostCells += popcount(bm) - lostFull++ - } - } - fmt.Printf("\n【叠加合并 overlay】\n") - fmt.Printf(" 新增整块: %d 块(+%d 格)\n", newBlocks, newCells) - fmt.Printf(" 与已有块重叠: %d 块,其中 %d 块含新格(+%d 格)\n", novBlock, overlap, overlapGain) - fmt.Printf(" 合计新增格: %d,合并后总量: %d 块 / %d 格\n", - newCells+overlapGain, len(local)+newBlocks, localCells+newCells+overlapGain) - - fmt.Printf("\n【整体替换 cover】\n") - fmt.Printf(" 库里现有 %d 块会被丢弃(快照里完全没有这些块),损失 %d 格\n", lostFull, lostCells) - fmt.Printf(" 替换后总量: %d 块 / %d 格\n", len(inc), incCells) - fmt.Printf(" 净变化: %+d 块 / %+d 格\n", len(inc)-len(local), incCells-localCells) - - fmt.Printf("\n【其它图层】\n") - layers, err := fog.RawLayers(data) - if err == nil { - for k, v := range layers { - fmt.Printf(" %s (%d 字节, %s)\n", k, len(v), hex.EncodeToString(v[:min(8, len(v))])) - } - } -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} - -var poptab = func() [256]int { - var t [256]int - for i := range t { - t[i] = strings.Count(strconv.FormatUint(uint64(i), 2), "1") - } - return t -}() - -func popcount(b []byte) int { - n := 0 - for _, v := range b { - n += poptab[v] - } - return n -} - -func must(err error) { - if err != nil { - fmt.Fprintln(os.Stderr, "错误:", err) - os.Exit(1) - } -} diff --git a/tools/idprobe/main.go b/tools/idprobe/main.go deleted file mode 100644 index 72eee4d..0000000 --- a/tools/idprobe/main.go +++ /dev/null @@ -1,171 +0,0 @@ -// 一次性探针:检查 rond 备份里是否存在「跨备份稳定」的对齐键。 -// 用完即可删。 -package main - -import ( - "database/sql" - "fmt" - "os" - "strings" - - "rond-with-you/internal/rond" -) - -func main() { - if len(os.Args) < 3 { - fmt.Println("用法: idprobe ") - os.Exit(2) - } - a := load(os.Args[1]) - b := load(os.Args[2]) - fmt.Printf("\n=== A=%s ===\n=== B=%s ===\n", os.Args[1], os.Args[2]) - - fmt.Printf("\n---------- ZVISIT.ZIDENTIFIER_ 稳定性 ----------\n") - fmt.Printf("A 有 UUID %d / %d,B 有 UUID %d / %d\n", len(a.visitUUID), len(a.visitPK), len(b.visitUUID), len(b.visitPK)) - inter := 0 - for u := range a.visitUUID { - if _, ok := b.visitUUID[u]; ok { - inter++ - } - } - fmt.Printf("两次备份 UUID 交集: %d(A 独有 %d,B 独有 %d)\n", - inter, len(a.visitUUID)-inter, len(b.visitUUID)-inter) - // UUID 相同则对比 pk 是否相同 - samePK, diffPK := 0, 0 - for u, pa := range a.visitUUID { - if pb, ok := b.visitUUID[u]; ok { - if pa == pb { - samePK++ - } else { - diffPK++ - } - } - } - fmt.Printf("UUID 相同者中:Z_PK 也相同 %d,Z_PK 已变 %d\n", samePK, diffPK) - - fmt.Printf("\n---------- ZACTIVITY.ZUID_ ----------\n") - fmt.Printf("A UUID %d / %d,B UUID %d / %d\n", len(a.actUUID), len(a.actPK), len(b.actUUID), len(b.actPK)) - same, dif := 0, 0 - for u, pa := range a.actUUID { - if pb, ok := b.actUUID[u]; ok { - if pa == pb { - same++ - } else { - dif++ - } - } - } - fmt.Printf("交集配对:Z_PK 相同 %d,Z_PK 已变 %d\n", same, dif) - - fmt.Printf("\n---------- ZLOCATION 可用稳定列 ----------\n") - fmt.Printf("A 列: %s\n", strings.Join(a.locCols, ", ")) - fmt.Printf("A 地点 %d,B 地点 %d\n", len(a.locPK), len(b.locPK)) - // 名称+粗坐标是否可作键 - type lk struct { - name string - lat float64 - lon float64 - } - keyA := map[string][]int{} - for pk, l := range a.locPK { - k := fmt.Sprintf("%s|%.5f|%.5f", l.name, l.lat, l.lon) - keyA[k] = append(keyA[k], pk) - } - hit, miss := 0, 0 - for pk, l := range b.locPK { - k := fmt.Sprintf("%s|%.5f|%.5f", l.name, l.lat, l.lon) - if _, ok := keyA[k]; ok { - hit++ - } else { - miss++ - if miss <= 5 { - fmt.Printf(" B 独有地点 #%d %q %.6f,%.6f\n", pk, l.name, l.lat, l.lon) - } - } - } - fmt.Printf("按「名称+坐标(1e-5)」键:B 命中 A %d,未命中 %d\n", hit, miss) -} - -type loc struct { - name string - lat, lon float64 -} - -type snap struct { - visitUUID map[string]int - visitPK map[int]bool - actUUID map[string]int - actPK map[int]bool - locPK map[int]loc - locCols []string -} - -func load(path string) *snap { - dir, err := os.MkdirTemp("", "idprobe") - must(err) - defer os.RemoveAll(dir) - db, err := rond.Open(path, dir) - must(err) - defer db.Close() - - s := &snap{visitUUID: map[string]int{}, visitPK: map[int]bool{}, - actUUID: map[string]int{}, actPK: map[int]bool{}, locPK: map[int]loc{}} - - rows, err := db.Query(`SELECT Z_PK, hex(ZIDENTIFIER_) FROM ZVISIT`) - must(err) - for rows.Next() { - var pk int - var u sql.NullString - must(rows.Scan(&pk, &u)) - s.visitPK[pk] = true - if u.Valid && u.String != "" && strings.Trim(u.String, "0") != "" { - s.visitUUID[u.String] = pk - } - } - rows.Close() - - rows, err = db.Query(`SELECT Z_PK, hex(ZUID_), COALESCE(ZNAME_,'') FROM ZACTIVITY`) - must(err) - for rows.Next() { - var pk int - var u sql.NullString - var n string - must(rows.Scan(&pk, &u, &n)) - s.actPK[pk] = true - if u.Valid && u.String != "" && strings.Trim(u.String, "0") != "" { - s.actUUID[u.String] = pk - } - } - rows.Close() - - rows, err = db.Query(`PRAGMA table_info(ZLOCATION)`) - must(err) - for rows.Next() { - var cid int - var name, typ string - var notnull int - var dflt sql.NullString - var pk int - must(rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk)) - s.locCols = append(s.locCols, name) - } - rows.Close() - - rows, err = db.Query(`SELECT Z_PK, COALESCE(ZNAME_,''), COALESCE(ZLATITUDE,0), COALESCE(ZLONGITUDE,0) FROM ZLOCATION`) - must(err) - for rows.Next() { - var pk int - var l loc - must(rows.Scan(&pk, &l.name, &l.lat, &l.lon)) - s.locPK[pk] = l - } - rows.Close() - return s -} - -func must(err error) { - if err != nil { - fmt.Fprintln(os.Stderr, "错误:", err) - os.Exit(1) - } -} diff --git a/tools/sqlprobe/main.go b/tools/sqlprobe/main.go deleted file mode 100644 index 88466bc..0000000 --- a/tools/sqlprobe/main.go +++ /dev/null @@ -1,57 +0,0 @@ -// 一次性探针:在 rond 备份的 SQLite 上跑一条查询并打印结果(BLOB 打十六进制)。 -package main - -import ( - "fmt" - "os" - "strings" - - "rond-with-you/internal/rond" -) - -func main() { - if len(os.Args) < 3 { - fmt.Println(`用法: sqlprobe "SELECT ..."`) - os.Exit(2) - } - dir, err := os.MkdirTemp("", "sqlprobe") - if err != nil { - panic(err) - } - defer os.RemoveAll(dir) - db, err := rond.Open(os.Args[1], dir) - if err != nil { - panic(err) - } - defer db.Close() - - rows, err := db.Query(os.Args[2]) - if err != nil { - panic(err) - } - defer rows.Close() - cols, _ := rows.Columns() - fmt.Println(strings.Join(cols, " | ")) - for rows.Next() { - vals := make([]any, len(cols)) - ptrs := make([]any, len(cols)) - for i := range vals { - ptrs[i] = &vals[i] - } - if err := rows.Scan(ptrs...); err != nil { - panic(err) - } - parts := make([]string, len(cols)) - for i, v := range vals { - switch t := v.(type) { - case nil: - parts[i] = "-" - case []byte: - parts[i] = fmt.Sprintf("%x", t) - default: - parts[i] = fmt.Sprint(t) - } - } - fmt.Println(strings.Join(parts, " | ")) - } -}