package store import ( "testing" "time" "github.com/shopspring/decimal" ) // v10 回填:存量 usage_logs 按 (channel_id, 小时) 聚合进桶;标记幂等(二次执行不重复累计)。 func TestBackfillChannelHourly(t *testing.T) { db, err := Open("sqlite", "/tmp/store_backfill_test_"+time.Now().Format("150405.000000000")+".db") if err != nil { t.Fatal(err) } t.Cleanup(func() { sqlDB, _ := db.DB() sqlDB.Close() }) // Open 已跑过一次空回填并打标;清掉标记与空桶,注入历史明细后重放 db.Where("key = ?", "channel_hourly_backfill_v10").Delete(&SystemConfig{}) db.Exec("DELETE FROM usage_channel_hourlies") at := time.Date(2026, 11, 9, 14, 30, 0, 0, time.UTC) atPrev := at.Add(-2 * time.Hour) rows := []UsageLog{ {TraceID: "b1", UserID: 1, ChannelID: 5, Status: UsageStatusSuccess, LatencyMS: 300, InputTokens: 10, OutputTokens: 5, Cost: decimal.NewFromFloat(0.01), CreatedAt: at}, {TraceID: "b2", UserID: 1, ChannelID: 5, Status: UsageStatusError, ErrorCode: strp("upstream_http_502"), CreatedAt: at}, {TraceID: "b3", UserID: 1, ChannelID: 5, Status: UsageStatusSuccess, LatencyMS: 100, InputTokens: 20, OutputTokens: 10, Cost: decimal.NewFromFloat(0.02), CreatedAt: atPrev}, {TraceID: "b4", UserID: 1, ChannelID: 0, Status: UsageStatusSuccess, CreatedAt: at}, // channel 0 不入桶 } if err := db.Create(&rows).Error; err != nil { t.Fatal(err) } backfillChannelHourly(db) var buckets []UsageChannelHourly db.Where("channel_id = ?", 5).Order("hour DESC").Find(&buckets) if len(buckets) != 2 { t.Fatalf("应有两个小时桶,实际 %d", len(buckets)) } cur, prev := buckets[0], buckets[1] if cur.Requests != 2 || cur.Success != 1 || cur.Errors != 1 || cur.LatencyMax != 300 { t.Fatalf("当前桶 = %+v", cur) } if prev.Requests != 1 || prev.Cost.String() != "0.02" { t.Fatalf("上一桶 = %+v", prev) } // 幂等:清标记重放,计数不应翻倍 db.Where("key = ?", "channel_hourly_backfill_v10").Delete(&SystemConfig{}) backfillChannelHourly(db) var total int64 db.Model(&UsageChannelHourly{}).Where("channel_id = ?", 5).Select("COALESCE(SUM(requests),0)").Scan(&total) if total != 3 { t.Fatalf("回填应幂等,requests 总计 = %d,期望 3", total) } } func strp(s string) *string { return &s }