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 再用应用侧读查询真打一遍, 迁移块排到依赖表之前这种错只有新库会炸,老库毫无症状。
325 lines
12 KiB
Go
325 lines
12 KiB
Go
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(®ion); 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)
|
||
}
|
||
}
|