feat: automate business upgrades and sync reviewed frontend
This commit is contained in:
@@ -204,26 +204,11 @@ func (r *accountRepo) Update(ctx context.Context, a *business.Account) error {
|
||||
}
|
||||
|
||||
func (r *accountRepo) AuthorityForLevel(ctx context.Context, level int) (uint, error) {
|
||||
// 原角色 ID 仅用于查迁移映射,不作为 Kra 角色 ID 使用。
|
||||
legacy := map[int]string{1: "2083497078545207297", 2: "2087435436573081601", 3: "2087361690139152385"}
|
||||
oldID, ok := legacy[level]
|
||||
if !ok {
|
||||
if level < 1 || level > 3 {
|
||||
return 0, business.ErrBusinessRole
|
||||
}
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
var id uint
|
||||
if db.Migrator().HasTable("sw_kra_id_map") {
|
||||
if err := db.Table("sw_kra_id_map m").Select("m.new_id").Joins("JOIN sys_authorities a ON a.authority_id=m.new_id AND a.deleted_at IS NULL").Where("m.entity = ? AND m.old_id = ?", "role", oldID).Scan(&id).Error; err != nil {
|
||||
return 0, business.ErrStorage
|
||||
}
|
||||
if id != 0 && id != 888 {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
// 新安装可使用对应中文角色;匹配不唯一时停止创建,避免错授权限。
|
||||
names := map[int]string{1: "一级商务", 2: "二级商务", 3: "三级商务"}
|
||||
var ids []uint
|
||||
if err := db.Table("sys_authorities").Where("authority_name = ? AND deleted_at IS NULL AND authority_id <> ?", names[level], 888).Pluck("authority_id", &ids).Error; err != nil {
|
||||
if err := r.data.DB().WithContext(ctx).Table("sys_authorities").Where("business_level=? AND deleted_at IS NULL AND authority_id<>888", level).Pluck("authority_id", &ids).Error; err != nil {
|
||||
return 0, business.ErrStorage
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
|
||||
@@ -15,10 +15,10 @@ func prepareAccounts(t *testing.T) *gorm.DB {
|
||||
}
|
||||
for _, sql := range []string{
|
||||
"CREATE TABLE sys_user_authority (sys_user_id INTEGER,sys_authority_authority_id INTEGER)",
|
||||
"CREATE TABLE sys_authorities (authority_id INTEGER,authority_name TEXT,deleted_at DATETIME)",
|
||||
"CREATE TABLE sys_authorities (authority_id INTEGER,authority_name TEXT,deleted_at DATETIME,business_level INTEGER UNIQUE)",
|
||||
"CREATE TABLE sw_kra_id_map (entity TEXT,old_id TEXT,new_id INTEGER)",
|
||||
"CREATE TABLE tab_promote (id INTEGER,source_db TEXT,busier_id INTEGER,promote_level INTEGER,status INTEGER)",
|
||||
"INSERT INTO sys_authorities VALUES (901,'一级商务',NULL),(902,'已改名二级角色',NULL),(903,'三级商务',NULL)",
|
||||
"INSERT INTO sys_authorities VALUES (901,'一级商务',NULL,1),(902,'已改名二级角色',NULL,2),(903,'三级商务',NULL,3)",
|
||||
"INSERT INTO sw_kra_id_map VALUES ('role','2087435436573081601',902)",
|
||||
} {
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
@@ -32,6 +32,9 @@ func TestAccountCreateIsAtomicAndPreferencesDefaultOn(t *testing.T) {
|
||||
db := prepareAccounts(t)
|
||||
r := NewAccountRepo(testData{db})
|
||||
ctx := context.Background()
|
||||
if err := db.Exec("DROP TABLE sw_kra_id_map").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role, err := r.AuthorityForLevel(ctx, 2)
|
||||
if err != nil || role != 902 {
|
||||
t.Fatalf("角色映射不正确: %d %v", role, err)
|
||||
|
||||
@@ -394,13 +394,11 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
|
||||
SourceDB string `gorm:"column:source_db"`
|
||||
PlatformBusierID uint64
|
||||
PlatformBusierAccount, Username string
|
||||
UserID uint
|
||||
IDPath string `gorm:"column:id_path"`
|
||||
}
|
||||
names := map[business.PlatformKey]bindName{}
|
||||
for start := 0; start < len(pairs); start += 300 {
|
||||
var rows []bindName
|
||||
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,b.user_id,COALESCE(NULLIF(u.nick_name,''),u.username) AS username,u.id_path").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
|
||||
q := channelBindingQuery(db, scope.Viewer, business.ChannelFilter{}).Select("b.source_db,b.platform_busier_id,b.platform_busier_account,u.username").Where("(b.source_db,b.platform_busier_id) IN ?", portraitBindingPairs(pairs[start:min(start+300, len(pairs))])).Order("b.user_id")
|
||||
if err := q.Scan(&rows).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
@@ -411,31 +409,6 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
|
||||
}
|
||||
}
|
||||
}
|
||||
ancestorIDs := map[uint]bool{}
|
||||
paths := map[business.PlatformKey][]uint{}
|
||||
for key, binding := range names {
|
||||
for _, part := range strings.Split(binding.IDPath, ",") {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, strconv.IntSize)
|
||||
if err == nil && id > 0 && uint(id) != binding.UserID {
|
||||
paths[key] = append(paths[key], uint(id))
|
||||
ancestorIDs[uint(id)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
ids := make([]uint, 0, len(ancestorIDs))
|
||||
for id := range ancestorIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
ancestors := map[uint]string{}
|
||||
for start := 0; start < len(ids); start += 300 {
|
||||
var rows []accountPO
|
||||
if err := db.Select("id,COALESCE(NULLIF(nick_name,''),username) AS username").Where("id IN ? AND level > 0", ids[start:min(start+300, len(ids))]).Find(&rows).Error; err != nil {
|
||||
return business.ErrStorage
|
||||
}
|
||||
for _, row := range rows {
|
||||
ancestors[row.ID] = row.Username
|
||||
}
|
||||
}
|
||||
for _, target := range targets {
|
||||
n := target.Names
|
||||
if n.PromoteID == nil || *n.PromoteID < 0 {
|
||||
@@ -448,14 +421,6 @@ func fillLedgerNames(db *gorm.DB, scope business.LedgerScope, filter business.Le
|
||||
if binding, ok := names[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}]; ok {
|
||||
n.BindingAccount = binding.PlatformBusierAccount
|
||||
n.BindingUserName = binding.Username
|
||||
for _, id := range paths[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}] {
|
||||
if name := strings.TrimSpace(ancestors[id]); name != "" {
|
||||
n.BusinessPath = append(n.BusinessPath, name)
|
||||
}
|
||||
}
|
||||
if name := strings.TrimSpace(binding.Username); name != "" {
|
||||
n.BusinessPath = append(n.BusinessPath, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func TestRegistrationScopePuidAndUnboundedPlayerPay(t *testing.T) {
|
||||
first = row
|
||||
}
|
||||
}
|
||||
if first == nil || first.TotalPayAmount == nil || portraitTestMoney(*first.TotalPayAmount) != "710.10" || first.FirstGameName != "历史游戏名" || first.DisplayBusiness() != "parent→branch→leaf" {
|
||||
if first == nil || first.TotalPayAmount == nil || portraitTestMoney(*first.TotalPayAmount) != "710.10" || first.FirstGameName != "历史游戏名" || first.DisplayBusiness() != "lx-business(leaf)" {
|
||||
t.Fatalf("累计充值不能受本次时间/渠道限制,且需保留游戏快照: %+v", first)
|
||||
}
|
||||
filter.SortField = "totalPayAmount"
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/data/swmigration"
|
||||
datasystem "kra/internal/data/system"
|
||||
"kra/pkg/database/migration"
|
||||
)
|
||||
|
||||
@@ -36,6 +38,9 @@ func CheckPrerequisites(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
func CheckSchema(db *gorm.DB) error {
|
||||
if err := swmigration.CheckSchema(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := CheckPrerequisites(db); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -50,6 +55,9 @@ func CheckSchema(db *gorm.DB) error {
|
||||
if db.Dialector.Name() != "mysql" {
|
||||
return nil
|
||||
}
|
||||
if err := datasystem.CheckBusinessRoles(db); err != nil {
|
||||
return err
|
||||
}
|
||||
indexes := append([]businessQueryIndex{}, businessQueryIndexes...)
|
||||
var idType string
|
||||
if err := db.Raw("SELECT data_type FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='tab_promote' AND column_name='id'").Scan(&idType).Error; err != nil {
|
||||
|
||||
@@ -2,11 +2,12 @@ package business
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/data/swmigration"
|
||||
"kra/pkg/database/migration"
|
||||
)
|
||||
|
||||
func Migrations() []migration.Step {
|
||||
return []migration.Step{{ID: "202611060023_business_binding_status_index", Migrate: migrateBindingStatusIndex}, {ID: "202611060022_business_promote_id_capacity", Migrate: migratePromoteIDCapacity}, {ID: "202611050020_business_alert_v2", Migrate: migrateAlertV2}, {ID: "202611040017_business_groups_goals_effectiveness", Migrate: migrateBusinessManagement}, {ID: "202611040018_business_effectiveness_query_index", Migrate: migrateEffectivenessQueryIndex}, {ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
|
||||
return []migration.Step{swmigration.StateMigration(), {ID: "202611060023_business_binding_status_index", Migrate: migrateBindingStatusIndex}, {ID: "202611060022_business_promote_id_capacity", Migrate: migratePromoteIDCapacity}, {ID: "202611050020_business_alert_v2", Migrate: migrateAlertV2}, {ID: "202611040017_business_groups_goals_effectiveness", Migrate: migrateBusinessManagement}, {ID: "202611040018_business_effectiveness_query_index", Migrate: migrateEffectivenessQueryIndex}, {ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{})
|
||||
}}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
|
||||
return migration.CreateMissingTables(db, &bindingLockPO{})
|
||||
|
||||
@@ -168,14 +168,10 @@ func (d *Data) reloadConfig(ctx context.Context) error {
|
||||
return fmt.Errorf("reload database: %w", err)
|
||||
}
|
||||
}
|
||||
if databaseReady && (next.Admin.System == nil || !next.Admin.System.DisableAutoMigrate) {
|
||||
if err = migrateAll(candidateDB.WithContext(ctx), d.catalog); err != nil {
|
||||
return fmt.Errorf("reload database migrations: %w", err)
|
||||
}
|
||||
}
|
||||
if databaseReady && next.Admin.System != nil && next.Admin.System.DisableAutoMigrate {
|
||||
if err = checkMigratedSchema(candidateDB.WithContext(ctx), d.catalog); err != nil {
|
||||
return fmt.Errorf("reload database schema: %w", err)
|
||||
if databaseReady {
|
||||
disable := next.Admin.System != nil && next.Admin.System.DisableAutoMigrate
|
||||
if err = prepareConfiguredDatabase(ctx, candidateDB, next.Data.Database, d.catalog, d.logger(), configPath, disable); err != nil {
|
||||
return fmt.Errorf("reload database upgrade: %w", err)
|
||||
}
|
||||
}
|
||||
storageConfig, err := dataintegration.ResolveStorageConfig(candidateDB.WithContext(ctx), next.Admin.Storage)
|
||||
|
||||
+6
-11
@@ -271,8 +271,13 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
|
||||
}
|
||||
usingFallback = true
|
||||
}
|
||||
d.databaseReady.Store(!usingFallback)
|
||||
d.gormDB = newReloadableDB(db)
|
||||
if !usingFallback {
|
||||
if err = prepareConfiguredDatabase(context.Background(), db, c.Database, catalog, appLogger, runtime.ConfigPath(), disableAutoMigrate); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
d.databaseReady.Store(!usingFallback)
|
||||
d.auditLog = newDataScopeAuditWriter(d, appLogger)
|
||||
registerDataScopeCallbacks(db, d.enqueueDataScopeAudit)
|
||||
d.dbList, err = openDatabaseList(c.DatabaseList, appLogger)
|
||||
@@ -282,16 +287,6 @@ func NewData(runtime *config.Store, appLogger *slog.Logger, storageManager *stor
|
||||
for _, item := range d.dbList {
|
||||
registerDataScopeCallbacks(item, d.enqueueDataScopeAudit)
|
||||
}
|
||||
if !usingFallback && !disableAutoMigrate {
|
||||
if err = migrateAll(db, catalog); err != nil {
|
||||
return nil, nil, fmt.Errorf("migrate tables: %w", err)
|
||||
}
|
||||
}
|
||||
if !usingFallback && disableAutoMigrate {
|
||||
if err = checkMigratedSchema(db, catalog); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
if !usingFallback {
|
||||
storageConfig, storageErr := dataintegration.ResolveStorageConfig(db, admin.Storage)
|
||||
if storageErr != nil {
|
||||
|
||||
@@ -4,17 +4,20 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
drivermysql "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"kra/internal/config"
|
||||
"kra/internal/data/swmigration"
|
||||
"kra/pkg/database/migration"
|
||||
"kra/pkg/module"
|
||||
)
|
||||
|
||||
// MigrateDatabase opens only the configured primary database. It does not
|
||||
// create a database, start a scheduler, open SDK clients, or initialize users.
|
||||
func MigrateDatabase(ctx context.Context, c *config.Database, catalog module.Catalog, logger *slog.Logger, checkOnly bool) error {
|
||||
func MigrateDatabase(ctx context.Context, c *config.Database, catalog module.Catalog, logger *slog.Logger, checkOnly bool, configPath ...string) error {
|
||||
if !databaseConnectionConfigured(c) {
|
||||
return fmt.Errorf("migration requires an existing configured database")
|
||||
}
|
||||
@@ -23,7 +26,23 @@ func MigrateDatabase(ctx context.Context, c *config.Database, catalog module.Cat
|
||||
if !checkOnly && migrationConfig.MaxOpenConns > 0 && migrationConfig.MaxOpenConns < 2 {
|
||||
migrationConfig.MaxOpenConns = 2
|
||||
}
|
||||
db, err := openDatabase(&migrationConfig, false, "", logger)
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
if normalizedDriver(c.Driver) == "mysql" && !checkOnly {
|
||||
var dsn string
|
||||
dsn, err = databaseDSN(&migrationConfig, "")
|
||||
if err == nil {
|
||||
var options *drivermysql.Config
|
||||
options, err = drivermysql.ParseDSN(dsn)
|
||||
if err == nil {
|
||||
options.ReadTimeout = 0
|
||||
options.WriteTimeout = 0
|
||||
db, err = openWithDriverConfig("mysql", options.FormatDSN(), &migrationConfig, logger)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
db, err = openDatabase(&migrationConfig, false, "", logger)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("open migration database: %w", err)
|
||||
}
|
||||
@@ -32,6 +51,17 @@ func MigrateDatabase(ctx context.Context, c *config.Database, catalog module.Cat
|
||||
if checkOnly {
|
||||
return checkMigratedSchema(db, catalog)
|
||||
}
|
||||
automatic, err := swmigration.Recognized(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if automatic {
|
||||
root := ""
|
||||
if len(configPath) > 0 && configPath[0] != "" {
|
||||
root = filepath.Join(filepath.Dir(configPath[0]), "migration-backups")
|
||||
}
|
||||
return swmigration.Run(ctx, db, catalog, root, logger)
|
||||
}
|
||||
// A dedicated connection owns the lock even when a migration uses a
|
||||
// transaction or temporarily changes connection-local SQL compatibility.
|
||||
run := func() error {
|
||||
@@ -67,6 +97,22 @@ func MigrateDatabase(ctx context.Context, c *config.Database, catalog module.Cat
|
||||
})
|
||||
}
|
||||
|
||||
// SW upgrades are automatic even for installations that previously disabled
|
||||
// generic table auto-migration. Other template databases keep that setting.
|
||||
func prepareConfiguredDatabase(ctx context.Context, db *gorm.DB, c *config.Database, catalog module.Catalog, logger *slog.Logger, configPath string, disable bool) error {
|
||||
automatic, err := swmigration.Recognized(db.Session(&gorm.Session{NewDB: true}).WithContext(ctx))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if automatic {
|
||||
return MigrateDatabase(ctx, c, catalog, logger, false, configPath)
|
||||
}
|
||||
if disable {
|
||||
return checkMigratedSchema(db, catalog)
|
||||
}
|
||||
return migrateAll(db, catalog)
|
||||
}
|
||||
|
||||
func checkMigratedSchema(db *gorm.DB, catalog module.Catalog) error {
|
||||
db = db.Session(&gorm.Session{NewDB: true})
|
||||
if err := migration.CheckApplied(db, catalog.MigrationSteps()); err != nil {
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
package swmigration
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func quoteName(value string) string { return "`" + strings.ReplaceAll(value, "`", "``") + "`" }
|
||||
|
||||
// backupDatabase writes affected data to a private, durable SQL archive.
|
||||
// Large raw-data tables are not rewritten by the upgrade; their original
|
||||
// schemas are included as comments. tab_promote is included with data because
|
||||
// its primary-key width changes. Cleanup snapshots include every drop target.
|
||||
func backupDatabase(ctx context.Context, db *gorm.DB, root, identity, phase string, cleanupTargets map[string]bool, logger *slog.Logger) (path, digest string, err error) {
|
||||
if root == "" {
|
||||
return "", "", fmt.Errorf("a persistent configuration directory is required for migration backups")
|
||||
}
|
||||
if err = os.MkdirAll(root, 0700); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
info, err := os.Lstat(root)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return "", "", fmt.Errorf("migration backup location must be a private directory")
|
||||
}
|
||||
if err = os.Chmod(root, 0700); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
key := sha256.Sum256([]byte(identity))
|
||||
name := fmt.Sprintf("sw-%x-%s-%d.sql.gz", key[:8], phase, time.Now().UnixNano())
|
||||
path = filepath.Join(root, name)
|
||||
partial := path + ".partial"
|
||||
file, err := os.OpenFile(partial, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
_ = os.Remove(partial)
|
||||
}
|
||||
}()
|
||||
hash := sha256.New()
|
||||
compressed, err := gzip.NewWriterLevel(io.MultiWriter(file, hash), gzip.BestSpeed)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer compressed.Close()
|
||||
writer := bufio.NewWriterSize(compressed, 128*1024)
|
||||
write := func(value string) error { _, err := writer.WriteString(value); return err }
|
||||
if err = write("-- Automatic SW upgrade backup. Restore only in a maintenance session.\n-- Contains affected/retired data; untouched raw-data schemas are comments.\nSET @sw_restore_mode=@@SESSION.sql_mode;\nSET @sw_restore_fk=@@SESSION.foreign_key_checks;\nSET @sw_restore_tz=@@SESSION.time_zone;\nSET SESSION sql_mode='NO_AUTO_VALUE_ON_ZERO';\nSET SESSION foreign_key_checks=0;\nSET SESSION time_zone='+00:00';\nSET NAMES utf8mb4;\n"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
pool, err := db.DB()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
conn, err := pool.Conn(ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer conn.Close()
|
||||
var oldTimeZone, oldSQLMode, oldClientCharset, oldCollation string
|
||||
var oldResultCharset sql.NullString
|
||||
if err = conn.QueryRowContext(ctx, "SELECT @@SESSION.time_zone,@@SESSION.sql_mode,@@SESSION.character_set_client,@@SESSION.character_set_results,@@SESSION.collation_connection").Scan(&oldTimeZone, &oldSQLMode, &oldClientCharset, &oldResultCharset, &oldCollation); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if _, err = conn.ExecContext(ctx, "SET SESSION time_zone='+00:00',sql_mode='NO_AUTO_VALUE_ON_ZERO'"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer func() {
|
||||
clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
var resultCharset any
|
||||
if oldResultCharset.Valid {
|
||||
resultCharset = oldResultCharset.String
|
||||
}
|
||||
_, restoreErr := conn.ExecContext(clean, "SET SESSION time_zone=?,sql_mode=?,character_set_client=?,character_set_results=?,collation_connection=?", oldTimeZone, oldSQLMode, oldClientCharset, resultCharset, oldCollation)
|
||||
if err == nil {
|
||||
err = restoreErr
|
||||
}
|
||||
}()
|
||||
if _, err = conn.ExecContext(ctx, "SET NAMES utf8mb4"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tx, err := conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true})
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var schema string
|
||||
if err = tx.QueryRowContext(ctx, "SELECT DATABASE()").Scan(&schema); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, "SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() AND table_type='BASE TABLE' ORDER BY table_name")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var tables []string
|
||||
for rows.Next() {
|
||||
var table string
|
||||
if err = rows.Scan(&table); err != nil {
|
||||
rows.Close()
|
||||
return "", "", err
|
||||
}
|
||||
if table != TableName {
|
||||
tables = append(tables, table)
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
for _, table := range tables {
|
||||
if err = ctx.Err(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if cleanupTargets != nil && !cleanupTargets[table] {
|
||||
continue
|
||||
}
|
||||
var unused, ddl string
|
||||
if err = tx.QueryRowContext(ctx, "SHOW CREATE TABLE "+quoteName(table)).Scan(&unused, &ddl); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
includeData := cleanupTargets != nil || !strings.HasPrefix(table, "tab_") || table == "tab_promote"
|
||||
if !includeData {
|
||||
if err = write("-- Retained raw table; schema reference only:\n-- " + strings.ReplaceAll(ddl, "\n", "\n-- ") + ";\n"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err = write("DROP TABLE IF EXISTS " + quoteName(table) + ";\n" + ddl + ";\n"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
columns, types, columnErr := backupColumns(ctx, tx, schema, table)
|
||||
if columnErr != nil {
|
||||
return "", "", columnErr
|
||||
}
|
||||
quoted := make([]string, len(columns))
|
||||
for i, col := range columns {
|
||||
quoted[i] = quoteName(col)
|
||||
}
|
||||
prefix := "INSERT INTO " + quoteName(table) + " (" + strings.Join(quoted, ",") + ") VALUES ("
|
||||
dataRows, queryErr := tx.QueryContext(ctx, "SELECT "+strings.Join(quoted, ",")+" FROM "+quoteName(table))
|
||||
if queryErr != nil {
|
||||
return "", "", queryErr
|
||||
}
|
||||
var count int64
|
||||
for dataRows.Next() {
|
||||
values := make([]any, len(columns))
|
||||
dest := make([]any, len(columns))
|
||||
for i := range values {
|
||||
dest[i] = &values[i]
|
||||
}
|
||||
if err = dataRows.Scan(dest...); err != nil {
|
||||
dataRows.Close()
|
||||
return "", "", err
|
||||
}
|
||||
literals := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
literals[i], err = backupLiteral(value, types[i])
|
||||
if err != nil {
|
||||
dataRows.Close()
|
||||
return "", "", fmt.Errorf("backup %s.%s: %w", table, columns[i], err)
|
||||
}
|
||||
}
|
||||
if err = write(prefix + strings.Join(literals, ",") + ");\n"); err != nil {
|
||||
dataRows.Close()
|
||||
return "", "", err
|
||||
}
|
||||
count++
|
||||
}
|
||||
err = dataRows.Err()
|
||||
dataRows.Close()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
logger.Info("migration backup table completed", "table", table, "rows", count, "phase", phase)
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = write("SET SESSION foreign_key_checks=@sw_restore_fk;\nSET SESSION sql_mode=@sw_restore_mode;\nSET SESSION time_zone=@sw_restore_tz;\n-- SW BACKUP COMPLETE\n"); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = writer.Flush(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = compressed.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = file.Sync(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = file.Close(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = os.Rename(partial, path); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
digest = hex.EncodeToString(hash.Sum(nil))
|
||||
if err = verifyBackup(path, digest); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
logger.Info("migration backup verified", "phase", phase, "path", path, "sha256", digest)
|
||||
return path, digest, nil
|
||||
}
|
||||
|
||||
func backupColumns(ctx context.Context, tx *sql.Tx, schema, table string) ([]string, []string, error) {
|
||||
rows, err := tx.QueryContext(ctx, "SELECT column_name,data_type,extra FROM information_schema.columns WHERE table_schema=? AND table_name=? ORDER BY ordinal_position", schema, table)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var names, types []string
|
||||
for rows.Next() {
|
||||
var name, kind, extra string
|
||||
if err := rows.Scan(&name, &kind, &extra); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if strings.Contains(extra, "VIRTUAL GENERATED") || strings.Contains(extra, "STORED GENERATED") {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
types = append(types, kind)
|
||||
}
|
||||
return names, types, rows.Err()
|
||||
}
|
||||
|
||||
func backupLiteral(value any, kind string) (string, error) {
|
||||
if value == nil {
|
||||
return "NULL", nil
|
||||
}
|
||||
var raw []byte
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
raw = v
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
case time.Time:
|
||||
if v.IsZero() {
|
||||
raw = []byte("0000-00-00 00:00:00")
|
||||
} else {
|
||||
raw = []byte(v.Format("2006-01-02 15:04:05.999999"))
|
||||
}
|
||||
case int64:
|
||||
raw = []byte(strconv.FormatInt(v, 10))
|
||||
case uint64:
|
||||
raw = []byte(strconv.FormatUint(v, 10))
|
||||
case float64:
|
||||
raw = []byte(strconv.FormatFloat(v, 'g', -1, 64))
|
||||
case bool:
|
||||
if v {
|
||||
raw = []byte("1")
|
||||
} else {
|
||||
raw = []byte("0")
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported SQL value %T", value)
|
||||
}
|
||||
if kind == "bit" {
|
||||
if v, ok := value.(int64); ok {
|
||||
return strconv.FormatInt(v, 10), nil
|
||||
}
|
||||
if v, ok := value.(uint64); ok {
|
||||
return strconv.FormatUint(v, 10), nil
|
||||
}
|
||||
return new(big.Int).SetBytes(raw).String(), nil
|
||||
}
|
||||
encoded := "FROM_BASE64('" + base64.StdEncoding.EncodeToString(raw) + "')"
|
||||
switch kind {
|
||||
case "binary", "varbinary", "blob", "tinyblob", "mediumblob", "longblob":
|
||||
return encoded, nil
|
||||
case "geometry", "point", "linestring", "polygon", "multipoint", "multilinestring", "multipolygon", "geometrycollection":
|
||||
return "", fmt.Errorf("unsupported geometry backup type %s", kind)
|
||||
default:
|
||||
return "CONVERT(" + encoded + " USING utf8mb4)", nil
|
||||
}
|
||||
}
|
||||
|
||||
func verifyBackup(path, expected string) error {
|
||||
if path == "" || len(expected) != 64 {
|
||||
return fmt.Errorf("verified migration backup is missing")
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open migration backup: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err = io.Copy(hash, file); err != nil {
|
||||
return err
|
||||
}
|
||||
if hex.EncodeToString(hash.Sum(nil)) != expected {
|
||||
return fmt.Errorf("migration backup checksum mismatch: %s", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package swmigration
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
assets "kra/deploy/migrations"
|
||||
)
|
||||
|
||||
func backupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("SW_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("isolated MySQL required")
|
||||
}
|
||||
cfg, err := driver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") {
|
||||
t.Fatal("local MySQL required")
|
||||
}
|
||||
cfg.DBName = ""
|
||||
cfg.ParseTime = true
|
||||
admin, err := sql.Open("mysql", cfg.FormatDSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name := fmt.Sprintf("sw_test_backup_%d", time.Now().UnixNano())
|
||||
if _, err := admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.DBName = name
|
||||
db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, _ := db.DB()
|
||||
pool.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() {
|
||||
pool.Close()
|
||||
_, err := admin.Exec("DROP DATABASE `" + name + "`")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
admin.Close()
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMySQLBackupRoundTripPreservesStorageValues(t *testing.T) {
|
||||
source, target := backupTestDB(t), backupTestDB(t)
|
||||
for _, statement := range []string{
|
||||
"SET SESSION time_zone='+08:00'",
|
||||
"CREATE TABLE sys_fixture(id BIGINT UNSIGNED PRIMARY KEY,txt LONGTEXT,raw VARBINARY(16),amount DECIMAL(30,6),payload JSON,flags BIT(9),stamp TIMESTAMP(6) NULL,clock DATETIME(6),computed DECIMAL(31,6) GENERATED ALWAYS AS(amount*2) VIRTUAL)",
|
||||
"INSERT INTO sys_fixture(id,txt,raw,amount,payload,flags,stamp,clock) VALUES(18446744073709551615,'中文😀 quotes '' slash \\\\ line',X'00FF27225C','123456789012345678901234.123456','{\"id\":\"9007199254740993\"}',b'100000001','2026-02-03 04:05:06.123456','2026-02-03 04:05:06.123456'),(1,'',X'',NULL,NULL,b'0',NULL,NULL)",
|
||||
"CREATE TABLE tab_user(id INT PRIMARY KEY)", "INSERT INTO tab_user VALUES(1)",
|
||||
"CREATE TABLE tab_promote(id BIGINT PRIMARY KEY)", "INSERT INTO tab_promote VALUES(9007199254740993)",
|
||||
} {
|
||||
if err := source.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := target.Exec("CREATE TABLE tab_user(id INT PRIMARY KEY)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target.Exec("INSERT INTO tab_user VALUES(77)")
|
||||
root := t.TempDir()
|
||||
path, digest, err := backupDatabase(context.Background(), source, root, "test-db", "source", nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := verifyBackup(path, digest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader, err := gzip.NewReader(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := io.ReadAll(reader)
|
||||
reader.Close()
|
||||
file.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statements, err := assets.Statements(string(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := target.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
query := "SELECT CAST(id AS CHAR) AS id,txt,HEX(raw) AS raw,CAST(amount AS CHAR) AS amount,CAST(payload AS CHAR) AS payload,CAST(flags AS UNSIGNED) AS flags,UNIX_TIMESTAMP(stamp) AS stamp,CAST(clock AS CHAR) AS clock,CAST(computed AS CHAR) AS computed FROM sys_fixture ORDER BY id"
|
||||
var before, after []map[string]any
|
||||
if err := source.Raw(query).Scan(&before).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := target.Raw(query).Scan(&after).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(before) != fmt.Sprint(after) {
|
||||
t.Fatalf("SQL backup round trip changed storage values: before=%v after=%v", before, after)
|
||||
}
|
||||
var id uint64
|
||||
target.Table("tab_promote").Select("id").Scan(&id)
|
||||
if id != 9007199254740993 {
|
||||
t.Fatal("large promote ID changed")
|
||||
}
|
||||
target.Table("tab_user").Select("id").Scan(&id)
|
||||
if id != 77 {
|
||||
t.Fatal("schema-only raw table was overwritten")
|
||||
}
|
||||
var timezone string
|
||||
source.Raw("SELECT @@SESSION.time_zone").Scan(&timezone)
|
||||
if timezone != "+08:00" {
|
||||
t.Fatalf("backup leaked connection time zone: %s", timezone)
|
||||
}
|
||||
appendFile, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appendFile.WriteString("corrupt")
|
||||
appendFile.Close()
|
||||
if err := verifyBackup(path, digest); err == nil {
|
||||
t.Fatal("modified backup checksum accepted")
|
||||
}
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, _, err := backupDatabase(cancelled, source, root, "test-db", "cancelled", nil, slog.New(slog.NewTextHandler(io.Discard, nil))); err == nil {
|
||||
t.Fatal("cancelled backup succeeded")
|
||||
}
|
||||
partial, _ := filepath.Glob(filepath.Join(root, "*.partial"))
|
||||
if len(partial) != 0 {
|
||||
t.Fatalf("partial backup left behind: %v", partial)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package swmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
scripts "kra/deploy/migrations"
|
||||
)
|
||||
|
||||
func migrateFramework(ctx context.Context, db *gorm.DB, conn *sql.Conn, logger *slog.Logger) error {
|
||||
tables, err := tableSet(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tables["sw_kra_state"] {
|
||||
var previous struct{ Version, Phase string }
|
||||
if err := db.Table("sw_kra_state").Where("id=1").Take(&previous).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if previous.Version != "20261103_framework_migration_v5" {
|
||||
return fmt.Errorf("unsupported legacy migration version %q", previous.Version)
|
||||
}
|
||||
switch previous.Phase {
|
||||
case "completed":
|
||||
return nil
|
||||
case "data_validated":
|
||||
return finishArchives(ctx, db, conn)
|
||||
case "backup":
|
||||
// All framework data changes and the data_validated marker commit
|
||||
// together. In this phase only empty target DDL may survive a crash.
|
||||
if err := resetIncompleteFramework(ctx, db, conn, tables); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported partial migration phase %q", previous.Phase)
|
||||
}
|
||||
} else if !tables["sys_user"] {
|
||||
return fmt.Errorf("legacy source users and migration evidence are missing")
|
||||
}
|
||||
return executeScript(ctx, conn, scripts.FrameworkSQL(), false, logger)
|
||||
}
|
||||
|
||||
func resetIncompleteFramework(ctx context.Context, db *gorm.DB, conn *sql.Conn, tables map[string]bool) error {
|
||||
for source, archive := range scripts.LegacyArchives() {
|
||||
if !tables[source] || tables[archive] {
|
||||
return fmt.Errorf("incomplete framework sources are not recoverable: %s", source)
|
||||
}
|
||||
}
|
||||
created := scripts.FrameworkCreatedTables()
|
||||
sort.SliceStable(created, func(i, j int) bool {
|
||||
priority := func(name string) int {
|
||||
if name == "sw_kra_state" {
|
||||
return 2
|
||||
}
|
||||
if name == "sw_kra_checks" {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
return priority(created[i]) < priority(created[j])
|
||||
})
|
||||
for _, name := range created {
|
||||
if !tables[name] || strings.HasPrefix(name, "bak_sw2kra_") || name == "sw_kra_state" || name == "sw_kra_checks" {
|
||||
continue
|
||||
}
|
||||
var count int64
|
||||
if err := db.Table(name).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count != 0 {
|
||||
return fmt.Errorf("partial framework target %s contains committed data; automatic reset refused", name)
|
||||
}
|
||||
}
|
||||
for _, name := range created {
|
||||
if tables[name] {
|
||||
if _, err := conn.ExecContext(ctx, "DROP TABLE "+quoteName(name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func finishArchives(ctx context.Context, db *gorm.DB, conn *sql.Conn) error {
|
||||
tables, err := tableSet(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range scripts.FrameworkCreatedTables() {
|
||||
if !tables[name] {
|
||||
return fmt.Errorf("validated framework table is missing: %s", name)
|
||||
}
|
||||
}
|
||||
if err := verifyCheckRows(db); err != nil {
|
||||
return err
|
||||
}
|
||||
var moves []string
|
||||
for source, archive := range scripts.LegacyArchives() {
|
||||
if tables[source] == tables[archive] {
|
||||
return fmt.Errorf("ambiguous legacy archive state: %s", source)
|
||||
}
|
||||
if tables[source] {
|
||||
moves = append(moves, quoteName(source)+" TO "+quoteName(archive))
|
||||
}
|
||||
}
|
||||
sort.Strings(moves)
|
||||
if len(moves) > 0 {
|
||||
if _, err := conn.ExecContext(ctx, "RENAME TABLE "+strings.Join(moves, ",")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = conn.ExecContext(ctx, "UPDATE sw_kra_state SET phase='completed',completed_at=NOW(3) WHERE id=1")
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
package swmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
scripts "kra/deploy/migrations"
|
||||
"kra/pkg/database/migration"
|
||||
"kra/pkg/module"
|
||||
)
|
||||
|
||||
// Recognized identifies an old SW database or a previous automatic upgrade.
|
||||
// Database names are not used to infer ownership of unrelated schemas.
|
||||
func Recognized(db *gorm.DB) (bool, error) {
|
||||
if db.Dialector.Name() != "mysql" {
|
||||
return false, nil
|
||||
}
|
||||
tables, err := tableSet(db)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if tables["sw_kra_state"] || tables["sys_user"] && tables["tab_sw_report_day"] && tables["tab_sw_report_server_day"] {
|
||||
return true, nil
|
||||
}
|
||||
if !tables[TableName] {
|
||||
return false, nil
|
||||
}
|
||||
// The state table also exists in new template installations. Only a
|
||||
// recorded legacy workflow identifies a previously upgraded SW database.
|
||||
var count int64
|
||||
if err := db.Table(TableName).Where("name=?", workflowName).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func tableSet(db *gorm.DB) (map[string]bool, error) {
|
||||
var names []string
|
||||
if err := db.Raw("SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() AND table_type='BASE TABLE'").Scan(&names).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := map[string]bool{}
|
||||
for _, name := range names {
|
||||
set[name] = true
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, db *gorm.DB, catalog module.Catalog, backupRoot string, logger *slog.Logger) (runErr error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
db = db.Session(&gorm.Session{NewDB: true}).WithContext(ctx)
|
||||
pool, err := db.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
conn, err := pool.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
var schema, serverUUID string
|
||||
if err = conn.QueryRowContext(ctx, "SELECT DATABASE(),@@server_uuid").Scan(&schema, &serverUUID); err != nil {
|
||||
return err
|
||||
}
|
||||
if schema == "" {
|
||||
return fmt.Errorf("automatic upgrade requires a selected database")
|
||||
}
|
||||
var lockName string
|
||||
if err = conn.QueryRowContext(ctx, "SELECT CONCAT('sw_to_kra:',MD5(DATABASE()))").Scan(&lockName); err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
var locked sql.NullInt64
|
||||
if err = conn.QueryRowContext(ctx, "SELECT GET_LOCK(?,10)", lockName).Scan(&locked); err != nil {
|
||||
return err
|
||||
}
|
||||
if locked.Valid && locked.Int64 == 1 {
|
||||
break
|
||||
}
|
||||
if !locked.Valid {
|
||||
return fmt.Errorf("database migration lock is unavailable")
|
||||
}
|
||||
logger.Info("waiting for another database upgrade", "database", schema)
|
||||
}
|
||||
defer func() {
|
||||
clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
_, _ = conn.ExecContext(clean, "DO RELEASE_LOCK(?)", lockName)
|
||||
}()
|
||||
if err = StateMigration().Migrate(db); err != nil {
|
||||
return err
|
||||
}
|
||||
state := statePO{Name: workflowName}
|
||||
err = db.First(&state, "name=?", workflowName).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
state = statePO{Name: workflowName, Version: workflowVersion, Phase: "backup", DatabaseIdentity: serverUUID + ":" + schema, StartedAt: time.Now(), UpdatedAt: time.Now()}
|
||||
initial, readErr := tableSet(db)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
state.SourceWasLegacy = initial["sys_user"]
|
||||
if initial["sw_kra_state"] {
|
||||
var phase string
|
||||
if err := db.Table("sw_kra_state").Select("phase").Where("id=1").Scan(&phase).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
state.SourceWasLegacy = state.SourceWasLegacy || phase != "completed"
|
||||
}
|
||||
if err = db.Create(&state).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.Version != workflowVersion || state.DatabaseIdentity != serverUUID+":"+schema {
|
||||
return fmt.Errorf("automatic upgrade identity/version does not match this database")
|
||||
}
|
||||
started := time.Now()
|
||||
logger.Info("automatic business database upgrade started", "database", schema, "phase", state.Phase)
|
||||
defer func() {
|
||||
status := "completed"
|
||||
if runErr != nil {
|
||||
status = "failed"
|
||||
clean, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = db.Session(&gorm.Session{NewDB: true}).WithContext(clean).Model(&statePO{}).Where("name=?", workflowName).Updates(map[string]any{"last_error": runErr.Error(), "updated_at": time.Now()}).Error
|
||||
}
|
||||
logger.Info("automatic business database upgrade finished", "database", schema, "status", status, "elapsed_ms", time.Since(started).Milliseconds())
|
||||
}()
|
||||
save := func() error { state.UpdatedAt = time.Now(); return db.Save(&state).Error }
|
||||
stage := func(name string, work func() error) error {
|
||||
state.Phase = name
|
||||
state.LastError = ""
|
||||
if err := save(); err != nil {
|
||||
return err
|
||||
}
|
||||
begin := time.Now()
|
||||
logger.Info("database upgrade stage started", "stage", name)
|
||||
done := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(stopped)
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
logger.Info("database upgrade is still running", "stage", name, "elapsed_ms", time.Since(begin).Milliseconds())
|
||||
}
|
||||
}
|
||||
}()
|
||||
err := work()
|
||||
close(done)
|
||||
<-stopped
|
||||
if err != nil {
|
||||
return fmt.Errorf("database upgrade %s: %w", name, err)
|
||||
}
|
||||
logger.Info("database upgrade stage completed", "stage", name, "elapsed_ms", time.Since(begin).Milliseconds())
|
||||
return nil
|
||||
}
|
||||
resumingCleanup := state.CleanupBackupPath != "" && state.VerifiedAt != nil
|
||||
wasComplete := state.CompletedAt != nil
|
||||
if !resumingCleanup && !wasComplete {
|
||||
if state.BackupPath == "" {
|
||||
if err := stage("backup", func() error {
|
||||
var e error
|
||||
state.BackupPath, state.BackupSHA256, e = backupDatabase(ctx, db, backupRoot, state.DatabaseIdentity, "source", nil, logger)
|
||||
return e
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := save(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := verifyBackup(state.BackupPath, state.BackupSHA256); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := stage("framework", func() error { return migrateFramework(ctx, db, conn, logger) }); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := stage("migrations", func() error {
|
||||
if err := catalog.CheckPrerequisites(db); err != nil {
|
||||
return err
|
||||
}
|
||||
return migration.Run(db, catalog.MigrationSteps())
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := stage("verify", func() error {
|
||||
if err := migration.CheckApplied(db, catalog.MigrationSteps()); err != nil {
|
||||
return err
|
||||
}
|
||||
return catalog.CheckSchema(db)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
remaining, err := remainingTargets(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(remaining) > 0 {
|
||||
if wasComplete {
|
||||
return fmt.Errorf("retired tables reappeared after a completed upgrade; automatic deletion refused")
|
||||
}
|
||||
if !resumingCleanup && !wasComplete {
|
||||
if err := verifyLegacyChecks(db); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if state.SourceWasLegacy && !state.TasksRestored {
|
||||
if err := stage("tasks", func() error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Exec("UPDATE sys_timed_tasks t JOIN sw_kra_id_map m ON m.entity='task' AND m.new_id=t.id JOIN sj_job j ON CAST(j.id AS CHAR)=m.old_id SET t.enabled=(j.job_status=1) WHERE j.deleted=0").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&statePO{}).Where("name=?", workflowName).Update("tasks_restored", true).Error
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
state.TasksRestored = true
|
||||
}
|
||||
if state.CleanupBackupPath == "" {
|
||||
if err := stage("cleanup-backup", func() error {
|
||||
var e error
|
||||
state.CleanupBackupPath, state.CleanupSHA256, e = backupDatabase(ctx, db, backupRoot, state.DatabaseIdentity, "cleanup", remaining, logger)
|
||||
return e
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := save(); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := verifyBackup(state.CleanupBackupPath, state.CleanupSHA256); err != nil {
|
||||
return err
|
||||
}
|
||||
// The journal survives deletion of all old migration helpers. A restart
|
||||
// can finish a partially completed DROP list using the verified archive.
|
||||
now := time.Now()
|
||||
state.VerifiedAt = &now
|
||||
state.VerifiedMigrations = migrationHash(catalog.MigrationSteps())
|
||||
if err := stage("cleanup", func() error {
|
||||
// CALL can commit before a killed process reaches DROP PROCEDURE.
|
||||
// Remove only our own shipped routine before checking dependencies.
|
||||
if _, err := conn.ExecContext(ctx, "DROP PROCEDURE IF EXISTS sw_kra_migrate_20261103"); err != nil {
|
||||
return err
|
||||
}
|
||||
return executeScript(ctx, conn, scripts.CleanupSQL(), true, logger)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := migration.CheckApplied(db, catalog.MigrationSteps()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := catalog.CheckSchema(db); err != nil {
|
||||
return err
|
||||
}
|
||||
remaining, err = remainingTargets(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
return fmt.Errorf("legacy cleanup left %d tables", len(remaining))
|
||||
}
|
||||
now := time.Now()
|
||||
state.Phase = "complete"
|
||||
if state.CompletedAt == nil {
|
||||
state.CompletedAt = &now
|
||||
}
|
||||
state.VerifiedAt = &now
|
||||
state.LastError = ""
|
||||
state.VerifiedMigrations = migrationHash(catalog.MigrationSteps())
|
||||
return save()
|
||||
}
|
||||
|
||||
func migrationHash(steps []migration.Step) string {
|
||||
ids := make([]string, len(steps))
|
||||
for i, step := range steps {
|
||||
ids[i] = step.ID
|
||||
}
|
||||
sort.Strings(ids)
|
||||
sum := sha256.Sum256([]byte(strings.Join(ids, "\n")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func remainingTargets(db *gorm.DB) (map[string]bool, error) {
|
||||
tables, err := tableSet(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
remaining := map[string]bool{}
|
||||
for _, name := range scripts.CleanupTables() {
|
||||
if tables[name] {
|
||||
remaining[name] = true
|
||||
}
|
||||
}
|
||||
return remaining, nil
|
||||
}
|
||||
|
||||
func executeScript(ctx context.Context, conn *sql.Conn, source string, cleanup bool, logger *slog.Logger) error {
|
||||
statements, err := scripts.Statements(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, statement := range statements {
|
||||
upper := strings.ToUpper(strings.TrimSpace(statement))
|
||||
if strings.HasPrefix(upper, "USE ") {
|
||||
continue
|
||||
}
|
||||
if cleanup && strings.HasPrefix(upper, "SET @SW_CLEANUP_EXECUTE") {
|
||||
statement = "SET @sw_cleanup_execute=1"
|
||||
}
|
||||
rows, err := conn.QueryContext(ctx, statement)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
columns, err := rows.Columns()
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
values := make([]any, len(columns))
|
||||
dest := make([]any, len(columns))
|
||||
for i := range values {
|
||||
dest[i] = &values[i]
|
||||
}
|
||||
if err := rows.Scan(dest...); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
// Result sets contain progress/metadata only, never copied rows.
|
||||
if len(columns) > 0 && (columns[0] == "dropped_table" || columns[0] == "result") {
|
||||
attrs := make([]any, 0, len(columns)*2)
|
||||
for i, name := range columns {
|
||||
value := values[i]
|
||||
if b, ok := value.([]byte); ok {
|
||||
value = string(b)
|
||||
}
|
||||
attrs = append(attrs, name, value)
|
||||
}
|
||||
logger.Info("database migration SQL result", attrs...)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if !rows.NextResultSet() {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyLegacyChecks(db *gorm.DB) error {
|
||||
tables, err := tableSet(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !tables["sw_kra_state"] || !tables["sw_kra_checks"] {
|
||||
return fmt.Errorf("legacy migration evidence is missing; cleanup refused")
|
||||
}
|
||||
var completed int64
|
||||
if err := db.Table("sw_kra_state").Where("id=1 AND version=? AND phase='completed' AND completed_at IS NOT NULL", "20261103_framework_migration_v5").Count(&completed).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if completed != 1 {
|
||||
return fmt.Errorf("legacy framework migration is incomplete")
|
||||
}
|
||||
if err := verifyCheckRows(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if tables["legacy_sw_20261103_sys_user"] && tables["sw_kra_id_map"] {
|
||||
var archived, mapped int64
|
||||
if err := db.Table("legacy_sw_20261103_sys_user").Where("del_flag='0'").Count(&archived).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Table("sw_kra_id_map").Where("entity='user'").Count(&mapped).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if archived != mapped {
|
||||
return fmt.Errorf("legacy accounts changed during conversion; cleanup refused")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyCheckRows(db *gorm.DB) error {
|
||||
var invalid, backups, checks int64
|
||||
if err := db.Table("sw_kra_checks").Where("passed<>1 OR expected_value IS NULL OR actual_value IS NULL OR expected_value<>actual_value").Count(&invalid).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Table("sw_kra_checks").Where("stage='backup'").Distinct("check_name").Count(&backups).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Table("sw_kra_checks").Where("stage='migration'").Distinct("check_name").Count(&checks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if invalid != 0 || backups != 52 || checks != 24 {
|
||||
return fmt.Errorf("legacy migration validation is incomplete; cleanup refused")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
package swmigration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
assets "kra/deploy/migrations"
|
||||
biz "kra/internal/biz/business"
|
||||
bizsystem "kra/internal/biz/system"
|
||||
"kra/internal/config"
|
||||
rootdata "kra/internal/data"
|
||||
data "kra/internal/data/business"
|
||||
"kra/internal/data/swmigration"
|
||||
"kra/internal/integration/storage"
|
||||
"kra/internal/modules"
|
||||
"kra/pkg/database/migration"
|
||||
)
|
||||
|
||||
func localDatabase(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("SW_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set SW_TEST_MYSQL_DSN for isolated MySQL upgrade tests")
|
||||
}
|
||||
cfg, err := driver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Net != "tcp" || !strings.HasPrefix(cfg.Addr, "127.0.0.1:") {
|
||||
t.Fatal("upgrade tests require an isolated local 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_upgrade_%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)
|
||||
}
|
||||
cfg.DBName = name
|
||||
db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, _ := db.DB()
|
||||
pool.SetMaxOpenConns(4)
|
||||
t.Cleanup(func() {
|
||||
pool.Close()
|
||||
_, err := admin.Exec("DROP DATABASE `" + name + "`")
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
admin.Close()
|
||||
})
|
||||
return db
|
||||
}
|
||||
|
||||
func legacyFixture(t *testing.T, db *gorm.DB) string {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("testdata/legacy-schema.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statements, err := assets.Statements(string(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var surface struct{ Menus, Jobs []map[string]any }
|
||||
file, err := os.Open("testdata/legacy-surface.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoder := json.NewDecoder(file)
|
||||
decoder.UseNumber()
|
||||
err = decoder.Decode(&surface)
|
||||
file.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
password, err := bcrypt.GenerateFromPassword([]byte("Fixture-password-2026"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roleIDs := []string{"1", "2083497078545207297", "2087435436573081601", "2087361690139152385"}
|
||||
userIDs := []string{"9007199254740993", "9007199254740995", "9007199254740997", "9007199254740999"}
|
||||
for level, id := range roleIDs {
|
||||
key := fmt.Sprintf("business%d", level)
|
||||
if level == 0 {
|
||||
key = "superadmin"
|
||||
}
|
||||
insertLegacy(t, db, "sys_role", map[string]any{"role_id": id, "role_name": fmt.Sprintf("renamed-role-%d", level), "role_key": key, "role_sort": level, "data_scope": "1", "status": "0", "del_flag": "0", "create_time": "2026-01-01 08:00:00", "update_time": "2026-01-01 08:00:00"})
|
||||
parent := "0"
|
||||
path := "0"
|
||||
if level > 1 {
|
||||
parent = userIDs[level-1]
|
||||
path = "0," + strings.Join(userIDs[1:level], ",")
|
||||
}
|
||||
insertLegacy(t, db, "sys_user", map[string]any{"user_id": userIDs[level], "user_name": fmt.Sprintf("fixture-user-%d", level), "nick_name": fmt.Sprintf("fixture-%d", level), "password": string(password), "status": "0", "del_flag": "0", "level": level, "parent_id": parent, "id_path": path, "create_time": "2026-01-01 08:00:00", "update_time": "2026-01-01 08:00:00"})
|
||||
insertLegacy(t, db, "sys_user_role", map[string]any{"user_id": userIDs[level], "role_id": id})
|
||||
insertLegacy(t, db, "sys_user_preference", map[string]any{"user_id": userIDs[level], "include_sub_promote": 1, "idcard_dedup": 1, "update_time": "2026-01-01 08:00:00"})
|
||||
}
|
||||
for _, menu := range surface.Menus {
|
||||
insertLegacy(t, db, "sys_menu", menu)
|
||||
for _, id := range roleIDs {
|
||||
insertLegacy(t, db, "sys_role_menu", map[string]any{"role_id": id, "menu_id": menu["menu_id"]})
|
||||
}
|
||||
}
|
||||
for i, job := range surface.Jobs {
|
||||
job["id"] = i + 1
|
||||
job["job_name"] = fmt.Sprintf("fixture-job-%d", i)
|
||||
job["biz_id"] = fmt.Sprintf("fixture-job-%d", i)
|
||||
job["namespace_id"] = "fixture"
|
||||
job["args_str"] = "{}"
|
||||
job["deleted"] = 0
|
||||
insertLegacy(t, db, "sj_job", job)
|
||||
}
|
||||
insertLegacy(t, db, "sys_user_bind_platform", map[string]any{"user_id": userIDs[3], "source_db": "lx", "platform_busier_id": 14, "platform_busier_account": "fixture-sdk", "status": "0", "create_time": "2026-01-01 08:00:00"})
|
||||
insertLegacy(t, db, "sys_oss", map[string]any{"oss_id": "9007199254741093", "file_name": "fixture.png", "original_name": "fixture.png", "file_suffix": "png", "url": "https://example.invalid/fixture.png", "ext1": "{}", "create_by": userIDs[3], "create_time": "2026-01-01 08:00:00"})
|
||||
insertLegacy(t, db, "sys_alert_rule", map[string]any{"id": 1, "rule_name": "fixture-rule", "rule_type": "fixture", "monitor_metric": 1, "monitor_scope": 1, "warning_color": "red", "creator_id": userIDs[3], "enabled": 1, "is_deleted": 0, "created_at": "2026-01-01 08:00:00", "updated_at": "2026-01-01 08:00:00"})
|
||||
insertLegacy(t, db, "sys_alert_record", map[string]any{"id": "9007199254740993", "platform_code": "lx", "platform_name": "fixture", "rule_id": 1, "rule_name": "fixture-rule", "rule_type": "fixture", "target_promote_id": 100, "business_id": userIDs[3], "business_name": "fixture", "status": 1, "alert_date": "2026-01-01", "created_at": "2026-01-01 08:00:00", "updated_at": "2026-01-01 08:00:00"})
|
||||
insertLegacy(t, db, "sys_alert_operation_log", map[string]any{"id": 1, "alert_id": "9007199254740993", "operator_id": userIDs[3], "log_type": 1, "attachment_ids": "[9007199254741093]", "created_at": "2026-01-01 08:00:00"})
|
||||
insertLegacy(t, db, "tab_promote", map[string]any{"id": 100, "source_db": "lx", "account": "fixture-channel", "promote_level": 1, "busier_id": 14})
|
||||
insertLegacy(t, db, "tab_user", map[string]any{"id": 1, "source_db": "lx", "account": "fixture-player", "promote_id": 100, "puid": 0, "register_time": "1767225600"})
|
||||
insertLegacy(t, db, "tab_spend", map[string]any{"id": 1, "source_db": "lx", "user_id": 1, "promote_id": 100, "pay_status": 1, "pay_amount": "12.34", "pay_time": 1767225600, "pay_order_number": "fixture-order"})
|
||||
if err := db.Exec("ALTER TABLE tab_promote AUTO_INCREMENT=2147483640").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(password)
|
||||
}
|
||||
|
||||
func insertLegacy(t *testing.T, db *gorm.DB, table string, values map[string]any) {
|
||||
t.Helper()
|
||||
var columns []struct {
|
||||
ColumnName, DataType, Extra, IsNullable string
|
||||
ColumnDefault *string
|
||||
}
|
||||
if err := db.Raw("SELECT column_name,data_type,extra,is_nullable,column_default FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name=? ORDER BY ordinal_position", table).Scan(&columns).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
known := map[string]bool{}
|
||||
for _, column := range columns {
|
||||
known[column.ColumnName] = true
|
||||
if _, exists := values[column.ColumnName]; exists || column.ColumnDefault != nil || column.IsNullable == "YES" || strings.Contains(column.Extra, "auto_increment") || strings.Contains(column.Extra, "GENERATED") {
|
||||
continue
|
||||
}
|
||||
var value any = "fixture"
|
||||
switch column.DataType {
|
||||
case "tinyint", "smallint", "mediumint", "int", "bigint", "decimal", "double", "float", "bit":
|
||||
value = 0
|
||||
case "datetime", "timestamp":
|
||||
value = "2026-01-01 08:00:00"
|
||||
case "date":
|
||||
value = "2026-01-01"
|
||||
case "time":
|
||||
value = "00:00:00"
|
||||
case "json":
|
||||
value = "{}"
|
||||
}
|
||||
values[column.ColumnName] = value
|
||||
}
|
||||
var names []string
|
||||
for name := range values {
|
||||
if !known[name] {
|
||||
t.Fatalf("unknown fixture column %s.%s", table, name)
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
args := make([]any, len(names))
|
||||
marks := make([]string, len(names))
|
||||
quoted := make([]string, len(names))
|
||||
for i, name := range names {
|
||||
args[i] = values[name]
|
||||
marks[i] = "?"
|
||||
quoted[i] = "`" + name + "`"
|
||||
}
|
||||
if err := db.Exec("INSERT INTO `"+table+"` ("+strings.Join(quoted, ",")+") VALUES ("+strings.Join(marks, ",")+")", args...).Error; err != nil {
|
||||
t.Fatalf("seed %s: %v", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
type provider struct{ db *gorm.DB }
|
||||
|
||||
func (p provider) DB() *gorm.DB { return p.db }
|
||||
|
||||
func assertComplete(t *testing.T, db *gorm.DB, password string) {
|
||||
t.Helper()
|
||||
if err := modules.Catalog().CheckSchema(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range assets.CleanupTables() {
|
||||
if db.Migrator().HasTable(name) {
|
||||
t.Fatalf("retired table remains: %s", name)
|
||||
}
|
||||
}
|
||||
var phase string
|
||||
db.Table(swmigration.TableName).Select("phase").Where("name='legacy-sw'").Scan(&phase)
|
||||
if phase != "complete" {
|
||||
t.Fatalf("phase=%q", phase)
|
||||
}
|
||||
var users []struct {
|
||||
ID, AuthorityID uint
|
||||
Password string
|
||||
}
|
||||
if err := db.Table("sys_users").Order("id").Find(&users).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(users) != 4 {
|
||||
t.Fatalf("migrated users=%d", len(users))
|
||||
}
|
||||
for _, user := range users {
|
||||
if user.Password != password {
|
||||
t.Fatal("password bytes changed")
|
||||
}
|
||||
}
|
||||
var count int64
|
||||
db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_type='BASE TABLE'").Scan(&count)
|
||||
if count != 78 {
|
||||
t.Fatalf("final table count=%d, want 78", count)
|
||||
}
|
||||
db.Table("tab_user").Count(&count)
|
||||
if count != 1 {
|
||||
t.Fatal("raw user data changed")
|
||||
}
|
||||
var next uint64
|
||||
db.Raw("SELECT auto_increment FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='tab_promote'").Scan(&next)
|
||||
if next != 2147483640 {
|
||||
t.Fatalf("auto-increment changed: %d", next)
|
||||
}
|
||||
var surface struct{ Jobs []map[string]any }
|
||||
fixture, err := os.Open("testdata/legacy-surface.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoder := json.NewDecoder(fixture)
|
||||
decoder.UseNumber()
|
||||
err = decoder.Decode(&surface)
|
||||
fixture.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedEnabled := int64(2) // new incremental and full effectiveness jobs
|
||||
for _, job := range surface.Jobs {
|
||||
if fmt.Sprint(job["job_status"]) == "1" {
|
||||
expectedEnabled++
|
||||
}
|
||||
}
|
||||
db.Table("sys_timed_tasks").Where("deleted_at IS NULL AND enabled=1").Count(&count)
|
||||
if count != expectedEnabled {
|
||||
t.Fatalf("legacy task enable states not restored: got %d want %d", count, expectedEnabled)
|
||||
}
|
||||
var attachments string
|
||||
db.Table("sys_alert_operation_log").Select("attachment_ids").Where("id=1").Scan(&attachments)
|
||||
if attachments == "[9007199254741093]" || attachments == "[]" {
|
||||
t.Fatalf("attachment migration did not finish before cleanup: %s", attachments)
|
||||
}
|
||||
var id uint
|
||||
uc := biz.NewAccountUsecase(data.NewAccountRepo(provider{db}))
|
||||
for level := 1; level <= 3; level++ {
|
||||
actor := bizsystem.Actor{UserID: users[0].ID, AuthorityID: 888}
|
||||
if level > 1 {
|
||||
var aid uint
|
||||
db.Table("sys_users").Select("authority_id").Where("id=?", id).Scan(&aid)
|
||||
actor = bizsystem.Actor{UserID: id, AuthorityID: aid}
|
||||
}
|
||||
created, err := uc.CreateChild(bizsystem.NewActorContext(context.Background(), actor), &biz.Account{Username: fmt.Sprintf("after-cleanup-%d", level), Password: "fixture-password", RealName: "fixture"})
|
||||
if err != nil {
|
||||
t.Fatalf("create business level %d after map removal: %v", level, err)
|
||||
}
|
||||
id = created.ID
|
||||
if created.Level != level {
|
||||
t.Fatalf("created level %d instead of %d", created.Level, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLAutomaticUpgradeAndRestart(t *testing.T) {
|
||||
db := localDatabase(t)
|
||||
password := legacyFixture(t, db)
|
||||
root := t.TempDir()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertComplete(t, db, password)
|
||||
archives, err := filepath.Glob(filepath.Join(root, "*.sql.gz"))
|
||||
if err != nil || len(archives) != 2 {
|
||||
t.Fatalf("backup archives=%v %v", archives, err)
|
||||
}
|
||||
if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err != nil {
|
||||
t.Fatalf("restart after cleanup: %v", err)
|
||||
}
|
||||
var count int64
|
||||
db.Table("sys_users").Count(&count)
|
||||
if count != 7 {
|
||||
t.Fatal("restart reset created users")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLUpgradeRecognitionRequiresLegacyEvidence(t *testing.T) {
|
||||
db := localDatabase(t)
|
||||
if err := swmigration.StateMigration().Migrate(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertRecognized := func(want bool) {
|
||||
t.Helper()
|
||||
got, err := swmigration.Recognized(db)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("legacy recognition = %v, want %v: %v", got, want, err)
|
||||
}
|
||||
}
|
||||
// Every fresh template installation creates this table, even when it has
|
||||
// never contained legacy SW data or started the legacy upgrade workflow.
|
||||
assertRecognized(false)
|
||||
if err := db.Exec("INSERT INTO sys_business_upgrade_state(name,version,phase,database_identity) VALUES('another-workflow','v1','complete','fixture')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertRecognized(false)
|
||||
if err := db.Exec("UPDATE sys_business_upgrade_state SET name='legacy-sw'").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertRecognized(true)
|
||||
}
|
||||
|
||||
func TestMySQLUpgradeResumesAfterIndexFailure(t *testing.T) {
|
||||
db := localDatabase(t)
|
||||
password := legacyFixture(t, db)
|
||||
root := t.TempDir()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := db.Exec("CREATE INDEX idx_sw_user_registration ON tab_user(id)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err == nil {
|
||||
t.Fatal("incompatible existing index accepted")
|
||||
}
|
||||
if !db.Migrator().HasTable("sw_kra_id_map") || !db.Migrator().HasTable("legacy_sw_20261103_sys_user") {
|
||||
t.Fatal("failed index migration deleted recovery data")
|
||||
}
|
||||
if err := db.Exec("DROP INDEX idx_sw_user_registration ON tab_user").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err != nil {
|
||||
t.Fatalf("resume indexes: %v", err)
|
||||
}
|
||||
assertComplete(t, db, password)
|
||||
}
|
||||
|
||||
func TestMySQLUpgradeResumesPartialCleanupOnlyWithVerifiedBackup(t *testing.T) {
|
||||
db := localDatabase(t)
|
||||
password := legacyFixture(t, db)
|
||||
root := t.TempDir()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := db.Exec("CREATE VIEW retained_job_view AS SELECT id FROM sj_job").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err == nil {
|
||||
t.Fatal("cleanup ignored a dependent view")
|
||||
}
|
||||
var path string
|
||||
if err := db.Table(swmigration.TableName).Select("cleanup_backup_path").Where("name='legacy-sw'").Scan(&path).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simulate a crash after an earlier table was dropped. Its data is already
|
||||
// in the completed cleanup archive, so restarting must not replace it with
|
||||
// a new snapshot of only the tables that remain.
|
||||
if err := db.Exec("DROP TABLE bak_sw2kra_20261103_sys_user").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(original, byte(1)), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err == nil || !strings.Contains(err.Error(), "checksum") {
|
||||
t.Fatalf("corrupt archive allowed deletion: %v", err)
|
||||
}
|
||||
if !db.Migrator().HasTable("sw_kra_id_map") {
|
||||
t.Fatal("mapping deleted without verified backup")
|
||||
}
|
||||
if err := os.WriteFile(path, original, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("DROP VIEW retained_job_view").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, log); err != nil {
|
||||
t.Fatalf("resume cleanup: %v", err)
|
||||
}
|
||||
assertComplete(t, db, password)
|
||||
}
|
||||
|
||||
func TestMySQLConcurrentAutomaticUpgrades(t *testing.T) {
|
||||
db := localDatabase(t)
|
||||
password := legacyFixture(t, db)
|
||||
root := t.TempDir()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
errors := make(chan error, 2)
|
||||
var group sync.WaitGroup
|
||||
for i := 0; i < 2; i++ {
|
||||
group.Add(1)
|
||||
go func() {
|
||||
defer group.Done()
|
||||
errors <- swmigration.Run(context.Background(), db, modules.Catalog(), root, log)
|
||||
}()
|
||||
}
|
||||
group.Wait()
|
||||
close(errors)
|
||||
for err := range errors {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
assertComplete(t, db, password)
|
||||
archives, _ := filepath.Glob(filepath.Join(root, "*.sql.gz"))
|
||||
if len(archives) != 2 {
|
||||
t.Fatalf("concurrent instances duplicated backups: %v", archives)
|
||||
}
|
||||
}
|
||||
|
||||
type cancelOnProgress struct {
|
||||
slog.Handler
|
||||
cancel context.CancelFunc
|
||||
once *sync.Once
|
||||
}
|
||||
|
||||
func (h cancelOnProgress) Handle(ctx context.Context, r slog.Record) error {
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
if a.Key == "result" && a.Value.String() == "旧框架备份已校验" {
|
||||
h.once.Do(h.cancel)
|
||||
}
|
||||
return true
|
||||
})
|
||||
return h.Handler.Handle(ctx, r)
|
||||
}
|
||||
func (h cancelOnProgress) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return cancelOnProgress{Handler: h.Handler.WithAttrs(attrs), cancel: h.cancel, once: h.once}
|
||||
}
|
||||
func (h cancelOnProgress) WithGroup(name string) slog.Handler {
|
||||
return cancelOnProgress{Handler: h.Handler.WithGroup(name), cancel: h.cancel, once: h.once}
|
||||
}
|
||||
|
||||
func TestMySQLUpgradeResumesInterruptedFrameworkDDL(t *testing.T) {
|
||||
db := localDatabase(t)
|
||||
password := legacyFixture(t, db)
|
||||
root := t.TempDir()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
log := slog.New(cancelOnProgress{Handler: slog.NewTextHandler(io.Discard, nil), cancel: cancel, once: &sync.Once{}})
|
||||
if err := swmigration.Run(ctx, db, modules.Catalog(), root, log); err == nil {
|
||||
t.Fatal("interrupted framework migration unexpectedly completed")
|
||||
}
|
||||
if err := swmigration.Run(context.Background(), db, modules.Catalog(), root, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||
t.Fatalf("recover interrupted DDL: %v", err)
|
||||
}
|
||||
assertComplete(t, db, password)
|
||||
}
|
||||
|
||||
func TestMySQLUpgradeFinishesCommittedArchiveWithoutRemapping(t *testing.T) {
|
||||
db := localDatabase(t)
|
||||
password := legacyFixture(t, db)
|
||||
pool, _ := db.DB()
|
||||
conn, err := pool.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statements, err := assets.Statements(assets.FrameworkSQL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if strings.HasPrefix(strings.ToUpper(statement), "USE ") {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.ExecContext(context.Background(), statement); err != nil {
|
||||
conn.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
conn.Close()
|
||||
if err := db.Exec("UPDATE sw_kra_state SET phase='data_validated',completed_at=NULL WHERE id=1").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := swmigration.Run(context.Background(), db, modules.Catalog(), t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||
t.Fatalf("resume committed conversion: %v", err)
|
||||
}
|
||||
assertComplete(t, db, password)
|
||||
}
|
||||
|
||||
func TestMySQLNativeStartupAutomaticallyUpgradesWithLegacyDisableFlag(t *testing.T) {
|
||||
db := localDatabase(t)
|
||||
password := legacyFixture(t, db)
|
||||
cfg, err := driver.ParseDSN(os.Getenv("SW_TEST_MYSQL_DSN"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Raw("SELECT DATABASE()").Scan(&cfg.DBName).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.ParseTime = true
|
||||
cfg.Loc = time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
cfg.ReadTimeout = time.Second
|
||||
root := t.TempDir()
|
||||
path := filepath.Join(root, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("admin: {}\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := &config.Admin{ConfigPath: path, System: &config.System{DisableAutoMigrate: true}, Local: &config.Local{StorePath: filepath.Join(root, "uploads")}}
|
||||
runtime := config.NewStore(&config.Config{Data: &config.Data{Database: &config.Database{Driver: "mysql", Source: cfg.FormatDSN(), LogMode: "silent", Engine: "InnoDB", MaxOpenConns: 1}}, Admin: admin})
|
||||
defer runtime.Close()
|
||||
store, err := storage.NewReloadable(admin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d, closeData, err := rootdata.NewData(runtime, slog.New(slog.NewTextHandler(io.Discard, nil)), store, modules.Catalog())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer closeData()
|
||||
if !d.DatabaseReady() {
|
||||
t.Fatal("database was not ready after automatic startup")
|
||||
}
|
||||
assertComplete(t, db, password)
|
||||
files, _ := filepath.Glob(filepath.Join(root, "migration-backups", "*.sql.gz"))
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("automatic private backups not created: %v", files)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLExistingMigratedInstallationPreservesTaskChoices(t *testing.T) {
|
||||
db := localDatabase(t)
|
||||
legacyFixture(t, db)
|
||||
pool, _ := db.DB()
|
||||
conn, err := pool.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statements, err := assets.Statements(assets.FrameworkSQL())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if strings.HasPrefix(strings.ToUpper(statement), "USE ") {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.ExecContext(context.Background(), statement); err != nil {
|
||||
conn.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
conn.Close()
|
||||
var oldSteps []migration.Step
|
||||
for _, step := range modules.Catalog().MigrationSteps() {
|
||||
if !strings.HasPrefix(step.ID, "202611060024_") && !strings.HasPrefix(step.ID, "202611060025_") {
|
||||
oldSteps = append(oldSteps, step)
|
||||
}
|
||||
}
|
||||
if err := migration.Run(db, oldSteps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("UPDATE sys_timed_tasks SET enabled=(id=1)").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var before, after []map[string]any
|
||||
if err := db.Table("sys_timed_tasks").Select("id,name,method_name,spec,params,enabled").Order("id").Find(&before).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := swmigration.Run(context.Background(), db, modules.Catalog(), t.TempDir(), slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table("sys_timed_tasks").Select("id,name,method_name,spec,params,enabled").Order("id").Find(&after).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(before, after) {
|
||||
t.Fatal("automatic cleanup changed existing task configuration")
|
||||
}
|
||||
for _, name := range assets.CleanupTables() {
|
||||
if db.Migrator().HasTable(name) {
|
||||
t.Fatalf("cleanup omitted %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package swmigration
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"kra/pkg/database/migration"
|
||||
"time"
|
||||
)
|
||||
|
||||
const TableName = "sys_business_upgrade_state"
|
||||
const workflowName = "legacy-sw"
|
||||
const workflowVersion = "20261106_automatic_v1"
|
||||
|
||||
type statePO struct {
|
||||
Name string `gorm:"primaryKey;size:64"`
|
||||
Version string `gorm:"size:64;not null"`
|
||||
Phase string `gorm:"size:32;not null"`
|
||||
DatabaseIdentity string `gorm:"size:128;not null"`
|
||||
SourceWasLegacy bool
|
||||
TasksRestored bool
|
||||
BackupPath string `gorm:"type:text"`
|
||||
BackupSHA256 string `gorm:"size:64"`
|
||||
CleanupBackupPath string `gorm:"type:text"`
|
||||
CleanupSHA256 string `gorm:"size:64"`
|
||||
VerifiedMigrations string `gorm:"size:64"`
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
VerifiedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
LastError string `gorm:"type:text"`
|
||||
}
|
||||
|
||||
func (statePO) TableName() string { return TableName }
|
||||
|
||||
func StateMigration() migration.Step {
|
||||
return migration.Step{ID: "202611060025_automatic_upgrade_state", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &statePO{}) }}
|
||||
}
|
||||
|
||||
func CheckSchema(db *gorm.DB) error { return migration.CheckModels(db, &statePO{}) }
|
||||
+1672
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,544 @@
|
||||
{
|
||||
"menus": [
|
||||
{
|
||||
"menu_id": "2084194096229949441",
|
||||
"menu_name": "商务管理",
|
||||
"parent_id": "0",
|
||||
"order_num": 1,
|
||||
"path": "BusMgmt",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "M",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": null,
|
||||
"icon": "category"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084194830778408961",
|
||||
"menu_name": "商务列表",
|
||||
"parent_id": "2084194096229949441",
|
||||
"order_num": 1,
|
||||
"path": "BusinessList",
|
||||
"component": "BusMgmt/BusinessList",
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "C",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:user:list",
|
||||
"icon": "email"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084202636386541569",
|
||||
"menu_name": "渠道列表",
|
||||
"parent_id": "2084194096229949441",
|
||||
"order_num": 2,
|
||||
"path": "ChannelList",
|
||||
"component": "BusMgmt/ChannelList",
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "C",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:promote:list",
|
||||
"icon": "chart"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084224284976246785",
|
||||
"menu_name": "数据分析",
|
||||
"parent_id": "0",
|
||||
"order_num": 2,
|
||||
"path": "DataAnalysis",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "M",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": null,
|
||||
"icon": "international"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084224583841378306",
|
||||
"menu_name": "渠道数据统计",
|
||||
"parent_id": "2084224284976246785",
|
||||
"order_num": 1,
|
||||
"path": "ChannelDataStatistics",
|
||||
"component": "DataAnalysis/ChannelDataStatistics",
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "C",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:channelData:list",
|
||||
"icon": "color"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084449385185308674",
|
||||
"menu_name": "充值明细",
|
||||
"parent_id": "2084224284976246785",
|
||||
"order_num": 2,
|
||||
"path": "RechargeDetails",
|
||||
"component": "DataAnalysis/RechargeDetails",
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "C",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:spend:list",
|
||||
"icon": "dict"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084453258847350785",
|
||||
"menu_name": "注册明细",
|
||||
"parent_id": "2084224284976246785",
|
||||
"order_num": 3,
|
||||
"path": "RegistrationDetails",
|
||||
"component": "DataAnalysis/RegistrationDetails",
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "C",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:register:list",
|
||||
"icon": "cascader"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084458436631752706",
|
||||
"menu_name": "游戏数据",
|
||||
"parent_id": "2084224284976246785",
|
||||
"order_num": 4,
|
||||
"path": "GameAnalytics",
|
||||
"component": "DataAnalysis/GameAnalytics",
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "C",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:gameData:list",
|
||||
"icon": "education"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084466605093318657",
|
||||
"menu_name": "区服分析",
|
||||
"parent_id": "2084224284976246785",
|
||||
"order_num": 5,
|
||||
"path": "ServerAnalysis",
|
||||
"component": "DataAnalysis/ServerAnalysis",
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "C",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:serverAnalysis:list",
|
||||
"icon": "excel"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084475309624684546",
|
||||
"menu_name": "排行榜",
|
||||
"parent_id": "2084224284976246785",
|
||||
"order_num": 5,
|
||||
"path": "RankingAnalysis",
|
||||
"component": "DataAnalysis/RankingAnalysis",
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "N",
|
||||
"menu_type": "C",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:rank:channel",
|
||||
"icon": "star"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084478005203202049",
|
||||
"menu_name": "预警中心",
|
||||
"parent_id": "0",
|
||||
"order_num": 3,
|
||||
"path": "AlertCenter",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "M",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": null,
|
||||
"icon": "process-definition"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084480429515108353",
|
||||
"menu_name": "预警列表",
|
||||
"parent_id": "2084478005203202049",
|
||||
"order_num": 1,
|
||||
"path": "AlertList",
|
||||
"component": "AlertCenter/AlertList",
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "N",
|
||||
"menu_type": "C",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:alertRecord:list",
|
||||
"icon": "documentation"
|
||||
},
|
||||
{
|
||||
"menu_id": "2084480593562726401",
|
||||
"menu_name": "预警规则",
|
||||
"parent_id": "2084478005203202049",
|
||||
"order_num": 2,
|
||||
"path": "AlertRules",
|
||||
"component": "AlertCenter/AlertRules",
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "N",
|
||||
"menu_type": "C",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:alertRule:list",
|
||||
"icon": "example"
|
||||
},
|
||||
{
|
||||
"menu_id": "2086033702105710593",
|
||||
"menu_name": "审核",
|
||||
"parent_id": "2084480429515108353",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:alertRecord:audit",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2086034409621880833",
|
||||
"menu_name": "详情接口",
|
||||
"parent_id": "2084480429515108353",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:alertRecord:query",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2086621845875916801",
|
||||
"menu_name": "添加备注",
|
||||
"parent_id": "2084480429515108353",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:alertRecord:process",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2086623544338350082",
|
||||
"menu_name": "上传图片",
|
||||
"parent_id": "2084480429515108353",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:oss:upload",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2086643768701812738",
|
||||
"menu_name": "查询",
|
||||
"parent_id": "2084480593562726401",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:alertRule:query",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2086643813702500354",
|
||||
"menu_name": "新增",
|
||||
"parent_id": "2084480593562726401",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:alertRule:add",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2086643981961199618",
|
||||
"menu_name": "编辑",
|
||||
"parent_id": "2084480593562726401",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:alertRule:edit",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2086644092393029633",
|
||||
"menu_name": "删除",
|
||||
"parent_id": "2084480593562726401",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:alertRule:remove",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2087069357514440706",
|
||||
"menu_name": "新增渠道",
|
||||
"parent_id": "2084202636386541569",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:promote:add",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2087385085920727041",
|
||||
"menu_name": "添加子商务",
|
||||
"parent_id": "2084194830778408961",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:user:add",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2087385679095975937",
|
||||
"menu_name": "查询",
|
||||
"parent_id": "2084194830778408961",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:user:query",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2087385949666332673",
|
||||
"menu_name": "编辑商务",
|
||||
"parent_id": "2084194830778408961",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:user:edit",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2087386325354336258",
|
||||
"menu_name": "导出",
|
||||
"parent_id": "2084194830778408961",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:user:export",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2087444380922167298",
|
||||
"menu_name": "绑定解绑游戏商务",
|
||||
"parent_id": "2084194830778408961",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:user:bind",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2087447465111326721",
|
||||
"menu_name": "查询",
|
||||
"parent_id": "2084202636386541569",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:promote:list",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2087831493110956033",
|
||||
"menu_name": "导出",
|
||||
"parent_id": "2084453258847350785",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:spend:export",
|
||||
"icon": ""
|
||||
},
|
||||
{
|
||||
"menu_id": "2095702540414996481",
|
||||
"menu_name": "渠道激活",
|
||||
"parent_id": "2084194096229949441",
|
||||
"order_num": 3,
|
||||
"path": "ChannelActivationList",
|
||||
"component": "BusMgmt/ChannelActivationList",
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "C",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": null,
|
||||
"icon": "company"
|
||||
},
|
||||
{
|
||||
"menu_id": "2095702652168032258",
|
||||
"menu_name": "列表",
|
||||
"parent_id": "2095702540414996481",
|
||||
"order_num": 1,
|
||||
"path": "",
|
||||
"component": null,
|
||||
"query_param": null,
|
||||
"is_frame": "N",
|
||||
"is_cache": "Y",
|
||||
"menu_type": "F",
|
||||
"visible": "0",
|
||||
"status": "0",
|
||||
"perms": "system:promoteActivePool:list",
|
||||
"icon": ""
|
||||
}
|
||||
],
|
||||
"jobs": [
|
||||
{
|
||||
"executor_info": "swReportDayYesterdayTask",
|
||||
"trigger_type": 3,
|
||||
"trigger_interval": "0 0 1 * * ? ",
|
||||
"job_status": 1
|
||||
},
|
||||
{
|
||||
"executor_info": "swReportServerDayYesterdayTask",
|
||||
"trigger_type": 3,
|
||||
"trigger_interval": "0 5 1 * * ? ",
|
||||
"job_status": 1
|
||||
},
|
||||
{
|
||||
"executor_info": "swReportDayTodayTask",
|
||||
"trigger_type": 3,
|
||||
"trigger_interval": "40 */5 * * * ? ",
|
||||
"job_status": 1
|
||||
},
|
||||
{
|
||||
"executor_info": "swReportServerDayTodayTask",
|
||||
"trigger_type": 3,
|
||||
"trigger_interval": "0 */5 * * * ?",
|
||||
"job_status": 1
|
||||
},
|
||||
{
|
||||
"executor_info": "swReportServerDayBackfillTask",
|
||||
"trigger_type": 2,
|
||||
"trigger_interval": "60",
|
||||
"job_status": 0
|
||||
},
|
||||
{
|
||||
"executor_info": "swReportDayBackfillTask",
|
||||
"trigger_type": 2,
|
||||
"trigger_interval": "60",
|
||||
"job_status": 0
|
||||
},
|
||||
{
|
||||
"executor_info": "alertTriggerTask",
|
||||
"trigger_type": 3,
|
||||
"trigger_interval": "0 */30 * * * ? ",
|
||||
"job_status": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -412,6 +412,9 @@ func (r *authorityAccessRepo) DeleteAuthority(ctx context.Context, id uint) erro
|
||||
return err
|
||||
}
|
||||
var users, children int64
|
||||
if err := system.ValidateAuthorityDeletion(&system.Authority{AuthorityID: authority.AuthorityID, BusinessLevel: authority.BusinessLevel}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Associated-user checks only block on users that still
|
||||
// exist (and are not soft-deleted). Counting raw join rows would make an
|
||||
// orphaned association impossible to clean up, which is observably
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The migration resolves historical IDs once. Runtime role assignment uses
|
||||
// business_level and is independent of editable names and migration helpers.
|
||||
func migrateBusinessRoles(db *gorm.DB) error {
|
||||
if !db.Migrator().HasColumn(&authorityPO{}, "BusinessLevel") {
|
||||
if err := db.Migrator().AddColumn(&authorityPO{}, "BusinessLevel"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var roles []authorityPO
|
||||
if err := db.Find(&roles).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(roles) == 0 {
|
||||
return ensureBusinessRoleIndex(db)
|
||||
}
|
||||
legacy := map[int]string{1: "2083497078545207297", 2: "2087435436573081601", 3: "2087361690139152385"}
|
||||
names := map[int]string{1: "一级商务", 2: "二级商务", 3: "三级商务"}
|
||||
hasMap := db.Migrator().HasTable("sw_kra_id_map")
|
||||
applicable := hasMap
|
||||
for _, role := range roles {
|
||||
if role.BusinessLevel != nil {
|
||||
applicable = true
|
||||
}
|
||||
for _, name := range names {
|
||||
if role.AuthorityName == name {
|
||||
applicable = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !applicable {
|
||||
return ensureBusinessRoleIndex(db)
|
||||
}
|
||||
bindings := map[int]uint{}
|
||||
for _, role := range roles {
|
||||
if role.BusinessLevel == nil {
|
||||
continue
|
||||
}
|
||||
level := *role.BusinessLevel
|
||||
if level < 1 || level > 3 || role.AuthorityID == 888 || bindings[level] != 0 {
|
||||
return fmt.Errorf("invalid or duplicate business role level %d", level)
|
||||
}
|
||||
bindings[level] = role.AuthorityID
|
||||
}
|
||||
for level := 1; level <= 3; level++ {
|
||||
if bindings[level] != 0 {
|
||||
continue
|
||||
}
|
||||
var ids []uint
|
||||
if hasMap {
|
||||
if err := db.Table("sw_kra_id_map m").Select("m.new_id").Joins("JOIN sys_authorities a ON a.authority_id=m.new_id AND a.deleted_at IS NULL").Where("m.entity='role' AND m.old_id=? AND m.new_id<>888", legacy[level]).Scan(&ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
for _, role := range roles {
|
||||
if role.AuthorityID != 888 && role.AuthorityName == names[level] {
|
||||
ids = append(ids, role.AuthorityID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(ids) != 1 {
|
||||
// A generic first installation can have only the administrator.
|
||||
// Migrated SW databases must supply all three mapped roles.
|
||||
if !hasMap && len(bindings) == 0 && len(roles) == 1 && roles[0].AuthorityID == 888 {
|
||||
return ensureBusinessRoleIndex(db)
|
||||
}
|
||||
return fmt.Errorf("business level %d requires exactly one migration role", level)
|
||||
}
|
||||
for _, id := range bindings {
|
||||
if id == ids[0] {
|
||||
return fmt.Errorf("one role cannot represent multiple business levels")
|
||||
}
|
||||
}
|
||||
bindings[level] = ids[0]
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
for level, id := range bindings {
|
||||
if err := tx.Model(&authorityPO{}).Where("authority_id=?", id).UpdateColumn("business_level", level).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureBusinessRoleIndex(db)
|
||||
}
|
||||
|
||||
func ensureBusinessRoleIndex(db *gorm.DB) error {
|
||||
const name = "uk_authority_business_level"
|
||||
if db.Migrator().HasIndex(&authorityPO{}, name) {
|
||||
return nil
|
||||
}
|
||||
return db.Migrator().CreateIndex(&authorityPO{}, name)
|
||||
}
|
||||
|
||||
func CheckBusinessRoles(db *gorm.DB) error {
|
||||
var roles []authorityPO
|
||||
if err := db.Where("business_level IS NOT NULL").Find(&roles).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[int]bool{}
|
||||
for _, role := range roles {
|
||||
if *role.BusinessLevel < 1 || *role.BusinessLevel > 3 || role.AuthorityID == 888 || seen[*role.BusinessLevel] {
|
||||
return fmt.Errorf("invalid business role binding")
|
||||
}
|
||||
seen[*role.BusinessLevel] = true
|
||||
}
|
||||
if len(seen) != 3 {
|
||||
return fmt.Errorf("three business role bindings are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"context"
|
||||
biz "kra/internal/biz/system"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBusinessRoleIdentitySurvivesRenameAndMappingCleanup(t *testing.T) {
|
||||
d := newTransactionTestData(t)
|
||||
db := d.DB()
|
||||
for _, statement := range []string{
|
||||
"INSERT INTO sys_authorities(authority_id,authority_name) VALUES(888,'admin'),(11,'一级已改名'),(12,'二级已改名'),(13,'三级已改名')",
|
||||
"CREATE TABLE sw_kra_id_map(entity TEXT,old_id TEXT,new_id INTEGER)",
|
||||
"INSERT INTO sw_kra_id_map VALUES('role','2083497078545207297',11),('role','2087435436573081601',12),('role','2087361690139152385',13)",
|
||||
} {
|
||||
if err := db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := migrateBusinessRoles(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Exec("DROP TABLE sw_kra_id_map").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateBusinessRoles(db); err != nil {
|
||||
t.Fatalf("migration retry after map removal: %v", err)
|
||||
}
|
||||
repo := &authorityAccessRepo{data: d}
|
||||
if err := repo.UpdateAuthority(context.Background(), &biz.Authority{AuthorityID: 12, AuthorityName: "客服主管"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var role authorityPO
|
||||
if err := db.First(&role, "authority_id=12").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if role.BusinessLevel == nil || *role.BusinessLevel != 2 {
|
||||
t.Fatal("rename changed the stable business role identity")
|
||||
}
|
||||
if err := repo.DeleteAuthority(context.Background(), 12); err != biz.ErrBusinessAuthorityProtected {
|
||||
t.Fatalf("reserved role deletion: %v", err)
|
||||
}
|
||||
if err := repo.CopyAuthority(context.Background(), 12, &biz.Authority{AuthorityID: 14, AuthorityName: "普通副本", DataScope: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role = authorityPO{}
|
||||
if err := db.First(&role, "authority_id=14").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if role.BusinessLevel != nil {
|
||||
t.Fatal("copy inherited the reserved business level")
|
||||
}
|
||||
if err := CheckBusinessRoles(db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBusinessRoleMigrationRejectsAmbiguousFallback(t *testing.T) {
|
||||
d := newTransactionTestData(t)
|
||||
if err := d.DB().Exec("INSERT INTO sys_authorities(authority_id,authority_name) VALUES(11,'一级商务'),(12,'一级商务'),(13,'二级商务'),(14,'三级商务')").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateBusinessRoles(d.DB()); err == nil {
|
||||
t.Fatal("ambiguous role names were accepted")
|
||||
}
|
||||
var count int64
|
||||
d.DB().Model(&authorityPO{}).Where("business_level IS NOT NULL").Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatal("failed role migration partially changed bindings")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ func Migrations() []migration.Step {
|
||||
{ID: "202608210002_communication_surface", Migrate: ensureCommunicationSurface},
|
||||
{ID: "202608210003_communication_test_surface", Migrate: ensureCommunicationTestSurface},
|
||||
{ID: "202611030002_business_user_fields", Migrate: ensureBusinessUserFields},
|
||||
{ID: "202611060024_business_role_identity", Migrate: migrateBusinessRoles},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ type authorityPO struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
AuthorityID uint `gorm:"primaryKey;column:authority_id"`
|
||||
AuthorityName string
|
||||
BusinessLevel *int `gorm:"type:tinyint unsigned;uniqueIndex:uk_authority_business_level"`
|
||||
ParentID *uint
|
||||
DataScope int `gorm:"default:1"`
|
||||
DefaultRouter string `gorm:"default:dashboard"`
|
||||
|
||||
@@ -97,7 +97,7 @@ func (r *userRepo) loadUser(ctx context.Context, po *userPO) (*system.User, erro
|
||||
}
|
||||
|
||||
func toBizAuthority(po authorityPO) system.Authority {
|
||||
return system.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAtPointer(po.DeletedAt), AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter}
|
||||
return system.Authority{CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DeletedAt: deletedAtPointer(po.DeletedAt), AuthorityID: po.AuthorityID, AuthorityName: po.AuthorityName, BusinessLevel: po.BusinessLevel, ParentID: po.ParentID, DataScope: po.DataScope, DefaultRouter: po.DefaultRouter}
|
||||
}
|
||||
|
||||
func parseOriginSetting(raw string) (map[string]any, error) {
|
||||
|
||||
Reference in New Issue
Block a user