package business import ( "context" "sync/atomic" "testing" "time" "gorm.io/gorm" "kra/internal/biz/business" ) func TestAlertLossSkipsPaymentReadsForExpiredLogins(t *testing.T) { db := openTestDB(t) now := prepareAlertV2(t, db) if err := db.Exec("UPDATE tab_promote SET last_login_time=0,create_time=1").Error; err != nil { t.Fatal(err) } var paymentReads atomic.Int32 if err := db.Callback().Row().Before("gorm:row").Register("count_loss_payment_reads", func(tx *gorm.DB) { if tx.Statement.Table == "tab_spend" { paymentReads.Add(1) } }); err != nil { t.Fatal(err) } keys := []business.ChannelKey{{SourceDB: "lx", ID: 1}, {SourceDB: "hw", ID: 1}} window := business.AlertLossAt(now) states, err := loadAlertLoss(db, keys, window) if err != nil || paymentReads.Load() != 0 || len(states) != 2 || !*states[keys[0]] || !*states[keys[1]] { t.Fatalf("expired logins should establish loss without payment reads: %+v %v reads=%d", states, err, paymentReads.Load()) } if err := db.Exec("UPDATE tab_promote SET last_login_time=? WHERE source_db='hw'", now.Unix()).Error; err != nil { t.Fatal(err) } states, err = loadAlertLoss(db, keys, window) if err != nil || paymentReads.Load() != 1 || !*states[keys[0]] || *states[keys[1]] { t.Fatalf("active login must still check payments within its own platform: %+v %v reads=%d", states, err, paymentReads.Load()) } } func prepareAlertV2(t *testing.T, db *gorm.DB) time.Time { t.Helper() now := time.Date(2026, 11, 5, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) for _, sql := range []string{ "CREATE TABLE tab_promote(id BIGINT,source_db VARCHAR(20),busier_id BIGINT,account VARCHAR(100),promote_level INT,status INT,last_login_time BIGINT,create_time BIGINT,parent_id BIGINT,top_promote_id BIGINT,PRIMARY KEY(source_db,id))", "CREATE TABLE tab_user(id BIGINT,source_db VARCHAR(20),promote_id BIGINT,puid BIGINT,register_time VARCHAR(30),idcard VARCHAR(100),PRIMARY KEY(source_db,id))", "CREATE TABLE tab_spend(id BIGINT PRIMARY KEY,source_db VARCHAR(20),promote_id BIGINT,pay_status INT,pay_time BIGINT,pay_amount DECIMAL(20,2))", "INSERT INTO tab_promote VALUES(1,'lx',7,'父渠道',1,1,0,1,0,0),(2,'lx',7,'子渠道',2,1,0,1,1,1),(3,'lx',7,'孙渠道',3,1,0,1,2,1),(1,'hw',7,'同号渠道',1,1,0,1,0,0)", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } if err := db.Exec("UPDATE tab_promote SET last_login_time=?", now.Unix()).Error; err != nil { t.Fatal(err) } if err := db.Exec("INSERT INTO tab_user VALUES(1,'lx',1,0,?,'card-a'),(2,'lx',2,0,?,'card-a'),(3,'lx',3,0,?,'card-b'),(4,'lx',3,1,?,'card-c'),(1,'hw',1,0,?,'other')", now.AddDate(0, 0, -2).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix()).Error; err != nil { t.Fatal(err) } if err := db.Exec("INSERT INTO tab_spend VALUES(1,'lx',1,1,?,100),(2,'lx',2,1,?,2000),(3,'lx',3,1,?,1000),(4,'lx',3,0,?,999999),(5,'hw',1,1,?,999999)", now.AddDate(0, 0, -2).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix()).Error; err != nil { t.Fatal(err) } return now } func TestAlertV2SnapshotsTreeAndBoundedComparison(t *testing.T) { db := openTestDB(t) now := prepareAlertV2(t, db) repo := NewAlertTriggerRepo(testData{db}) key := business.ChannelKey{SourceDB: "lx", ID: 1} sample, err := repo.Comparison(context.Background(), 2, []business.ChannelKey{key}, now, 1) if err != nil || sample[key].Current != "3000.00" || sample[key].Previous != "100.00" { t.Fatalf("子渠道汇总或平台隔离错误: %+v %v", sample, err) } for _, dedup := range []bool{true, false} { totals, err := repo.Snapshots(context.Background(), []business.ChannelKey{key}, now, dedup) want := int64(3) if dedup { want = 2 } if err != nil || totals[key].Players != want || totals[key].Pay != "3100" { t.Fatalf("累计快照或去重错误: %+v %v", totals, err) } } } func TestAlertV2ExemptionHistoryAndAutomaticRemoval(t *testing.T) { db := openTestDB(t) now := prepareAlertV2(t, db) if err := db.AutoMigrate(&accountPO{}); err != nil { t.Fatal(err) } if err := db.Exec("INSERT INTO sys_users(id,username,level,id_path) VALUES(7,'owner',1,'0')").Error; err != nil { t.Fatal(err) } if err := db.Exec("INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(7,'lx',7,'0')").Error; err != nil { t.Fatal(err) } r := NewAlertGovernanceRepo(testData{db}) ctx := context.Background() entry := &business.AlertExemption{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 1}, ChannelName: "父渠道", OwnerID: 7, AddedBy: 7, AddedName: "一级商务", AddedAt: now, Reason: "长期合作"} if err := r.AddExemption(ctx, business.UserScope{All: true}, entry.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return entry, nil }); err != nil { t.Fatal(err) } if err := r.AddExemption(ctx, business.UserScope{All: true}, entry.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return entry, nil }); err != business.ErrAlertExemptExists { t.Fatalf("重复加入: %v", err) } rows, total, err := r.Exemptions(ctx, business.LevelOneScope{UserID: 7}, business.AlertExemptionFilter{}) if err != nil || total != 1 { t.Fatalf("名单: %+v %v", rows, err) } if err := r.RemoveExemption(ctx, business.LevelOneScope{UserID: 8}, rows[0].ID, 8, "越权", "手动移出", now); err != business.ErrAlertExemptNotFound { t.Fatalf("跨商务移出: %v", err) } hit := &business.AlertRecord{BusinessID: 7, PlatformCode: "lx", TargetPromoteID: 1, RuleID: 1, RuleType: "充值", Status: 1, CreatedAt: now, UpdatedAt: now} written, err := NewAlertTriggerRepo(testData{db}).StoreHit(ctx, hit, []int{1, 2, 3, 4}, func(*business.AlertRecord) bool { return true }) if err != nil || written { t.Fatalf("免预警仍生成: %v %v", written, err) } // 父渠道自身近 30 天流水仅 100 元,满足激活池条件;子渠道流水不冒充它自身的流水。 uc := business.NewAlertGovernanceUsecase(r, nil) if err := uc.Reconcile(ctx, now); err != nil { t.Fatal(err) } rows, _, _ = r.Exemptions(ctx, business.LevelOneScope{All: true}, business.AlertExemptionFilter{}) if rows[0].RemovedAt == nil || rows[0].RemovedName != "系统(自动移出)" || rows[0].RemoveReason != "进入激活池 · 自动移出" { t.Fatalf("自动移出未留痕: %+v", rows[0]) } if err := r.AddExemption(ctx, business.UserScope{All: true}, entry.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return entry, nil }); err != nil { t.Fatal(err) } _, total, err = r.Exemptions(ctx, business.LevelOneScope{All: true}, business.AlertExemptionFilter{}) if err != nil || total != 2 { t.Fatal("重新加入不得覆盖历史") } } func TestAlertV2GapSeparatesAbsoluteRulesAndIgnoredRecords(t *testing.T) { db := openTestDB(t) r := NewAlertTriggerRepo(testData{db}) now := time.Now().Truncate(time.Second) hit := &business.AlertRecord{PlatformCode: "lx", TargetPromoteID: 1, RuleID: 1, RuleType: "充值", Status: 1, CreatedAt: now, UpdatedAt: now, GapDays: 7, IndependentRule: true} decide := func(current *business.AlertRecord) bool { return current == nil || current.Status == business.AlertUnprocessed } write := func(want bool) { t.Helper() got, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide) if err != nil || got != want { t.Fatalf("written=%v want=%v err=%v", got, want, err) } } write(true) hit.RuleID = 2 write(true) hit.RuleID = 1 write(false) if err := db.Model(&alertRecordPO{}).Where("rule_id=1").Update("status", business.AlertIgnored).Error; err != nil { t.Fatal(err) } hit.CreatedAt = now.AddDate(0, 0, 6) write(false) hit.CreatedAt = now.AddDate(0, 0, 7) write(true) var rows []alertRecordPO if err := db.Order("id").Find(&rows).Error; err != nil { t.Fatal(err) } if len(rows) != 3 || rows[0].Status != business.AlertIgnored { t.Fatal("已忽略记录被复活或独立规则被覆盖") } } func TestAlertV2LiveLossDoesNotRewriteSnapshots(t *testing.T) { db := openTestDB(t) now := prepareAlertV2(t, db) // 流失检测使用父渠道的当前值,快照保持触发时的值。 players, pay := int64(2), "3100.00" if err := db.Create(&alertRecordPO{ID: 1, PlatformCode: "lx", TargetPromoteID: 1, BusinessID: 7, Status: 1, AlertContent: "满2000", TotalPlayers: &players, TotalPay: &pay, CreatedAt: now}).Error; err != nil { t.Fatal(err) } scope := business.AlertRecordScope{Owner: business.LevelOneScope{All: true}, Viewer: business.UserScope{All: true}} r := NewAlertRecordRepo(testData{db}) lost := true page, err := r.List(context.Background(), scope, business.AlertRecordFilter{Lost: &lost, AlertContent: "2000"}) if err != nil || page.Total != 1 || page.Rows[0].Lost == nil || !*page.Rows[0].Lost { t.Fatalf("实时流失筛选: %+v %v", page, err) } if err := db.Exec("UPDATE tab_spend SET pay_amount=1000 WHERE id=1").Error; err != nil { t.Fatal(err) } if err := db.Exec("UPDATE tab_promote SET last_login_time=?", time.Now().Unix()).Error; err != nil { t.Fatal(err) } lost = false page, err = r.List(context.Background(), scope, business.AlertRecordFilter{Lost: &lost}) if err != nil || page.Total != 1 || *page.Rows[0].TotalPlayers != 2 || *page.Rows[0].TotalPay != "3100" { t.Fatalf("实时状态或冻结快照错误: %+v %v", page, err) } }