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) } 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 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 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) } }