package business import ( "context" "strings" "sync/atomic" "testing" "time" "gorm.io/gorm" "kra/internal/biz/business" ) func prepareAnalyticsData(t *testing.T) (*gorm.DB, business.AnalyticsRepo, business.AnalyticsScope, business.AnalyticsFilter) { t.Helper() db, _ := prepareChannels(t) for _, sql := range []string{ "INSERT INTO tab_promote_apply VALUES('hw',100,1,1)", "ALTER TABLE tab_sw_report_day ADD COLUMN game_name TEXT", "ALTER TABLE tab_sw_report_day_key ADD COLUMN game_name TEXT", "UPDATE tab_sw_report_day SET game_name=CASE WHEN game_id=1 THEN '历史游戏甲' ELSE '游戏乙' END", "UPDATE tab_sw_report_day_key SET game_name=CASE WHEN game_id=1 THEN '历史游戏甲' ELSE '游戏乙' END", "UPDATE tab_sw_report_day_key SET identity_key='A' WHERE (source_db='hw' OR promote_id=103) AND metric=1", "INSERT INTO tab_sw_report_day(source_db,promote_id,game_id,dedup_mode,stat_date,total_pay,game_name) VALUES('lx',0,1,0,'2026-11-01',5,'历史游戏甲'),('lx',999,1,0,'2026-11-01',7,'历史游戏甲')", "INSERT INTO tab_sw_report_day_key(source_db,promote_id,game_id,dedup_mode,stat_date,metric,identity_key,game_name) VALUES('lx',0,1,1,'2026-11-01',1,'official','历史游戏甲'),('lx',999,1,1,'2026-11-01',1,'orphan','历史游戏甲'),('lx',100,1,1,'2026-11-01',2,'A','历史游戏甲'),('lx',103,1,1,'2026-11-02',2,'A','历史游戏甲'),('hw',100,1,1,'2026-11-01',2,'A','历史游戏甲'),('lx',100,1,1,'2026-11-01',3,'A','历史游戏甲'),('lx',103,1,1,'2026-11-02',3,'A','历史游戏甲'),('hw',100,1,1,'2026-11-01',3,'A','历史游戏甲')", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } scope := business.AnalyticsScope{Viewer: channelTestScope(), IncludeSub: true, IDCardDedup: true} filter := business.AnalyticsFilter{Sources: []string{"lx", "hw"}, Begin: "2026-11-01", End: "2026-11-03"} return db, NewAnalyticsRepo(testData{db}), scope, filter } func TestAnalyticsChannelTreeAndAdminOfficialRows(t *testing.T) { _, repo, scope, filter := prepareAnalyticsData(t) data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil { t.Fatal(err) } if err := repo.FillNames(context.Background(), business.ChannelAnalytics, scope, filter, data.Rows); err != nil { t.Fatal(err) } if len(data.Rows) != 3 || data.GlobalCounts != nil { t.Fatalf("普通商务统计范围错误: %+v", data) } var lx *business.AnalyticsRow for _, row := range data.Rows { if row.SourceDB == "lx" && row.ID == 100 { lx = row } if row.ID == 0 || row.ID == 999 { t.Fatal("受约束商务看到官方/未知渠道") } } if lx == nil || lx.Counts.Register != 2 || portraitTestMoney(lx.Pay) != "60.60" || lx.Name != "root-channel" || lx.BusinessName() != "lx-business" { t.Fatalf("渠道树去重、充值或商务展示错误: %+v", lx) } scope.Viewer = business.UserScope{All: true} data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) != 5 || data.GlobalCounts.Register != 4 || data.GlobalCounts.Active != 2 || data.GlobalCounts.Pay != 1 || portraitTestMoney(data.GlobalPay) != "2070.60" { t.Fatalf("超管全局去重或官方/历史渠道遗漏: %+v %v", data, err) } } func TestAnalyticsMetricFiltersPreserveGlobalDedupAndEffectiveCount(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) scope.Viewer = business.UserScope{All: true} yes, no := true, false filter.HasRegister, filter.HasPay = &yes, &yes // The same real identity occurs under different root channels/platforms. // All rows match, so the filtered total must keep the original global count. data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) != 5 || data.GlobalCounts.Register != 4 || portraitTestMoney(data.GlobalPay) != "2070.60" { t.Fatalf("filtered summary lost global deduplication: %+v %v", data, err) } if err := db.Exec("UPDATE tab_sw_report_day SET total_pay=0 WHERE source_db='lx' AND promote_id IN (100,101,102)").Error; err != nil { t.Fatal(err) } filter.HasPay = &no data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) != 1 || data.Rows[0].ID != 100 || portraitTestMoney(data.GlobalPay) != "0.00" || data.GlobalCounts.Register != 2 { t.Fatalf("no-payment filter must keep the whole root scope and matching totals: %+v %v", data, err) } filter.HasRegister = &no data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) != 0 || data.GlobalCounts.Register != 0 || data.EffectiveCount != 0 { t.Fatalf("combined filters must use AND and return empty totals: %+v %v", data, err) } } func TestAnalyticsNegativeFiltersIncludeInactiveAuthorizedChannels(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) for _, sql := range []string{ "INSERT INTO tab_promote(id,source_db,account,promote_level,parent_id,top_promote_id,busier_id) VALUES(200,'lx','inactive',1,0,0,14),(201,'lx','invisible',1,0,0,999),(202,'lx','inactive-child',2,200,200,14)", "INSERT INTO tab_promote_apply(source_db,promote_id,game_id,status) VALUES('lx',202,1,1)", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } no := false filter.HasRegister, filter.HasPay = &no, &no filter.Sources = []string{"lx"} for _, games := range [][]string{nil, {"当前游戏甲"}} { filter.GameNames = games // Use the actual game name; the report's saved historical name can differ. if len(games) > 0 { if err := db.Table("tab_game").Where("source_db='lx' AND id=1").Pluck("game_name", &filter.GameNames).Error; err != nil { t.Fatal(err) } } data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil { t.Fatal(err) } found := false for _, row := range data.Rows { if row.ID == 201 || row.ID == 202 { t.Fatalf("permission or tree aggregation leak: %+v", row) } if row.ID == 200 { found = true if row.Counts.Register != 0 || row.Pay != "0" { t.Fatalf("inactive metrics: %+v", row) } } } if !found { t.Fatalf("inactive authorized channel missing with games %v", filter.GameNames) } } } func TestAnalyticsInactiveCatalogDoesNotExpandSummaryParameters(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) rows := make([]map[string]any, 2000) for i := range rows { rows[i] = map[string]any{"id": 1000 + i, "source_db": "lx", "account": "inactive", "promote_level": 1, "parent_id": 0, "top_promote_id": 0, "busier_id": 14} } if err := db.Table("tab_promote").CreateInBatches(rows, 200).Error; err != nil { t.Fatal(err) } var excessive atomic.Bool if err := db.Callback().Row().After("gorm:row").Register("check_summary_parameters", func(tx *gorm.DB) { if strings.Contains(tx.Statement.SQL.String(), "COUNT(DISTINCT") && len(tx.Statement.Vars) > 100 { excessive.Store(true) } }); err != nil { t.Fatal(err) } scope.Viewer, scope.IncludeSub = business.UserScope{All: true}, false no := false filter.HasRegister, filter.HasPay = &no, &no data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) < len(rows) || excessive.Load() || data.GlobalCounts.Register != 0 || portraitTestMoney(data.GlobalPay) != "0.00" { t.Fatalf("inactive rows bloated the summary query or changed totals: rows=%d excessive=%v err=%v", len(data.Rows), excessive.Load(), err) } } func TestAnalyticsRowsOnlyComeFromSelectedPeriod(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) for _, sql := range []string{ "INSERT INTO tab_promote(id,source_db,account,promote_level,parent_id,top_promote_id,busier_id) VALUES(200,'lx','outside-period',1,0,0,14),(201,'lx','active-only',1,0,0,14),(202,'lx','never-active',1,0,0,14)", "INSERT INTO tab_sw_report_day(source_db,promote_id,game_id,dedup_mode,stat_date,total_pay,game_name) VALUES('lx',200,1,0,'2026-10-31',500,'历史游戏甲')", "INSERT INTO tab_sw_report_day_key(source_db,promote_id,game_id,dedup_mode,stat_date,metric,identity_key,game_name) VALUES('lx',201,1,1,'2026-11-02',2,'active-only','历史游戏甲')", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } for _, viewer := range []business.UserScope{scope.Viewer, {All: true}} { scope.Viewer = viewer baseline, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil { t.Fatal(err) } keys := map[business.AnalyticsKey]bool{} for _, row := range baseline.Rows { if row.ID == 200 || row.ID == 202 { t.Fatalf("默认报表不能补入区间外或完全无数据渠道: %+v", row) } keys[row.AnalyticsKey] = true } if !keys[business.AnalyticsKey{SourceDB: "lx", ID: 201}] { t.Fatal("仅有活跃、没有注册或充值的统计行不能遗漏") } if viewer.All && (baseline.GlobalCounts.Register != 4 || baseline.GlobalCounts.Active != 3 || portraitTestMoney(baseline.GlobalPay) != "2070.60") { t.Fatalf("区间外记录不能改变全量合计: %+v", baseline) } } filter.Sources, filter.PromoteName = []string{"lx"}, "root-channel" data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) != 1 || portraitTestMoney(data.GlobalPay) != "60.60" || data.GlobalCounts.Register != data.Rows[0].Counts.Register { t.Fatalf("平台、名称筛选须共同约束列表与全量合计: %+v %v", data, err) } filter = business.AnalyticsFilter{Sources: []string{"lx", "hw"}, Begin: "2027-01-01", End: "2027-01-02"} data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) != 0 || data.GlobalCounts.Register != 0 || portraitTestMoney(data.GlobalPay) != "0.00" { t.Fatalf("无报表记录的区间应返回空列表与零合计: %+v %v", data, err) } } func TestAnalyticsEffectiveCountIncludesQualifiedDescendants(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) qualified := time.Date(2026, 11, 2, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)).Unix() if err := db.Exec("UPDATE sys_business_effectiveness_state SET rule_key='audit' WHERE id=1").Error; err != nil { t.Fatal(err) } for _, key := range []business.ChannelKey{{SourceDB: "lx", ID: 100}, {SourceDB: "lx", ID: 101}, {SourceDB: "yq", ID: 100}} { if err := db.Create(&effectivenessPO{RuleKey: "audit", SourceDB: key.SourceDB, PromoteID: key.ID, QualifiedAt: &qualified, WindowPay: "3000"}).Error; err != nil { t.Fatal(err) } } filter.PromoteName = "root-channel" data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) != 1 || data.EffectiveCount != 2 { t.Fatalf("合并渠道树展示仍须计入下级达标,且不能混入权限外同号渠道: %+v %v", data, err) } scope.IncludeSub = false data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || data.EffectiveCount != 1 { t.Fatalf("关闭包含下级后只统计自身: %+v %v", data, err) } filter.Begin = "2026-11-03" data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || data.EffectiveCount != 0 { t.Fatalf("有效端必须按达标时间归属统计区间: %+v %v", data, err) } } func TestAnalyticsFiltersKeepHierarchyAndSnapshotNames(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) filter.PromoteName = "child" data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) != 1 || data.Rows[0].ID != 100 || data.Rows[0].Counts.Register != 2 || portraitTestMoney(data.Rows[0].Pay) != "60.60" { t.Fatalf("模糊渠道筛选应扩到同树可见渠道: %+v %v", data, err) } if err := db.Exec("UPDATE tab_promote SET register_type=1 WHERE source_db='lx' AND id=101").Error; err != nil { t.Fatal(err) } filter.PromoteName = "" kind := 1 filter.PromoteType = &kind data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil { t.Fatal(err) } if err := repo.FillNames(context.Background(), business.ChannelAnalytics, scope, filter, data.Rows); err != nil { t.Fatal(err) } if err != nil || len(data.Rows) != 1 || data.Rows[0].ID != 100 || portraitTestMoney(data.Rows[0].Pay) != "20.20" || data.Rows[0].PromoteType == nil || *data.Rows[0].PromoteType != 0 { t.Fatalf("类型筛选应限制参与渠道,同时显示归根后的当前资料: %+v %v", data, err) } filter.PromoteType = nil filter.GameNames = []string{"历史游戏甲"} data, err = repo.Aggregate(context.Background(), business.GameAnalytics, scope, filter) if err != nil || len(data.Rows) != 2 { t.Fatalf("按游戏快照名筛选错误: %+v %v", data, err) } if err := repo.FillNames(context.Background(), business.GameAnalytics, scope, filter, data.Rows); err != nil { t.Fatal(err) } for _, row := range data.Rows { if row.SourceDB == "lx" && (row.ID != 1 || row.Name != "游戏甲" || row.Counts.Register != 1) { t.Fatalf("应按报表快照筛选、用当前游戏名展示: %+v", row) } } filter.GameNames = []string{"游戏甲"} data, err = repo.Aggregate(context.Background(), business.GameAnalytics, scope, filter) if err != nil || len(data.Rows) != 0 { t.Fatal("不能用当前游戏表名替代报表快照过滤") } } func TestAnalyticsChannelSearchMatchesNicknameAndKeepsScope(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) if err := db.Exec("UPDATE tab_promote SET nickname='同名推广' WHERE id=100").Error; err != nil { t.Fatal(err) } scope.Viewer = business.UserScope{SelfID: 9, OnlySelf: true} filter.PromoteName = "推广" data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) != 1 || data.Rows[0].SourceDB != "lx" || data.Rows[0].ID != 100 { t.Fatalf("nickname search must remain scoped: %+v %v", data, err) } filter.PromoteName = "%" data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) != 0 { t.Fatal("wildcard keyword must not broaden the query") } } func TestGameAuthorizationFiltersAggregatedFactsByPlatform(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) scope.Viewer = business.UserScope{SelfID: 9, OnlySelf: true} for _, sql := range []string{ "INSERT INTO tab_game(source_db,id,game_name) VALUES('lx',77,'未申请游戏'),('hw',77,'其他平台游戏')", "INSERT INTO tab_promote_apply(source_db,promote_id,game_id,status) VALUES('hw',100,77,1)", "INSERT INTO tab_sw_report_day(source_db,promote_id,game_id,dedup_mode,stat_date,total_pay,game_name) VALUES('lx',100,77,0,'2026-11-01',9999999,'未申请游戏')", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } data, err := repo.Aggregate(context.Background(), business.GameAnalytics, scope, filter) if err != nil { t.Fatal(err) } if len(data.Rows) == 0 { t.Fatal("approved games must remain visible") } for _, row := range data.Rows { if row.ID == 77 || row.SourceDB != "lx" { t.Fatal("unapproved game facts or another platform's approval escaped authorization") } } } func TestAnalyticsMissingScopeAndTablesDoNotBroaden(t *testing.T) { db, repo, scope, filter := prepareAnalyticsData(t) scope.IncludeSub = false data, err := repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || len(data.Rows) != 2 { t.Fatal("关闭下级仅保留一级渠道统计") } if err := db.Exec("DROP TABLE tab_sw_report_day_key").Error; err != nil { t.Fatal(err) } data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || data.Available { t.Fatal("缺身份键表不能返回零汇总") } scope.Viewer = business.UserScope{SelfID: 500, OnlySelf: true} data, err = repo.Aggregate(context.Background(), business.ChannelAnalytics, scope, filter) if err != nil || !data.Available || len(data.Rows) != 0 { t.Fatal("无绑定应为确定空范围") } }