Files
rond-with-you/internal/stats/verdict_scope_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

356 lines
13 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"
"fmt"
"os"
"testing"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"rond-with-you/internal/stats"
)
// verdictFixture 是一套**自造**的数据集,坐标故意取在远端(89.9 / 100.x),
// 与任何真实备份、任何测试夹具都不会重合。
//
// 为什么不复用 setup(t) 的活跃数据集:`place_key` 是**坐标键**,而所有夹具导入的都是
// 同一份备份、坐标完全相同。往活跃数据集上写 place_votes 会顺着坐标键漏进并行跑的
// 其它包(internal/web 有用例要读某个地点的 verdict),把别人的断言弄脏。
// 这里只建自己的数据集,并且**不碰 is_active**——改它会让并行跑的统计用例选到空库。
type verdictFixture struct {
db *sql.DB
ds int64
uid int64
p1, p2, p3 int64 // p1/p2 打「推荐」,p3 不打
}
func newVerdictFixture(t *testing.T) *verdictFixture {
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() })
ctx := context.Background()
tag := fmt.Sprint(time.Now().UnixNano())
var uid int64
if err := db.QueryRowContext(ctx, `INSERT INTO users (username, password_hash, display_name)
VALUES ($1,'x','verdict-scope') RETURNING id`, "verdict-scope-"+tag).Scan(&uid); err != nil {
t.Fatalf("建临时账号失败: %v", err)
}
var ds int64
sha := fmt.Sprintf("%064x", time.Now().UnixNano())
if err := db.QueryRowContext(ctx, `INSERT INTO datasets (user_id, original_name, sha256, status)
VALUES ($1,'verdict-scope',$2,'done') RETURNING id`, uid, sha).Scan(&ds); err != nil {
t.Fatalf("建临时数据集失败: %v", err)
}
t.Cleanup(func() {
db.ExecContext(ctx, `DELETE FROM place_votes WHERE user_id=$1`, uid)
// 数据集级联带走 places / visits / movements / weather / activities
db.ExecContext(ctx, `DELETE FROM datasets WHERE id=$1`, ds)
db.ExecContext(ctx, `DELETE FROM users WHERE id=$1`, uid)
})
// 到访的到达时间固定写死,避免「今天」漂移导致天数/月份断言不稳
at := func(iso string) time.Time {
v, err := time.Parse(time.RFC3339, iso)
if err != nil {
t.Fatal(err)
}
return v
}
mkPlace := func(pk int, lat, lon float64, city, prov string) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(ctx, `INSERT INTO places
(dataset_id, src_pk, name, lat, lon, city, province)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
ds, pk, fmt.Sprintf("P%d", pk), lat, lon, city, prov).Scan(&id); err != nil {
t.Fatalf("建地点失败: %v", err)
}
return id
}
f := &verdictFixture{db: db, ds: ds, uid: uid}
f.p1 = mkPlace(1, 89.900001, 100.100001, "CityA", "ProvA")
f.p2 = mkPlace(2, 89.900002, 100.100002, "CityB", "ProvA")
f.p3 = mkPlace(3, 89.900003, 100.100003, "CityC", "ProvB")
var actID int64
if err := db.QueryRowContext(ctx, `INSERT INTO activities (dataset_id, src_pk, name)
VALUES ($1,1,'家') RETURNING id`, ds).Scan(&actID); err != nil {
t.Fatalf("建活动失败: %v", err)
}
mkVisit := func(pk int, placeID int64, iso string, dur int) {
t.Helper()
if _, err := db.ExecContext(ctx, `INSERT INTO visits
(dataset_id, src_pk, place_id, activity_id, arrival, duration_min)
VALUES ($1,$2,$3,$4,$5,$6)`, ds, pk, placeID, actID, at(iso), dur); err != nil {
t.Fatalf("建到访失败: %v", err)
}
}
mkVisit(101, f.p1, "2026-01-05T10:00:00+08:00", 60)
mkVisit(102, f.p1, "2026-01-06T10:00:00+08:00", 30)
mkVisit(103, f.p2, "2026-02-10T10:00:00+08:00", 20)
mkVisit(104, f.p3, "2026-02-11T10:00:00+08:00", 10)
mkMove := func(pk, from, to int, placeID int64, km float64, iso string) {
t.Helper()
if _, err := db.ExecContext(ctx, `INSERT INTO movements
(dataset_id, src_pk, from_visit_src, to_visit_src, from_place_id, to_place_id,
started_at, ended_at, distance_km)
VALUES ($1,$2,$3,$4,$5,$5,$6,$6,$7)`, ds, pk, from, to, placeID, at(iso), km); err != nil {
t.Fatalf("建位移失败: %v", err)
}
}
// m1 跨 p1→p2(两端都在 S 内)· m2 跨 p3→p1(任一端在 S 内就该算)· m3 两端都在 p3(该被排除)
mkMove(201, 101, 103, f.p1, 5.0, "2026-01-07T10:00:00+08:00")
mkMove(202, 104, 101, f.p1, 7.0, "2026-02-12T10:00:00+08:00")
mkMove(203, 104, 104, f.p3, 11.0, "2026-02-13T10:00:00+08:00")
mkWeather := func(pk, visitSrc int, iso, cond string) {
t.Helper()
if _, err := db.ExecContext(ctx, `INSERT INTO weather
(dataset_id, src_pk, visit_src, at, condition) VALUES ($1,$2,$3,$4,$5)`,
ds, pk, visitSrc, at(iso), cond); err != nil {
t.Fatalf("建天气失败: %v", err)
}
}
mkWeather(301, 101, "2026-01-05T10:00:00+08:00", "Clear")
mkWeather(302, 104, "2026-02-11T10:00:00+08:00", "Cloudy")
// 只给 p1 / p2 打「推荐」。键用 Go 侧的 %.6f —— 顺便验证它与 SQL 的
// to_char(...,'FM999990.000000') 逐位一致(不一致这里就查不到,断言会红)。
mark := func(placeID int64, lat, lon float64, verdict int) {
t.Helper()
key := fmt.Sprintf("%.6f|%.6f", lat, lon)
if _, err := db.ExecContext(ctx, `INSERT INTO place_votes
(user_id, place_key, dataset_id, src_pk, verdict) VALUES ($1,$2,$3,1,$4)`,
uid, key, ds, verdict); err != nil {
t.Fatalf("写 verdict(键 %s)失败: %v", key, err)
}
}
mark(f.p1, 89.900001, 100.100001, 1)
mark(f.p2, 89.900002, 100.100002, 1)
return f
}
func (f *verdictFixture) filter(v int) stats.Filter {
return stats.Filter{DatasetID: f.ds, Verdict: v}
}
func sumBuckets(bs []stats.Bucket) int {
n := 0
for _, b := range bs {
n += b.Count
}
return n
}
// D7 回归:verdict 必须作用到**每一个**统计口径上。
//
// 用户已定口径「全域一致」——筛选条上看得见的这枚条件,页面上所有数字都得跟着变,
// 否则就是「点没了、里程还在」这种自相矛盾的视图(P1-3 是同一类问题的另一个面)。
//
// 判据用**不可能存在的结论值 99**:没有任何地点会被它匹配,于是**每一个**统计都必须退回
// 空集。任何一处漏了下推(地图点、城市/省份、类型计数、概览、里程/出行、天气、时间分布)
// 都会在这一条上暴露成一个非零数——比逐个统计写断言更彻底。
func TestVerdictScopesEveryStatistic(t *testing.T) {
f := newVerdictFixture(t)
ctx := context.Background()
d := stats.DB{DB: f.db}
// 先确认夹具本身有数据(否则「为空」是废话)
all, err := d.Overview(ctx, f.filter(0))
if err != nil {
t.Fatalf("Overview(无筛选) 失败: %v", err)
}
if all.Visits != 4 || all.Places != 3 || all.Movements != 3 {
t.Fatalf("夹具数据不对:visits=%d places=%d movements=%d,期望 4/3/3", all.Visits, all.Places, all.Movements)
}
// ① 不可能存在的结论 ⇒ 全域都该是空的
none := f.filter(99)
ov, err := d.Overview(ctx, none)
if err != nil {
t.Fatal(err)
}
if ov != (stats.Overview{}) {
t.Errorf("Verdict=99 时 Overview 应当全零,实际 %+v", ov)
}
places, total, err := d.Places(ctx, none, "visits", 100, 0)
if err != nil {
t.Fatal(err)
}
if len(places) != 0 || total != 0 {
t.Errorf("Verdict=99 时地点列表应当为空,实际 %d 条(total=%d)", len(places), total)
}
mapped, err := d.MapPlaces(ctx, none, 100)
if err != nil {
t.Fatal(err)
}
if len(mapped) != 0 {
t.Errorf("Verdict=99 时地图点应当为空,实际 %d 个", len(mapped))
}
if rows, n, err := d.Visits(ctx, none, 100, 0, "desc"); err != nil || len(rows) != 0 || n != 0 {
t.Errorf("Verdict=99 时到访列表应当为空,实际 %d 条(total=%d, err=%v)", len(rows), n, err)
}
if as, err := d.ActivityStats(ctx, none); err != nil || len(as) != 0 {
t.Errorf("Verdict=99 时类型分布应当为空,实际 %d 组(err=%v)", len(as), err)
}
if ac, err := d.ActivityCounts(ctx, none); err != nil || len(ac) != 0 {
t.Errorf("Verdict=99 时类型计数应当为空,实际 %d 项(err=%v)", len(ac), err)
}
if cs, err := d.CityStats(ctx, none, 10); err != nil || len(cs) != 0 {
t.Errorf("Verdict=99 时城市分布应当为空,实际 %d 组(err=%v)", len(cs), err)
}
if ps, err := d.ProvinceStats(ctx, none); err != nil || len(ps) != 0 {
t.Errorf("Verdict=99 时省份分布应当为空,实际 %d 组(err=%v)", len(ps), err)
}
if ws, err := d.WeatherStats(ctx, none); err != nil || len(ws) != 0 {
t.Errorf("Verdict=99 时天气汇总应当为空,实际 %d 组(err=%v)", len(ws), err)
}
if ms, err := d.Monthly(ctx, none); err != nil || sumBuckets(ms) != 0 {
t.Errorf("Verdict=99 时月度分布应当为空,实际 %d 次(err=%v)", sumBuckets(ms), err)
}
if cal, err := d.Calendar(ctx, none); err != nil || len(cal) != 0 {
t.Errorf("Verdict=99 时日历应当为空,实际 %d 天(err=%v)", len(cal), err)
}
if bs, err := d.Hourly(ctx, none); err != nil || sumBuckets(bs) != 0 {
t.Errorf("Verdict=99 时小时分布应当为空,实际 %d 次(err=%v)", sumBuckets(bs), err)
}
if bs, err := d.Weekday(ctx, none); err != nil || sumBuckets(bs) != 0 {
t.Errorf("Verdict=99 时星期分布应当为空,实际 %d 次(err=%v)", sumBuckets(bs), err)
}
if ts, err := d.TransportStats(ctx, none); err != nil || len(ts) != 0 {
t.Errorf("Verdict=99 时出行方式应当为空,实际 %d 组(err=%v)", len(ts), err)
}
}
// D7 正向:只给 p1/p2 打「推荐」时,每个口径都必须**恰好**收窄到它们名下的数据。
// 上一条证明「不会漏下推」,这一条证明「下推之后选对了」。
func TestVerdictNarrowsEveryStatisticToMarkedPlaces(t *testing.T) {
f := newVerdictFixture(t)
ctx := context.Background()
d := stats.DB{DB: f.db}
on := f.filter(1)
// S = {p1, p2}:3 条到访(60+30+20 分钟)、2 个地点、2 个城市、1 个省、3 个不同日期
ov, err := d.Overview(ctx, on)
if err != nil {
t.Fatal(err)
}
if ov.Visits != 3 || ov.Places != 2 || ov.Cities != 2 || ov.Provinces != 1 || ov.Days != 3 {
t.Errorf("Overview 收窄不对:visits=%d places=%d cities=%d provinces=%d days=%d,期望 3/2/2/1/3",
ov.Visits, ov.Places, ov.Cities, ov.Provinces, ov.Days)
}
if ov.DwellMin != 110 {
t.Errorf("停留 %d 分钟,期望 110", ov.DwellMin)
}
// 位移按「任一端在 S 内」:m1(p1→p2) 与 m2(p3→p1) 算,两端都在 p3 的 m3 不算
if ov.Movements != 2 || ov.DistanceKm < 11.99 || ov.DistanceKm > 12.01 {
t.Errorf("位移收窄不对:movements=%d distance=%.2f,期望 2 / 12.00", ov.Movements, ov.DistanceKm)
}
places, total, err := d.Places(ctx, on, "visits", 100, 0)
if err != nil {
t.Fatal(err)
}
if total != 2 || len(places) != 2 {
t.Errorf("地点列表应当只剩 2 个,实际 %d 条(total=%d)", len(places), total)
}
got := map[int64]bool{}
for _, p := range places {
got[p.ID] = true
}
if !got[f.p1] || !got[f.p2] || got[f.p3] {
t.Errorf("地点列表选错:%v(应当只有 p1/p2)", got)
}
mapped, err := d.MapPlaces(ctx, on, 100)
if err != nil {
t.Fatal(err)
}
if len(mapped) != 2 {
t.Errorf("地图点应当只剩 2 个,实际 %d 个(这一处以前完全没认 verdict)", len(mapped))
}
// 每个「按到访计数」的口径都必须与 Overview.Visits 对得上
if ac, err := d.ActivityCounts(ctx, on); err != nil {
t.Fatal(err)
} else {
n := 0
for _, c := range ac {
n += c
}
if n != ov.Visits {
t.Errorf("类型计数合计 %d,与到访数 %d 不符", n, ov.Visits)
}
}
if as, err := d.ActivityStats(ctx, on); err != nil {
t.Fatal(err)
} else {
n := 0
for _, a := range as {
n += a.Count
}
if n != ov.Visits {
t.Errorf("类型分布合计 %d,与到访数 %d 不符", n, ov.Visits)
}
}
if cs, err := d.CityStats(ctx, on, 10); err != nil {
t.Fatal(err)
} else {
n := 0
for _, c := range cs {
n += c.Count
}
if n != ov.Visits || len(cs) != 2 {
t.Errorf("城市分布应当 2 组共 %d 次,实际 %d 组共 %d 次", ov.Visits, len(cs), n)
}
}
if ps, err := d.ProvinceStats(ctx, on); err != nil {
t.Fatal(err)
} else {
n := 0
for _, p := range ps {
n += p.Count
}
if n != ov.Visits || len(ps) != 1 || ps[0].Province != "ProvA" {
t.Errorf("省份分布应当只有 ProvA 共 %d 次,实际 %d 组共 %d 次", ov.Visits, len(ps), n)
}
}
if ms, err := d.Monthly(ctx, on); err != nil || sumBuckets(ms) != ov.Visits {
t.Errorf("月度分布合计 %d,与到访数 %d 不符(err=%v)", sumBuckets(ms), ov.Visits, err)
}
if bs, err := d.Hourly(ctx, on); err != nil || sumBuckets(bs) != ov.Visits {
t.Errorf("小时分布合计不对(err=%v)", err)
}
if bs, err := d.Weekday(ctx, on); err != nil || sumBuckets(bs) != ov.Visits {
t.Errorf("星期分布合计不对(err=%v)", err)
}
// 天气挂在到访上:只有 p1 那条该留下
if ws, err := d.WeatherStats(ctx, on); err != nil {
t.Fatal(err)
} else {
n := 0
for _, w := range ws {
n += w.Count
}
if n != 1 {
t.Errorf("天气应当只剩 1 条(挂在 p1 上那条),实际 %d 条", n)
}
}
// 到访列表也要跟着收窄
if rows, n, err := d.Visits(ctx, on, 100, 0, "desc"); err != nil || len(rows) != 3 || n != 3 {
t.Errorf("到访列表应当只剩 3 条,实际 %d 条(total=%d, err=%v)", len(rows), n, err)
}
}