package business import ( "context" "strings" "testing" "time" "gorm.io/gorm" "gorm.io/gorm/logger" "kra/internal/biz/business" ) type alertSQLCapture struct { logger.Interface queries []string elapsed []time.Duration } func (l *alertSQLCapture) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { sql, _ := fc() if strings.Contains(sql, "AS current_value") || strings.Contains(sql, "AS first_unix") || strings.Contains(sql, "AS recent_amount") { l.queries = append(l.queries, sql) l.elapsed = append(l.elapsed, time.Since(begin)) } l.Interface.Trace(ctx, begin, fc, err) } func TestMySQLAlertV2MigrationAndRangePlans(t *testing.T) { db := openRehearsalMySQL(t) now := prepareAlertV2(t, db) // 模拟尚未升级的真实旧表,覆盖普通列合并迁移路径。 for _, sql := range []string{ "ALTER TABLE sys_alert_record DROP INDEX uk_alert_record_v2_open", "ALTER TABLE sys_alert_record DROP COLUMN v2_open_key", "ALTER TABLE sys_alert_record DROP COLUMN independent_rule, DROP COLUMN total_players, DROP COLUMN total_pay, DROP COLUMN warning_color", "ALTER TABLE sys_alert_rule DROP COLUMN condition_mode, DROP COLUMN amount_direction, DROP COLUMN amount_threshold", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } if err := db.Exec("ALTER TABLE sys_alert_record ADD COLUMN unclosed_flag TINYINT GENERATED ALWAYS AS (CASE WHEN status=5 THEN NULL ELSE 1 END) STORED, ADD UNIQUE INDEX uk_alert_record_unclosed(platform_code,target_promote_id,rule_type,unclosed_flag)").Error; err != nil { t.Fatal(err) } if err := migrateAlertV2(db); err != nil { t.Fatal(err) } if err := migrateAlertV2(db); err != nil { t.Fatalf("重复迁移: %v", err) } if db.Migrator().HasIndex("sys_alert_record", "uk_alert_record_unclosed") { t.Fatal("旧唯一键阻挡已忽略或独立绝对值规则") } if err := db.Exec("ALTER TABLE sys_alert_record DROP INDEX uk_alert_record_v2_open, ADD UNIQUE INDEX uk_alert_record_v2_open(platform_code,target_promote_id,rule_type,v2_open_key)").Error; err != nil { t.Fatal(err) } if err := migrateAlertV2UniqueKey(db); err != nil { t.Fatalf("已有 V2 索引升级失败: %v", err) } rows := make([]map[string]any, 10000) for i := range rows { rows[i] = map[string]any{"id": 100 + i, "source_db": "lx", "promote_id": 1, "pay_status": 1, "pay_time": now.AddDate(-1, 0, 0).Unix(), "pay_amount": "1.00"} } if err := db.Table("tab_spend").CreateInBatches(rows, 500).Error; err != nil { t.Fatal(err) } if err := db.Exec("ANALYZE TABLE tab_spend,tab_promote").Error; err != nil { t.Fatal(err) } capture := &alertSQLCapture{Interface: logger.Default.LogMode(logger.Silent)} r := NewAlertTriggerRepo(testData{db.Session(&gorm.Session{Logger: capture})}) key := business.ChannelKey{SourceDB: "lx", ID: 1} values, err := r.Comparison(context.Background(), 2, []business.ChannelKey{key}, now, 1) if err != nil || values[key].Current != "3000.00" || values[key].Previous != "100.00" { t.Fatalf("MySQL 周期计算: %+v %v", values, err) } if _, err := loadAlertLoss(db.Session(&gorm.Session{Logger: capture}), []business.ChannelKey{key, key}, business.AlertLossAt(now)); err != nil { t.Fatal(err) } for i, sql := range capture.queries { var plan []struct { Table string Type string Key string Rows int64 Extra string } if err := db.Raw("EXPLAIN " + sql).Scan(&plan).Error; err != nil { t.Fatal(err) } t.Logf("query %d elapsed=%v plan=%+v", i, capture.elapsed[i], plan) for _, step := range plan { if (strings.Contains(sql, "AS current_value") || strings.Contains(sql, "AS recent_amount")) && (step.Table == "m" || step.Table == "tab_spend") && (step.Type != "range" || step.Rows > 100) { t.Fatalf("预警周期统计必须按时间范围读取,不能扫描整个渠道历史: %+v", plan) } } } hit := &business.AlertRecord{PlatformCode: "lx", TargetPromoteID: 1, RuleID: 1, RuleType: "充值", IndependentRule: true, GapDays: 7, Status: 1, CreatedAt: now, UpdatedAt: now} decide := func(r *business.AlertRecord) bool { return r == nil || r.Status == 1 } for _, id := range []uint64{1, 2} { hit.RuleID = id if ok, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide); err != nil || !ok { t.Fatalf("独立规则被唯一键挡住: %v %v", ok, err) } } if err := db.Model(&alertRecordPO{}).Where("rule_id=1").Update("status", 6).Error; err != nil { t.Fatal(err) } hit.RuleID = 1 hit.CreatedAt = now.AddDate(0, 0, 7) if ok, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide); err != nil || !ok { t.Fatalf("已忽略仍占用唯一键: %v %v", ok, err) } } func TestMySQLAlertMigrationKeepsLegacyConstraintOnUnexpectedIndex(t *testing.T) { db := openRehearsalMySQL(t) for _, sql := range []string{ "ALTER TABLE sys_alert_record DROP INDEX uk_alert_record_v2_open, ADD INDEX uk_alert_record_v2_open(platform_code), ADD UNIQUE INDEX uk_alert_record_daily(platform_code,target_promote_id,rule_type,alert_date)", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } if err := migrateAlertV2UniqueKey(db); err == nil { t.Fatal("意外的现有索引定义不应被当成迁移成功") } if !db.Migrator().HasIndex("sys_alert_record", "uk_alert_record_daily") { t.Fatal("验证失败前不能移除旧约束") } }