Files
rond-with-you/internal/stats/stats_test.go
T
tamakyi b4c2a8452f 审计修复:后端(安全 / 数据一致性 / 性能)
audit-2026-09-21.md 里的后端条目落地。每条都配了回归守卫,并逐条做过变异验证
(注入原 bug → 确认按正确理由变红 → 还原 → 变绿)。

安全
- CSRF 只加同源 Origin/Referer 守卫(csrf.go),不动模板
- 会话 cookie 的 Secure 按请求判断;两步验证两闸(loginGuard + 验证码一次性)
- 跳转收口到 safeInternalPath;clientIP 只认 XFF 最后一段
- GPX 按属主圈定且查 RowsAffected;下载名走 downloadDisposition
- 上传口统一 MaxBytesReader;落盘后的失败路径清掉半成品

数据一致性
- 区域判定两端各过一遍再 AND(apiTrack 与 movementsWhere 对齐)
- Filter.Verdict 全域一致(下推到 visitsWhere);聚合刷新必须返回 error
- 附属数据改挂坐标键,Go 与 SQL 两套表达式逐位一致
- 脏备份的伪时间/越界坐标不再进领域模型;rows 循环统一 drainRows
- 合并与导出:writeRawRow 上抛错误、悬空引用清理、原生列补全

性能
- placeCols 相关子查询带 dataset_id 前导列;findSourceArchive 先按大小筛
- 批量勾选改 ANY($3::bigint[]);fillupCache 加上限;同一查询不再查两遍

另:新增「全新库」冒烟测试 —— 空库跑完 Migrate 再用应用侧读查询真打一遍,
迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
2026-09-24 20:37:16 +08:00

