package business import ( "context" "math/big" "testing" "time" "gorm.io/gorm" "kra/internal/biz/business" ) func preparePortraitData(t *testing.T) (*gorm.DB, business.BusinessPortraitRepo, business.PortraitReportScope) { t.Helper() db, _ := prepareChannels(t) zone := time.FixedZone("CST", 8*3600) start := time.Date(2026, 11, 3, 0, 0, 0, 0, zone) for _, sql := range []string{ "UPDATE tab_sw_report_day_key SET identity_key='A' WHERE source_db='hw' AND metric=1", "INSERT INTO tab_sw_report_day_key VALUES('hw',100,1,1,'2026-11-01',2,'C'),('lx',100,1,1,'2026-11-01',3,'A'),('hw',100,1,1,'2026-11-01',3,'A')", "INSERT INTO tab_sw_report_day_key VALUES('hw',100,1,0,'2026-11-01',1,'u1')", "ALTER TABLE tab_user ADD COLUMN register_time INTEGER", "ALTER TABLE tab_spend ADD COLUMN pay_time INTEGER", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } if err := db.Exec("UPDATE tab_user SET register_time = ?", start.Unix()+3600).Error; err != nil { t.Fatal(err) } if err := db.Exec("UPDATE tab_spend SET pay_time = ?", start.Unix()+3600).Error; err != nil { t.Fatal(err) } scope := business.PortraitReportScope{Pairs: []business.PlatformKey{{SourceDB: "lx", BusierID: 14}, {SourceDB: "hw", BusierID: 14}}, Sources: []string{"lx", "hw"}, IncludeSub: true, IDCardDedup: true, RankLimit: 5, Range: business.PortraitRange{Begin: time.Date(2026, 11, 1, 0, 0, 0, 0, zone), End: start, PreviousBegin: time.Date(2026, 10, 1, 0, 0, 0, 0, zone), PreviousEnd: time.Date(2026, 10, 3, 0, 0, 0, 0, zone)}} return db, NewBusinessPortraitRepo(testData{db}, channelDatabases{}), scope } func portraitTestMoney(s string) string { n, ok := new(big.Rat).SetString(s) if !ok { return "invalid" } return n.FloatString(2) } func TestPortraitBindingsAndChannelsFollowBusinessTree(t *testing.T) { _, repo, _ := preparePortraitData(t) ctx := context.Background() bindings, err := repo.Bindings(ctx, channelTestScope(), []string{"lx", "hw"}, nil) if err != nil || len(bindings) != 2 { t.Fatalf("本人及下级绑定范围错误: %+v %v", bindings, err) } for _, b := range bindings { if b.UserID == 10 || b.Status != "0" { t.Fatal("混入平级或未通过绑定") } } pairs := []business.PlatformKey{} for _, b := range bindings { pairs = append(pairs, b.PlatformKey) } channels, available, err := repo.Channels(ctx, pairs, true) if err != nil || !available || len(channels) != 5 { t.Fatalf("渠道范围不正确: %+v %v", channels, err) } channels, _, err = repo.Channels(ctx, pairs, false) if err != nil || len(channels) != 2 { t.Fatal("关闭下级时只能取一级渠道") } outside := business.PlatformKey{SourceDB: "lx", BusierID: 16} bindings, err = repo.Bindings(ctx, channelTestScope(), []string{"lx"}, &outside) if err != nil || len(bindings) != 0 { t.Fatal("绑定过滤绕过商务树") } } func TestPortraitSummaryDedupsAcrossPlatformsAndRanksWithinTrees(t *testing.T) { _, repo, scope := preparePortraitData(t) stats, err := repo.Statistics(context.Background(), scope) if err != nil { t.Fatal(err) } if stats.Current.Register != 3 || stats.Current.Active != 1 || stats.Current.Pay != 1 || portraitTestMoney(stats.CurrentPay) != "2058.60" || portraitTestMoney(stats.PreviousPay) != "1.10" { t.Fatalf("全局去重或金额口径错误: %+v", stats) } if stats.RegisterSeries["2026-11-01"] != "1" || stats.RegisterSeries["2026-11-02"] != "3" { t.Fatalf("日趋势错误: %+v", stats.RegisterSeries) } var lxRoot, hwRoot string for _, rank := range stats.RegisterRanks { if rank.ID == 100 && rank.SourceDB == "lx" { lxRoot = rank.Value } if rank.ID == 100 && rank.SourceDB == "hw" { hwRoot = rank.Value } } if lxRoot != "2" || hwRoot != "1" { t.Fatalf("渠道树注册排行不能直接累加子渠道或混淆同号平台: %+v", stats.RegisterRanks) } scope.IDCardDedup = false stats, err = repo.Statistics(context.Background(), scope) if err != nil || stats.Current.Register != 3 || portraitTestMoney(stats.CurrentPay) != "2058.60" { t.Fatalf("模式0没有区分跨平台同号身份或改变金额: %+v %v", stats, err) } scope.IDCardDedup = true scope.IncludeSub = false stats, err = repo.Statistics(context.Background(), scope) if err != nil || stats.Current.Register != 1 || portraitTestMoney(stats.CurrentPay) != "1009.10" { t.Fatalf("关闭下级没有收窄: %+v %v", stats, err) } } func TestPortraitHourlyUsesRawMainAccountsAndHalfOpenDay(t *testing.T) { db, repo, scope := preparePortraitData(t) scope.Range.Begin = scope.Range.End scope.Range.Hourly = true stats, err := repo.Statistics(context.Background(), scope) if err != nil || stats.RegisterSeries["1"] != "5" || portraitTestMoney(stats.PaySeries["1"]) != "200.90" { t.Fatalf("小时趋势错误: %+v %v", stats, err) } end := scope.Range.End.AddDate(0, 0, 1).Unix() if err := db.Exec("INSERT INTO tab_user(source_db,id,promote_id,puid,idcard,register_time) VALUES('lx',900,100,0,'boundary',?)", end).Error; err != nil { t.Fatal(err) } stats, err = repo.Statistics(context.Background(), scope) if err != nil || stats.RegisterSeries["0"] != "" || stats.RegisterSeries["1"] != "5" { t.Fatal("次日零点被计入当日") } if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil { t.Fatal(err) } stats, err = repo.Statistics(context.Background(), scope) if err != nil || stats.Available { t.Fatal("缺汇总键表不应返回假零指标") } channels, available, err := repo.Channels(context.Background(), nil, true) if err != nil || !available || len(channels) != 0 { t.Fatal("无绑定应是已知空范围") } } func TestPortraitCountLimitAppliesBeforeTopN(t *testing.T) { db, repo, scope := preparePortraitData(t) if err := db.Exec("INSERT INTO tab_sw_report_day_key VALUES('lx',103,1,1,'2026-11-02',1,'N2'),('lx',103,1,1,'2026-11-02',1,'N3')").Error; err != nil { t.Fatal(err) } scope.CountLimit = 1 scope.RankLimit = 1 scope.Sources = []string{"lx"} stats, err := repo.Statistics(context.Background(), scope) if err != nil || len(stats.RegisterRanks) != 1 || stats.RegisterRanks[0].ID != 100 || stats.RegisterRanks[0].Value != "1" { t.Fatalf("人数归一上限必须先于排行截取: %+v %v", stats, err) } }