This commit is contained in:
2026-08-17 02:12:17 +08:00
parent 560ae46b82
commit 6a05803e08
96 changed files with 2888 additions and 685 deletions
+168 -5
View File
@@ -2,6 +2,7 @@ package data
import (
"fmt"
"strings"
"time"
"gorm.io/gorm"
@@ -11,6 +12,9 @@ func migrateAll(db *gorm.DB) error {
if err := migrateLegacyIgnoreAPITable(db); err != nil {
return err
}
if err := migrateLegacyAuthorityDepartmentColumns(db); err != nil {
return err
}
if err := db.AutoMigrate(
&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{},
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
@@ -35,6 +39,134 @@ func migrateAll(db *gorm.DB) error {
return reconcileReferenceIndexes(db)
}
// migrateLegacyAuthorityDepartmentColumns preserves data created by early
// Kra builds, which used shortened join-column names and a composite primary
// key. The administration connection model has neither a primary key nor a
// uniqueness constraint, so rebuild the small table before AutoMigrate.
func migrateLegacyAuthorityDepartmentColumns(db *gorm.DB) error {
clean := db.Session(&gorm.Session{NewDB: true})
const (
table = "sys_authority_departments"
backup = "sys_authority_departments_kra_legacy"
)
// MySQL and Oracle auto-commit DDL. If a prior process stopped between the
// rename and cleanup steps, restore the untouched backup first and retry the
// migration from a known state.
if clean.Migrator().HasTable(backup) {
if clean.Migrator().HasTable(table) {
if err := clean.Migrator().DropTable(table); err != nil {
return fmt.Errorf("remove incomplete authority-department table: %w", err)
}
}
if err := clean.Migrator().RenameTable(backup, table); err != nil {
return fmt.Errorf("restore authority-department backup: %w", err)
}
}
if !clean.Migrator().HasTable(table) {
return nil
}
authorityColumn := "sys_authority_authority_id"
if !tableHasColumn(clean, table, authorityColumn) {
if !tableHasColumn(clean, table, "authority_id") {
return fmt.Errorf("authority-department table has no authority column")
}
authorityColumn = "authority_id"
}
departmentColumn := "sys_department_id"
if !tableHasColumn(clean, table, departmentColumn) {
if !tableHasColumn(clean, table, "department_id") {
return fmt.Errorf("authority-department table has no department column")
}
departmentColumn = "department_id"
}
hasPrimaryKey, err := tableHasPrimaryKey(clean, table)
if err != nil {
return err
}
if authorityColumn == "sys_authority_authority_id" && departmentColumn == "sys_department_id" && !hasPrimaryKey {
return nil
}
type relation struct {
AuthorityID uint `gorm:"column:authority_id"`
DepartmentID uint `gorm:"column:department_id"`
}
var rows []relation
selectColumns := authorityColumn + " AS authority_id, " + departmentColumn + " AS department_id"
if err := clean.Table(table).Select(selectColumns).Scan(&rows).Error; err != nil {
return fmt.Errorf("read legacy authority-department rows: %w", err)
}
rebuild := func(tx *gorm.DB) error {
if err := tx.Migrator().RenameTable(table, backup); err != nil {
return fmt.Errorf("rename legacy authority-department table: %w", err)
}
if err := tx.AutoMigrate(&authorityDepartmentPO{}); err != nil {
return fmt.Errorf("create authority-department table: %w", err)
}
if len(rows) > 0 {
items := make([]authorityDepartmentPO, 0, len(rows))
for _, row := range rows {
items = append(items, authorityDepartmentPO{AuthorityID: row.AuthorityID, DepartmentID: row.DepartmentID})
}
if err := tx.Create(&items).Error; err != nil {
return fmt.Errorf("copy authority-department rows: %w", err)
}
}
var count int64
if err := tx.Model(&authorityDepartmentPO{}).Count(&count).Error; err != nil {
return fmt.Errorf("verify authority-department rows: %w", err)
}
if count != int64(len(rows)) {
return fmt.Errorf("verify authority-department rows: got %d want %d", count, len(rows))
}
if err := tx.Migrator().DropTable(backup); err != nil {
return fmt.Errorf("drop legacy authority-department table: %w", err)
}
return nil
}
switch clean.Dialector.Name() {
case "mysql", "oracle":
if err := rebuild(clean); err != nil {
restoreErr := restoreAuthorityDepartmentBackup(clean, table, backup)
if restoreErr != nil {
return fmt.Errorf("%v; restore authority-department backup: %w", err, restoreErr)
}
return err
}
return nil
default:
return clean.Transaction(rebuild)
}
}
func restoreAuthorityDepartmentBackup(db *gorm.DB, table, backup string) error {
if db.Migrator().HasTable(table) {
if err := db.Migrator().DropTable(table); err != nil {
return err
}
}
if db.Migrator().HasTable(backup) {
return db.Migrator().RenameTable(backup, table)
}
return nil
}
func tableHasPrimaryKey(db *gorm.DB, table string) (bool, error) {
columns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return false, err
}
for _, column := range columns {
if primary, ok := column.PrimaryKey(); ok && primary {
return true, nil
}
}
return false, nil
}
// Older builds used a status label outside the administration page's supported
// state set, so normalize existing rows during migration.
func normalizeErrorRecordStatuses(db *gorm.DB) error {
@@ -55,18 +187,32 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
Path string
Method string
}
query := clean.Table("sys_authority_apis sa").
Select("sa.authority_id, a.path, a.method").
Joins("JOIN sys_apis a ON a.id = sa.api_id")
baseQuery := func() *gorm.DB {
return clean.Table("sys_authority_apis sa").
Select("sa.authority_id, a.path, a.method").
Joins("JOIN sys_apis a ON a.id = sa.api_id")
}
query := baseQuery()
// Early Kra schemas stored sys_apis without soft-delete timestamps. The
// legacy-policy migration must run before assuming that column exists;
// otherwise an upgrade from those schemas cannot start on MySQL.
if clean.Migrator().HasColumn(&apiPO{}, "deleted_at") {
if tableHasColumn(clean, "sys_apis", "deleted_at") {
query = query.Where("a.deleted_at IS NULL")
}
var rows []legacyPolicy
if err := query.Find(&rows).Error; err != nil {
return err
// A few MySQL-compatible drivers report stale/incomplete metadata from
// INFORMATION_SCHEMA during startup. If the optional soft-delete column
// was reported present but the join still rejects it, retry using only
// columns shared by every legacy schema. This migration must never block
// startup of an older database solely because deleted_at is absent.
if strings.Contains(strings.ToLower(err.Error()), "unknown column") && strings.Contains(strings.ToLower(err.Error()), "deleted_at") {
if retryErr := baseQuery().Find(&rows).Error; retryErr != nil {
return retryErr
}
} else {
return err
}
}
for _, row := range rows {
exists, err := policyExists(clean, row.AuthorityID, row.Path, row.Method)
@@ -83,6 +229,23 @@ func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error {
return nil
}
// tableHasColumn deliberately inspects the physical table rather than the
// model schema. Legacy databases may predate soft-delete columns even though
// the current PO includes gorm.DeletedAt. Metadata inspection failures are
// treated as "unknown" so callers use the portable query shape.
func tableHasColumn(db *gorm.DB, table, column string) bool {
columns, err := db.Migrator().ColumnTypes(table)
if err != nil {
return false
}
for _, item := range columns {
if strings.EqualFold(item.Name(), column) {
return true
}
}
return false
}
// migrateLegacyIgnoreAPITable upgrades the early Kra-only composite-key
// shape (path, method) to the compatible model shape (ID/timestamps/soft
// delete). AutoMigrate can add columns but cannot replace an existing