325 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package stats_test
import (
"context"
"database/sql"
"os"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"rond-with-you/internal/stats"
)
// 这些是只读的对照测试:把统计结果和「手写 SQL 数一遍」的结果比对,
// 专门钉住「区域限制漏在某个查询里」这类问题(轨迹、里程、出行方式都犯过)。
func setup(t *testing.T) (*sql.DB, int64) {
t.Helper()
dsn := os.Getenv("ROND_DSN")
if dsn == "" {
t.Skip("未设置 ROND_DSN")
}
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
var id int64
if err := db.QueryRow(`SELECT id FROM datasets WHERE is_active LIMIT 1`).Scan(&id); err != nil {
t.Skipf("没有生效数据集:%v", err)
}
return db, id
}
func TestOverviewMatchesManualSQL(t *testing.T) {
db, dsID := setup(t)
d := stats.DB{DB: db}
f := stats.Filter{DatasetID: dsID}
got, err := d.Overview(context.Background(), f)
if err != nil {
t.Fatal(err)
}
var places, cities, days, visits, movements int
var dist float64
if err := db.QueryRow(`SELECT count(DISTINCT v.place_id), count(DISTINCT p.city),
count(DISTINCT (v.arrival AT TIME ZONE 'Asia/Shanghai')::date), count(*)
FROM visits v LEFT JOIN places p ON p.id = v.place_id WHERE v.dataset_id=$1`, dsID).
Scan(&places, &cities, &days, &visits); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT COALESCE(sum(distance_km),0), count(*) FROM movements WHERE dataset_id=$1`, dsID).
Scan(&dist, &movements); err != nil {
t.Fatal(err)
}
if got.Places != places || got.Cities != cities || got.Days != days || got.Visits != visits {
t.Errorf("总览与手工 SQL 不一致:%+v vs places=%d cities=%d days=%d visits=%d",
got, places, cities, days, visits)
}
if diff := got.DistanceKm - dist; diff > 0.01 || diff < -0.01 {
t.Errorf("里程 %.4f,手工 SQL %.4f", got.DistanceKm, dist)
}
if got.Movements != movements {
t.Errorf("行程数 %d,手工 SQL %d", got.Movements, movements)
}
}
// 区域限制必须同时作用于 visits 与 movements:只改其中一边,
// 访客会看到「到访次数藏了、里程还留着」这种自相矛盾的数字。
func TestRegionFilterAppliesToVisitsAndMovements(t *testing.T) {
db, dsID := setup(t)
d := stats.DB{DB: db}
ctx := context.Background()
// 挑一个有记录的省份做白名单,避免用空区域(那样等于没限制)
var region string
if err := db.QueryRow(`SELECT p.province FROM places p JOIN visits v ON v.place_id=p.id
WHERE v.dataset_id=$1 AND p.province IS NOT NULL AND p.province <> ''
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&region); err != nil {
t.Skipf("找不到可用省份:%v", err)
}
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
if err != nil {
t.Fatal(err)
}
only, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}})
if err != nil {
t.Fatal(err)
}
if only.Visits >= all.Visits {
t.Errorf("白名单 %s 后到访数没减少:%d → %d", region, all.Visits, only.Visits)
}
if only.DistanceKm >= all.DistanceKm {
t.Errorf("白名单 %s 后里程没减少:%.1f → %.1f(movements 的区域过滤可能漏了)",
region, all.DistanceKm, only.DistanceKm)
}
// 黑名单:反过来,排除这个省后剩下的应等于「全部 - 只有它」
var other string
if err := db.QueryRow(`SELECT p.province FROM places p JOIN visits v ON v.place_id=p.id
WHERE v.dataset_id=$1 AND p.province IS NOT NULL AND p.province <> '' AND p.province <> $2
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID, region).Scan(&other); err == nil {
notRegion, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}, RegionsExclude: true})
if err != nil {
t.Fatal(err)
}
if notRegion.Visits == all.Visits {
t.Errorf("黑名单 %s 后到访数没减少:仍为 %d", region, all.Visits)
}
}
// 出行方式统计同样要跟着缩水
tAll, err := d.TransportStats(ctx, stats.Filter{DatasetID: dsID})
if err != nil {
t.Fatal(err)
}
tOnly, err := d.TransportStats(ctx, stats.Filter{DatasetID: dsID, Regions: []string{region}})
if err != nil {
t.Fatal(err)
}
sum := func(ts []stats.TransportStat) float64 {
var v float64
for _, x := range ts {
v += x.Distance
}
return v
}
if sum(tOnly) >= sum(tAll) {
t.Errorf("白名单 %s 后出行方式里程没减少:%.1f → %.1f", region, sum(tAll), sum(tOnly))
}
}
// 过滤条件不该把时间范围弄丢。
func TestOverviewTimeRangeStillApplies(t *testing.T) {
db, dsID := setup(t)
d := stats.DB{DB: db}
ctx := context.Background()
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
if err != nil {
t.Fatal(err)
}
if all.FirstAt == nil || all.LastAt == nil {
t.Skip("没有时间范围可测")
}
mid := all.FirstAt.Add(all.LastAt.Sub(*all.FirstAt) / 2)
half, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, From: &mid})
if err != nil {
t.Fatal(err)
}
if half.Visits >= all.Visits {
t.Errorf("限定后半段后到访数没减少:%d → %d", all.Visits, half.Visits)
}
if half.LastAt == nil || all.LastAt == nil || half.LastAt.Before(mid) {
t.Errorf("后半段的最后时间 %v 早于起点 %v", half.LastAt, mid)
}
}
// TestWeatherStatsRespectsFilter 天气统计必须跟着筛选走。
//
// 以前 WeatherStats 声明了 Filter 却只用了 DatasetID,HourlyTemp 干脆只收 datasetID:
// 统计页筛完之后「气温」还是全量汇总,跟同一页的到访数对不上。
func TestWeatherStatsRespectsFilter(t *testing.T) {
db, dsID := setup(t)
d := stats.DB{DB: db}
ctx := context.Background()
var minAt, maxAt time.Time
if err := db.QueryRow(`SELECT min(at), max(at) FROM weather WHERE dataset_id=$1`, dsID).
Scan(&minAt, &maxAt); err != nil {
t.Skipf("没有天气数据可测:%v", err)
}
// 取「最后一个月」当区间:它必然只覆盖一部分天气
from := time.Date(maxAt.Year(), maxAt.Month(), 1, 0, 0, 0, 0, maxAt.Location())
if !from.After(minAt) {
t.Skip("天气数据只落在一个月内,构造不出有效区间")
}
sum := func(ws []stats.WeatherStat) int {
n := 0
for _, w := range ws {
n += w.Count
}
return n
}
all, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID})
if err != nil {
t.Fatal(err)
}
got, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID, From: &from})
if err != nil {
t.Fatal(err)
}
// 与手写 SQL 对账
var wantTotal, wantWindow int
if err := db.QueryRow(`SELECT count(*) FROM weather WHERE dataset_id=$1`, dsID).Scan(&wantTotal); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT count(*) FROM weather w WHERE w.dataset_id=$1 AND w.at >= $2`,
dsID, from).Scan(&wantWindow); err != nil {
t.Fatal(err)
}
// 分组数是 12 上限,只有没截断时求和才等于总数
if len(got) < 12 && sum(got) != wantWindow {
t.Errorf("按时间筛选后天气行数 %d,手写 SQL %d", sum(got), wantWindow)
}
if wantWindow < wantTotal && sum(got) >= sum(all) {
t.Errorf("筛了时间但天气行数没减少(%d → %d),筛选没生效", sum(all), sum(got))
}
// 逐月气温同样要跟着区间走
temps, err := d.HourlyTemp(ctx, stats.Filter{DatasetID: dsID, From: &from})
if err != nil {
t.Fatal(err)
}
if len(temps) != 1 {
t.Errorf("区间只覆盖 %s 一个月,逐月气温应当只剩 1 组,实际 %d 组:%+v",
from.Format("2006-01"), len(temps), temps)
}
// 城市筛选:只有归属于该城市到访的天气算数
var city string
if err := db.QueryRow(`SELECT p.city FROM weather w
JOIN visits v ON v.dataset_id=w.dataset_id AND v.src_pk=w.visit_src
JOIN places p ON p.id=v.place_id
WHERE w.dataset_id=$1 AND p.city IS NOT NULL
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&city); err != nil {
t.Skipf("没有带城市的天气:%v", err)
}
byCity, err := d.WeatherStats(ctx, stats.Filter{DatasetID: dsID, City: city})
if err != nil {
t.Fatal(err)
}
var wantCity int
if err := db.QueryRow(`SELECT count(*) FROM weather w
WHERE w.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
WHERE v.dataset_id=w.dataset_id AND v.src_pk=w.visit_src
AND EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city=$2))`,
dsID, city).Scan(&wantCity); err != nil {
t.Fatal(err)
}
if len(byCity) < 12 && sum(byCity) != wantCity {
t.Errorf("按城市「%s」筛选后天气行数 %d,手写 SQL %d", city, sum(byCity), wantCity)
}
if sum(byCity) >= wantTotal {
t.Errorf("按城市筛选后行数(%d)不该等于全量(%d)", sum(byCity), wantTotal)
}
}
// TestVisitSideFiltersApplyToMovements 钉住「按活动 / 城市 / 标签 / 关键词筛选时,
// 里程与出行次数也要跟着变」。
//
// movements 自己只有两端到访,没有活动、城市这些字段。曾经 movementsWhere 只认
// dataset / 时间 / 家工作 / 区域,于是筛完之后「到访数变了、里程还是全量」——
// 区域那档犯过一次(见上一个测试),这几档是同一个洞没堵完。
func TestVisitSideFiltersApplyToMovements(t *testing.T) {
db, dsID := setup(t)
d := stats.DB{DB: db}
ctx := context.Background()
// 挑一个真有到访的活动,且它只覆盖一部分到访(否则筛选前后一样,测不出东西)
var actID int64
if err := db.QueryRow(`SELECT activity_id FROM visits
WHERE dataset_id=$1 AND activity_id IS NOT NULL
GROUP BY activity_id ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&actID); err != nil {
t.Skipf("没有带活动的到访:%v", err)
}
all, err := d.Overview(ctx, stats.Filter{DatasetID: dsID})
if err != nil {
t.Fatal(err)
}
one, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, ActivityIDs: []int64{actID}})
if err != nil {
t.Fatal(err)
}
if one.Visits >= all.Visits {
t.Skipf("活动 %d 覆盖了全部到访,换个数据集再测", actID)
}
// 手写 SQL:两端**任一端**的到访属于该活动的位移
var dist float64
var n int
if err := db.QueryRow(`SELECT COALESCE(sum(m.distance_km),0), count(*) FROM movements m
WHERE m.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
WHERE v.dataset_id=m.dataset_id AND v.activity_id=$2
AND (v.src_pk=m.from_visit_src OR v.src_pk=m.to_visit_src))`,
dsID, actID).Scan(&dist, &n); err != nil {
t.Fatal(err)
}
if diff := one.DistanceKm - dist; diff > 0.01 || diff < -0.01 {
t.Errorf("按活动筛选后里程 %.4f,手写 SQL %.4f(筛选没作用到里程上?)", one.DistanceKm, dist)
}
if one.Movements != n {
t.Errorf("按活动筛选后出行次数 %d,手写 SQL %d", one.Movements, n)
}
// 同一条路径也覆盖城市:movementVisitConds 里所有维度共用一个 EXISTS
var city string
if err := db.QueryRow(`SELECT p.city FROM visits v JOIN places p ON p.id=v.place_id
WHERE v.dataset_id=$1 AND p.city IS NOT NULL AND p.city<>''
GROUP BY 1 ORDER BY count(*) DESC LIMIT 1`, dsID).Scan(&city); err != nil {
t.Skipf("没有带城市的到访:%v", err)
}
byCity, err := d.Overview(ctx, stats.Filter{DatasetID: dsID, City: city})
if err != nil {
t.Fatal(err)
}
var cityDist float64
if err := db.QueryRow(`SELECT COALESCE(sum(m.distance_km),0) FROM movements m
WHERE m.dataset_id=$1 AND EXISTS (SELECT 1 FROM visits v
WHERE v.dataset_id=m.dataset_id
AND EXISTS (SELECT 1 FROM places px WHERE px.id=v.place_id AND px.city=$2)
AND (v.src_pk=m.from_visit_src OR v.src_pk=m.to_visit_src))`,
dsID, city).Scan(&cityDist); err != nil {
t.Fatal(err)
}
if diff := byCity.DistanceKm - cityDist; diff > 0.01 || diff < -0.01 {
t.Errorf("按城市「%s」筛选后里程 %.4f,手写 SQL %.4f", city, byCity.DistanceKm, cityDist)
}
}