378 lines
14 KiB
Go
378 lines
14 KiB
Go
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)
|
|
}
|
|
}
|