package business import ( "context" "kra/internal/biz/business" "testing" "time" ) func TestReportBuildStorageReplacesBothTablesAtomically(t *testing.T) { db := openTestDB(t) ctx := context.Background() day := time.Date(2026, 11, 4, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) for _, sql := range []string{ "CREATE TABLE tab_user(id INTEGER,source_db TEXT,puid INTEGER,promote_id INTEGER,fgame_id INTEGER,idcard TEXT,register_time TEXT)", "CREATE TABLE tab_user_day_login(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,login_time INTEGER)", "CREATE TABLE tab_user_play_info(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,create_time INTEGER,play_time INTEGER)", "CREATE TABLE tab_spend(user_id INTEGER,source_db TEXT,promote_id INTEGER,game_id INTEGER,server_id TEXT,pay_time INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))", "CREATE TABLE tab_game(id INTEGER,source_db TEXT,game_name TEXT)", "INSERT INTO tab_game VALUES(1,'lx','当前游戏')", "CREATE TABLE tab_sw_report_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,new_pay_cnt INTEGER,total_pay DECIMAL(20,2))", "CREATE TABLE tab_sw_report_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)", "CREATE TABLE tab_sw_report_server_day(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,new_register_cnt INTEGER,active_cnt INTEGER,pay_cnt INTEGER,total_pay DECIMAL(20,2))", "CREATE TABLE tab_sw_report_server_day_key(id INTEGER PRIMARY KEY,stat_date DATE,source_db TEXT,promote_id INTEGER,game_id INTEGER,game_name TEXT,server_num TEXT,dedup_mode INTEGER,metric INTEGER,identity_key TEXT)", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } for _, sql := range []string{ "INSERT INTO tab_user VALUES(1,'lx',0,10,1,'cardX',?),(2,'lx',1,10,1,'sub',?),(3,'lx',NULL,10,1,'null',?)", } { if err := db.Exec(sql, day.Unix(), day.Unix(), day.Unix()).Error; err != nil { t.Fatal(err) } } for _, sql := range []string{ "INSERT INTO tab_user_day_login VALUES(1,'lx',NULL,1,?),(2,'lx',10,1,?),(3,'lx',10,1,?)", "INSERT INTO tab_spend VALUES(1,'lx',10,1,'001',?,1,10),(2,'lx',10,1,'001',?,1,20),(3,'lx',10,1,'001',?,0,999)", } { if err := db.Exec(sql, day.Unix(), day.Unix(), day.Unix()).Error; err != nil { t.Fatal(err) } } if err := db.Exec("INSERT INTO tab_user_play_info VALUES(2,'lx',10,1,' 001 ',?,?)", day.Unix(), day.Unix()).Error; err != nil { t.Fatal(err) } uc := business.NewReportBuildUsecase(NewReportBuildRepo(testData{db})) run := func(kind business.ReportBuildKind) error { return uc.Run(ctx, kind, "backfill", "2026-11-04", "2026-11-04") } if err := run(business.DailyReport); err != nil { t.Fatal(err) } var counts []struct { DedupMode, NewRegisterCnt, ActiveCnt, PayCnt, NewPayCnt int TotalPay string GameName string } if err := db.Table("tab_sw_report_day").Order("dedup_mode").Scan(&counts).Error; err != nil { t.Fatal(err) } if len(counts) != 2 || counts[0].NewRegisterCnt != 1 || counts[0].ActiveCnt != 1 || counts[0].PayCnt != 2 || counts[0].NewPayCnt != 1 || portraitTestMoney(counts[0].TotalPay) != "30.00" || counts[0].GameName != "当前游戏" { t.Fatalf("日报主账号/付费/金额规则错误: %+v", counts) } if err := run(business.ServerReport); err != nil { t.Fatal(err) } var server struct { NewRegisterCnt, ActiveCnt, PayCnt int ServerNum string } if err := db.Table("tab_sw_report_server_day").Where("dedup_mode=0").Take(&server).Error; err != nil { t.Fatal(err) } if server.NewRegisterCnt != 1 || server.ActiveCnt != 1 || server.PayCnt != 2 || server.ServerNum != "001" { t.Fatalf("区服创角应包含子账号: %+v", server) } if err := run(business.DailyReport); err != nil { t.Fatal(err) } var before int64 if err := db.Table("tab_sw_report_day").Where("stat_date=?", "2026-11-04").Count(&before).Error; err != nil || before != 2 { t.Fatal("回跑日期必须保持自然日格式且不能重复累积行") } db.Table("tab_sw_report_day_key").Count(&before) if err := db.Exec("CREATE TRIGGER reject_report_key BEFORE INSERT ON tab_sw_report_day_key BEGIN SELECT RAISE(ABORT,'测试写入失败'); END").Error; err != nil { t.Fatal(err) } if err := db.Exec("UPDATE tab_spend SET pay_amount=100 WHERE user_id=1").Error; err != nil { t.Fatal(err) } if err := run(business.DailyReport); err == nil { t.Fatal("身份键写入失败必须回滚") } var after int64 db.Table("tab_sw_report_day_key").Count(&after) var amount string db.Table("tab_sw_report_day").Select("total_pay").Where("dedup_mode=0").Scan(&amount) if before != after || portraitTestMoney(amount) != "30.00" { t.Fatal("失败后丢失原身份键或覆盖原汇总") } } func TestReportUsersRestrictKnownPlatformsAndKeepAmbiguousMatches(t *testing.T) { db := openTestDB(t) if err := db.Exec("CREATE TABLE tab_user(id INTEGER,source_db TEXT,puid INTEGER,promote_id INTEGER,idcard TEXT,PRIMARY KEY(id,source_db))").Error; err != nil { t.Fatal(err) } if err := db.Exec("INSERT INTO tab_user VALUES(1,'lx',0,10,'known'),(1,'hw',0,20,'unrelated'),(2,'lx',0,10,'ambiguous-a'),(2,'hw',0,20,'ambiguous-b'),(3,'yq',0,30,'unique')").Error; err != nil { t.Fatal(err) } known := map[business.ReportUserKey]bool{{SourceDB: "lx", ID: 1}: true, {SourceDB: "lx", ID: 2}: true} users, err := loadReportUsers(db, known, map[int64]bool{2: true, 3: true}) if err != nil { t.Fatal(err) } if len(users) != 4 { t.Fatalf("wrong user scope: %+v", users) } if _, ok := users[business.ReportUserKey{SourceDB: "hw", ID: 1}]; ok { t.Fatal("known source loaded another platform") } for _, key := range []business.ReportUserKey{{SourceDB: "lx", ID: 1}, {SourceDB: "lx", ID: 2}, {SourceDB: "hw", ID: 2}, {SourceDB: "yq", ID: 3}} { if _, ok := users[key]; !ok { t.Fatalf("lost identity needed for report/ambiguity: %+v", key) } } } func TestReportUsersAcrossBatches(t *testing.T) { db := openTestDB(t) if err := db.Exec("CREATE TABLE tab_user(id INTEGER,source_db TEXT,puid INTEGER,promote_id INTEGER,idcard TEXT,PRIMARY KEY(id,source_db))").Error; err != nil { t.Fatal(err) } known := map[business.ReportUserKey]bool{} unknown := map[int64]bool{} rows := make([]map[string]any, 0, 2004) for id := int64(1); id <= 1002; id++ { for _, source := range []string{"lx", "hw"} { rows = append(rows, map[string]any{"id": id, "source_db": source, "puid": 0, "promote_id": id, "idcard": source}) } known[business.ReportUserKey{SourceDB: "lx", ID: id}] = true if id > 501 { unknown[id] = true } } // 缺失用户不能被同号的其他平台用户替代。 known[business.ReportUserKey{SourceDB: "yq", ID: 1}] = true if err := db.Table("tab_user").CreateInBatches(rows, 100).Error; err != nil { t.Fatal(err) } users, err := loadReportUsers(db, known, unknown) if err != nil { t.Fatal(err) } if len(users) != 1503 { t.Fatalf("分批读取用户数量错误: %d", len(users)) } for id := int64(1); id <= 1002; id++ { user, ok := users[business.ReportUserKey{SourceDB: "lx", ID: id}] if !ok || user.PromoteID == nil || *user.PromoteID != id || user.IDCard != "lx" { t.Fatalf("跨批丢失或混淆用户资料: id=%d user=%+v", id, user) } _, other := users[business.ReportUserKey{SourceDB: "hw", ID: id}] if other != unknown[id] { t.Fatalf("跨批用户平台范围错误: id=%d", id) } } if _, exists := users[business.ReportUserKey{SourceDB: "yq", ID: 1}]; exists { t.Fatal("缺失平台用户不能回退到同号账号") } }