diff --git a/internal/data/business/mysql_rehearsal_test.go b/internal/data/business/mysql_rehearsal_test.go index aeae5ef..de9ea9f 100644 --- a/internal/data/business/mysql_rehearsal_test.go +++ b/internal/data/business/mysql_rehearsal_test.go @@ -221,6 +221,47 @@ func TestMySQLRegistrationIndexPreservesCastAndUsesRange(t *testing.T) { } } +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 TestMySQLChannelCreationSerializesAccount(t *testing.T) { db := openRehearsalMySQL(t) if err := db.AutoMigrate(&platformChannelPO{}, &channelSettlementPO{}); err != nil { diff --git a/internal/data/business/query_indexes.go b/internal/data/business/query_indexes.go index 36d22fa..2b63528 100644 --- a/internal/data/business/query_indexes.go +++ b/internal/data/business/query_indexes.go @@ -74,7 +74,17 @@ func migrateQueryIndexes(db *gorm.DB, indexes []businessQueryIndex) error { } // 同一大表的多个索引合并构建,减少重复扫描。 if len(additions) > 0 { - if err := db.Exec("ALTER TABLE `" + table + "` " + strings.Join(additions, ",") + ", ALGORITHM=INPLACE, LOCK=NONE").Error; err != nil { + build := func(conn *gorm.DB) error { + return conn.Exec("ALTER TABLE `" + table + "` " + strings.Join(additions, ",") + ", ALGORITHM=INPLACE, LOCK=NONE").Error + } + // Later indexes also evaluate the historical generated time column. + // Apply the same connection-local compatibility rule as its first index. + if table == "tab_user" && db.Migrator().HasColumn(table, "sw_register_time_unix") { + err = withLegacyRegistrationCast(db, build) + } else { + err = build(db) + } + if err != nil { return fmt.Errorf("add query indexes on %s: %w", table, err) } } diff --git a/internal/data/business/registration_index.go b/internal/data/business/registration_index.go index eb10733..3e5d633 100644 --- a/internal/data/business/registration_index.go +++ b/internal/data/business/registration_index.go @@ -44,11 +44,20 @@ func migrateRegistrationIndex(db *gorm.DB) error { } func addRegistrationIndex(db *gorm.DB) error { + return withLegacyRegistrationCast(db, func(conn *gorm.DB) error { + return conn.Exec("ALTER TABLE tab_user ADD INDEX idx_sw_user_registration(source_db,puid,sw_register_time_unix,id), ALGORITHM=INPLACE, LOCK=NONE").Error + }) +} + +func withLegacyRegistrationCast(db *gorm.DB, migrate func(*gorm.DB) error) error { // 旧 SELECT CAST 会以警告方式接受尾部空格等历史值。建索引属于 DDL, // 严格模式会把同一警告升级为错误;仅在固定连接的索引构建期间保持旧转换行为。 return db.Connection(func(conn *gorm.DB) (result error) { + // Keep the bound connection's base handle free of statement errors so + // restoration still executes after a failed ALTER TABLE. + fresh := func() *gorm.DB { return conn.Session(&gorm.Session{NewDB: true}) } var previous string - if err := conn.Raw("SELECT @@session.sql_mode").Scan(&previous).Error; err != nil { + if err := fresh().Raw("SELECT @@session.sql_mode").Scan(&previous).Error; err != nil { return err } modes := []string{} @@ -57,14 +66,14 @@ func addRegistrationIndex(db *gorm.DB) error { modes = append(modes, mode) } } - if err := conn.Exec("SET SESSION sql_mode=?", strings.Join(modes, ",")).Error; err != nil { + if err := fresh().Exec("SET SESSION sql_mode=?", strings.Join(modes, ",")).Error; err != nil { return err } defer func() { - if err := conn.Exec("SET SESSION sql_mode=?", previous).Error; err != nil { + if err := fresh().Exec("SET SESSION sql_mode=?", previous).Error; err != nil { result = errors.Join(result, err) } }() - return conn.Exec("ALTER TABLE tab_user ADD INDEX idx_sw_user_registration(source_db,puid,sw_register_time_unix,id), ALGORITHM=INPLACE, LOCK=NONE").Error + return migrate(fresh()) }) }