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