package business import ( "context" "database/sql" "fmt" "os" "strings" "sync" "testing" "time" drivermysql "github.com/go-sql-driver/mysql" "gorm.io/driver/mysql" "gorm.io/gorm" "kra/internal/biz/business" ) // 显式设置本地隔离 MySQL DSN 才执行;每项测试只创建并清理自己的随机数据库。 func openRehearsalMySQL(t *testing.T) *gorm.DB { t.Helper() dsn := os.Getenv("SW_TEST_MYSQL_DSN") if dsn == "" { t.Skip("未配置隔离 MySQL") } cfg, err := drivermysql.ParseDSN(dsn) if err != nil { t.Fatal(err) } if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") { t.Fatal("MySQL 演练测试只接受本地隔离实例") } cfg.DBName = "" cfg.ParseTime = true cfg.Loc = time.FixedZone("Asia/Shanghai", 8*3600) admin, err := sql.Open("mysql", cfg.FormatDSN()) if err != nil { t.Fatal(err) } name := fmt.Sprintf("sw_test_business_%d", time.Now().UnixNano()) if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { _ = admin.Close() t.Fatal(err) } t.Cleanup(func() { _, err := admin.Exec("DROP DATABASE `" + name + "`") if err != nil { t.Error(err) } _ = admin.Close() }) cfg.DBName = name db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{}) if err != nil { t.Fatal(err) } pool, _ := db.DB() pool.SetMaxOpenConns(20) t.Cleanup(func() { _ = pool.Close() }) for _, step := range Migrations() { if err := step.Migrate(db); err != nil { t.Fatal(err) } } return db } func TestMySQLAlertTriggerConcurrentDeduplication(t *testing.T) { db := openRehearsalMySQL(t) 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) VIRTUAL, ADD UNIQUE INDEX uk_alert_record_unclosed(platform_code,target_promote_id,rule_type,unclosed_flag)").Error; err != nil { t.Fatal(err) } if err := migrateAlertV2UniqueKey(db); err != nil { t.Fatal(err) } r := NewAlertTriggerRepo(testData{db}) now := time.Now().Truncate(time.Millisecond) hit := &business.AlertRecord{RuleID: 1, PlatformCode: "lx", PlatformName: "龙翔", TargetPromoteID: 10, RuleType: "注册", Status: 1, BusinessID: 10, CreatedAt: now, UpdatedAt: now} decide := func(current *business.AlertRecord) bool { return current == nil || current.Status == 1 } errors := make(chan error, 16) var group sync.WaitGroup for i := 0; i < 16; i++ { group.Add(1) go func() { defer group.Done() _, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide) errors <- err }() } group.Wait() close(errors) for err := range errors { if err != nil { t.Fatal(err) } } var count int64 if err := db.Model(&alertRecordPO{}).Count(&count).Error; err != nil || count != 1 { t.Fatalf("并发重复预警 count=%d error=%v", count, err) } if err := db.Model(&alertRecordPO{}).Where("platform_code='lx'").Update("status", 2).Error; err != nil { t.Fatal(err) } changed := *hit changed.AlertContent = "不应覆盖处理中记录" written, err := r.StoreHit(context.Background(), &changed, []int{1, 2, 3, 4}, decide) if err != nil || written { t.Fatalf("在途记录被刷新: %v %v", written, err) } var row alertRecordPO if err := db.First(&row).Error; err != nil { t.Fatal(err) } if row.Status != 2 || row.AlertContent != "" { t.Fatalf("状态或快照发生变化: %+v", row) } if err := db.Model(&row).Update("status", 4).Error; err != nil { t.Fatal(err) } written, err = r.StoreHit(context.Background(), &changed, []int{1, 2, 3, 4}, decide) if err != nil || written { t.Fatalf("状态 4 是驳回,不能另建或覆盖未关闭记录: %v %v", written, err) } if err := db.Model(&row).Update("status", 5).Error; err != nil { t.Fatal(err) } written, err = r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide) db.Model(&alertRecordPO{}).Count(&count) if err != nil || !written || count != 2 { t.Fatalf("关闭后新一轮预警失败: %v count=%d %v", written, count, err) } } func TestMySQLChannelStatusSummaryAndEffectivenessFilter(t *testing.T) { db := openRehearsalMySQL(t) if err := db.AutoMigrate(&accountPO{}, &channelPO{}); err != nil { t.Fatal(err) } for i, status := range []int{0, -1, -2} { created := int64(1000 + i) if err := db.Create(&channelPO{ID: uint64(i + 1), SourceDB: "lx", Account: fmt.Sprintf("channel-%d", i), LockStatus: &status, CreateTime: &created}).Error; err != nil { t.Fatal(err) } } r := NewChannelRepo(testData{db}) page, err := r.List(context.Background(), business.UserScope{All: true}, business.ChannelFilter{Sources: []string{"lx"}}, business.ListLimit(1)) if err != nil || page.Total != 3 || len(page.Rows) != 1 || page.StatusCounts.Normal != 1 || page.StatusCounts.Locked != 1 || page.StatusCounts.Terminated != 1 { t.Fatalf("MySQL全量状态汇总与分页不一致: %+v %v", page, err) } yes := true page, err = r.List(context.Background(), business.UserScope{All: true}, business.ChannelFilter{Sources: []string{"lx"}, Effective: &yes}) if err != nil || page.Total != 0 { t.Fatalf("有效端筛选不能重复关联表或漏用筛选条件: %+v %v", page, err) } } func TestMySQLRegistrationIndexPreservesCastAndUsesRange(t *testing.T) { db := openRehearsalMySQL(t) for _, statement := range []string{ "CREATE TABLE tab_user(id INT,source_db VARCHAR(10),puid INT,register_time VARCHAR(11),PRIMARY KEY(id,source_db),KEY(register_time))", "INSERT INTO tab_user VALUES(1,'lx',0,'1793635200'),(2,'lx',0,'01793635200'),(3,'lx',0,'1793635200 '),(4,'lx',1,'1793635200'),(5,'hw',0,'1793635200'),(6,'lx',0,'1793000000')", } { if err := db.Exec(statement).Error; err != nil { t.Fatal(err) } } // 让命中比例接近实际分页查询,避免微型表上优化器合理选择全表扫描。 outside := make([]map[string]any, 1000) for i := range outside { outside[i] = map[string]any{"id": 100 + i, "source_db": "lx", "puid": 0, "register_time": "1793000000"} } if err := db.Table("tab_user").CreateInBatches(outside, 200).Error; err != nil { t.Fatal(err) } pool, _ := db.DB() pool.SetMaxOpenConns(1) var beforeMode string if err := db.Raw("SELECT @@session.sql_mode").Scan(&beforeMode).Error; err != nil { t.Fatal(err) } query := "SELECT id,register_time FROM tab_user r WHERE r.source_db='lx' AND r.puid=0 AND CAST(r.register_time AS UNSIGNED) BETWEEN 1793635200 AND 1793721599 ORDER BY id" var before, after []struct { ID int RegisterTime string } if err := db.Raw(query).Scan(&before).Error; err != nil { t.Fatal(err) } for i := 0; i < 2; i++ { if err := migrateRegistrationIndex(db); err != nil { t.Fatal(err) } } var afterMode string if err := db.Raw("SELECT @@session.sql_mode").Scan(&afterMode).Error; err != nil || beforeMode != afterMode { t.Fatalf("索引创建未恢复会话模式: %v", err) } if err := db.Raw(query).Scan(&after).Error; err != nil { t.Fatal(err) } if len(before) != 3 || fmt.Sprint(before) != fmt.Sprint(after) { t.Fatalf("时间转换或原字符串改变: before=%+v after=%+v", before, after) } var plan []struct{ Key, Type string } if err := db.Raw("EXPLAIN SELECT COUNT(*) FROM tab_user r WHERE r.source_db='lx' AND r.puid=0 AND CAST(r.register_time AS UNSIGNED) BETWEEN 1793635200 AND 1793721599").Scan(&plan).Error; err != nil { t.Fatal(err) } if len(plan) != 1 || plan[0].Key != "idx_sw_user_registration" || plan[0].Type != "range" { t.Fatalf("未使用时间范围索引: %+v", plan) } queryAll, err := reportRegistrationQuery(db) if err != nil { t.Fatal(err) } var allIDs []int if err := queryAll.Select("id").Where("puid=0 AND CAST(register_time AS UNSIGNED) BETWEEN ? AND ?", 1793635200, 1793721599).Order("id").Scan(&allIDs).Error; err != nil { t.Fatal(err) } if fmt.Sprint(allIDs) != "[1 2 3 5]" { t.Fatalf("跨平台重建筛选丢失记录: %v", allIDs) } } func TestMySQLLaterRegistrationIndexesPreserveMalformedLegacyTimes(t *testing.T) { db := openRehearsalMySQL(t) for _, sql := range []string{ "CREATE TABLE tab_user(id INT,source_db VARCHAR(10),puid INT,promote_id INT,register_time VARCHAR(20),PRIMARY KEY(id,source_db))", "INSERT INTO tab_user VALUES(1,'lx',0,10,''),(2,'lx',0,10,'bad'),(3,'lx',0,10,'1793635200 ')", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } pool, _ := db.DB() pool.SetMaxOpenConns(1) var mode string if err := db.Raw("SELECT @@session.sql_mode").Scan(&mode).Error; err != nil { t.Fatal(err) } if err := migrateRegistrationIndex(db); err != nil { t.Fatal(err) } indexes := []businessQueryIndex{{"tab_user", "idx_alert_user_window", []string{"source_db", "promote_id", "sw_register_time_unix", "puid"}}} for i := 0; i < 2; i++ { if err := migrateQueryIndexes(db, indexes); err != nil { t.Fatal(err) } } var after string if err := db.Raw("SELECT @@session.sql_mode").Scan(&after).Error; err != nil || mode != after { t.Fatalf("sql_mode changed: %v", err) } var values []string if err := db.Table("tab_user").Order("id").Pluck("register_time", &values).Error; err != nil || strings.Join(values, "|") != "|bad|1793635200 " { t.Fatalf("original strings changed: %q %v", values, err) } if err := migrateQueryIndexes(db, []businessQueryIndex{{"tab_user", "idx_invalid_column", []string{"missing_column"}}}); err == nil { t.Fatal("invalid DDL unexpectedly succeeded") } if err := db.Raw("SELECT @@session.sql_mode").Scan(&after).Error; err != nil || mode != after { t.Fatalf("failed DDL did not restore sql_mode: %v", err) } } func TestMySQLChannelOwnPlayerSortUsesGroupedCoveringIndex(t *testing.T) { db := openRehearsalMySQL(t) for _, sql := range []string{ "CREATE TABLE tab_promote(id BIGINT,source_db VARCHAR(10),create_time BIGINT,PRIMARY KEY(id,source_db))", "CREATE TABLE tab_user(id BIGINT,source_db VARCHAR(10),promote_id BIGINT,puid BIGINT,sw_register_time_unix BIGINT,idcard VARCHAR(20),PRIMARY KEY(id,source_db),INDEX idx_alert_user_window(source_db,promote_id,sw_register_time_unix,puid))", "INSERT INTO tab_promote VALUES(1,'lx',1),(2,'lx',2),(1,'hw',3)", "INSERT INTO tab_user VALUES(1,'lx',1,0,1,'a'),(2,'lx',1,0,1,'a'),(3,'lx',2,0,1,'b'),(1,'hw',1,0,1,'c')", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } keys, err := channelMetricPage(db, db.Table("tab_promote p").Where("p.source_db='lx'"), business.ChannelFilter{Sources: []string{"lx"}, SortField: "playerCount"}, business.NewListOptions(business.ListLimit(1))) if err != nil || len(keys) != 1 || keys[0] != (business.ChannelKey{SourceDB: "lx", ID: 1}) { t.Fatalf("own-channel sort: %v %v", keys, err) } var counts []struct { SourceDB string PromoteID uint64 Count int64 } if err := whereMetricChannels(db, []business.ChannelKey{{SourceDB: "lx", ID: 2}, {SourceDB: "hw", ID: 1}}).Table("tab_user").Select("source_db,promote_id,COUNT(*) AS count").Group("source_db,promote_id").Scan(&counts).Error; err != nil || len(counts) != 2 { t.Fatalf("paired source filter: %v %v", counts, err) } for _, row := range counts { if row.Count != 1 { t.Fatalf("included a same-id channel from another platform: %+v", row) } } } func TestMySQLChannelCreationSerializesAccount(t *testing.T) { db := openRehearsalMySQL(t) if err := db.AutoMigrate(&platformChannelPO{}, &channelSettlementPO{}); err != nil { t.Fatal(err) } for _, statement := range []string{"CREATE TABLE tab_promote_business(id BIGINT PRIMARY KEY,promote_ids TEXT)", "INSERT INTO tab_promote_business VALUES(7,'5,6')"} { if err := db.Exec(statement).Error; err != nil { t.Fatal(err) } } repo := NewChannelWriteRepo(channelDatabases{db}) input := &business.ChannelWrite{ChannelCreate: business.ChannelCreate{SourceDB: "lx", Account: "concurrentChannel", Password: "encoded", BusierID: 7}, Status: -3, Level: 1, SettlementDays: 7, CreatedUnix: 1000, NextCountUnix: 605800} results := make(chan error, 8) var group sync.WaitGroup for i := 0; i < 8; i++ { group.Add(1) go func() { defer group.Done() _, err := repo.Create(context.Background(), input) results <- err }() } group.Wait() close(results) created, duplicates := 0, 0 for err := range results { if err == nil { created++ } else if err == business.ErrChannelExists { duplicates++ } else { t.Fatal(err) } } var channelCount, settlementCount int64 db.Model(&platformChannelPO{}).Count(&channelCount) db.Model(&channelSettlementPO{}).Count(&settlementCount) if created != 1 || duplicates != 7 || channelCount != 1 || settlementCount != 1 { t.Fatalf("并发创建结果错误: created=%d duplicate=%d channels=%d settlements=%d", created, duplicates, channelCount, settlementCount) } var relation string db.Table("tab_promote_business").Select("promote_ids").Where("id=7").Scan(&relation) if relation != "5,6,1" { t.Fatalf("商务渠道关系未完整保留: %s", relation) } if err := db.Exec("CREATE TRIGGER fail_settlement BEFORE INSERT ON tab_promote_settlement_time FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='test failure'").Error; err != nil { t.Fatal(err) } failed := *input failed.Account = "rollbackChannel" if _, err := repo.Create(context.Background(), &failed); err == nil { t.Fatal("结算写入失败应该回滚") } db.Model(&platformChannelPO{}).Count(&channelCount) if channelCount != 1 { t.Fatal("失败的渠道记录没有回滚") } if err := db.Exec("DROP TRIGGER fail_settlement").Error; err != nil { t.Fatal(err) } if _, err := repo.Create(context.Background(), &failed); err != nil { t.Fatalf("失败后命名锁未释放或无法重试: %v", err) } } func TestMySQLServerReportIndexIsAdditiveAndIdempotent(t *testing.T) { db := openRehearsalMySQL(t) if err := db.Exec("CREATE TABLE tab_user_play_info(id INT PRIMARY KEY,create_time INT,play_time INT,KEY(play_time))").Error; err != nil { t.Fatal(err) } for i := 0; i < 2; i++ { if err := migrateServerReportIndex(db); err != nil { t.Fatal(err) } } var count int64 db.Raw("SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='tab_user_play_info' AND column_name='create_time' AND seq_in_index=1").Scan(&count) if count != 1 || !db.Migrator().HasIndex("tab_user_play_info", "play_time") { t.Fatalf("重复创建索引或覆盖旧索引: %d", count) } }