feat: automate business upgrades and sync reviewed frontend

This commit is contained in:
Yvan
2026-11-06 15:48:35 +08:00
parent 7df74bc8e8
commit 9fccd804a3
58 changed files with 5689 additions and 259 deletions
+2 -17
View File
@@ -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 {
+5 -2
View File
@@ -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)
+1 -36
View File
@@ -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)
}
}
}
}
+1 -1
View File
@@ -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 -1
View File
@@ -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{})
+4 -8
View File
@@ -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
View File
@@ -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 {
+48 -2
View File
@@ -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 {
+317
View File
@@ -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
}
+155
View File
@@ -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)
}
}
+119
View File
@@ -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
}
+426
View File
@@ -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
}
+609
View File
@@ -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)
}
}
}
+38
View File
@@ -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{}) }
File diff suppressed because it is too large Load Diff
+544
View File
@@ -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
}
]
}
+3
View File
@@ -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
+119
View File
@@ -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")
}
}
+1
View File
@@ -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},
}
}
+1
View File
@@ -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"`
+1 -1
View File
@@ -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) {