package data import ( "fmt" "strings" "time" "gorm.io/gorm" ) 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{}, &departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{}, &dictionaryPO{}, &dictionaryDetailPO{}, ¶meterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &securityConfigPO{}, &versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{}, &operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{}, &taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{}, &announcementPO{}, ); err != nil { return err } if err := migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil { return err } if err := normalizeErrorRecordStatuses(db); err != nil { return err } if err := reconcileRootAuthorityAPIs(db); err != nil { return err } 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 { return db.Session(&gorm.Session{NewDB: true}).Model(&errorRecordPO{}).Where("status = ?", "未解决").Update("status", "未处理").Error } // migrateLegacyAuthorityAPIsToCasbinRules upgrades the early Kra join-table // representation to the independent Casbin policy table. Keep the legacy // table in place for backwards compatibility, but make casbin_rule the sole // live policy source. Existing policy rows are not duplicated. func migrateLegacyAuthorityAPIsToCasbinRules(db *gorm.DB) error { clean := db.Session(&gorm.Session{NewDB: true}) if !clean.Migrator().HasTable(&authorityAPIPO{}) || !clean.Migrator().HasTable(&casbinRulePO{}) { return nil } type legacyPolicy struct { AuthorityID uint Path string Method string } 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 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 { // 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) if err != nil { return err } if exists { continue } if err := clean.Create(&casbinRulePO{Ptype: "p", V0: fmt.Sprint(row.AuthorityID), V1: row.Path, V2: row.Method}).Error; err != nil { return err } } 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 // composite primary key portably, so rebuild the small table once while // preserving every existing ignore rule. func migrateLegacyIgnoreAPITable(db *gorm.DB) error { clean := db.Session(&gorm.Session{NewDB: true}) if !clean.Migrator().HasTable(&ignoredAPIPO{}) || clean.Migrator().HasColumn(&ignoredAPIPO{}, "id") { return nil } legacyTable := fmt.Sprintf("sys_ignore_apis_legacy_%d", time.Now().UnixNano()) type legacyIgnoredAPI struct { Path string Method string } return clean.Transaction(func(tx *gorm.DB) error { if err := tx.Migrator().RenameTable(ignoredAPIPO{}.TableName(), legacyTable); err != nil { return fmt.Errorf("rename legacy ignore API table: %w", err) } if err := tx.AutoMigrate(&ignoredAPIPO{}); err != nil { return fmt.Errorf("create compatible ignore API table: %w", err) } var rows []legacyIgnoredAPI if err := tx.Table(legacyTable).Find(&rows).Error; err != nil { return fmt.Errorf("read legacy ignore API rows: %w", err) } if len(rows) > 0 { items := make([]ignoredAPIPO, 0, len(rows)) for _, row := range rows { items = append(items, ignoredAPIPO{Path: row.Path, Method: row.Method}) } if err := tx.Create(&items).Error; err != nil { return fmt.Errorf("copy legacy ignore API rows: %w", err) } } if err := tx.Migrator().DropTable(legacyTable); err != nil { return fmt.Errorf("drop legacy ignore API table: %w", err) } return nil }) } // reconcileRootAuthorityAPIs is a one-time upgrade path from the former Kra // implementation where authority 888 bypassed policy storage entirely. The compatible behavior // grants its root role through persisted Casbin policies, so when a legacy // database has the root role but no stored API links, materialize the same // policy set and let normal authorization read it thereafter. func reconcileRootAuthorityAPIs(db *gorm.DB) error { clean := db.Session(&gorm.Session{NewDB: true}) var authorityCount int64 if err := clean.Session(&gorm.Session{NewDB: true}).Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil || authorityCount == 0 { return err } var policyCount int64 if err := policyScope(clean).Where("v0 = ?", "888").Count(&policyCount).Error; err != nil || policyCount != 0 { return err } var ignored []ignoredAPIPO if err := clean.Session(&gorm.Session{NewDB: true}).Find(&ignored).Error; err != nil { return err } ignoreSet := make(map[string]struct{}, len(ignored)) for _, item := range ignored { ignoreSet[item.Method+"\x00"+item.Path] = struct{}{} } var apis []apiPO if err := clean.Session(&gorm.Session{NewDB: true}).Find(&apis).Error; err != nil { return err } rules := make([]casbinRulePO, 0, len(apis)) for _, api := range apis { if _, ok := ignoreSet[api.Method+"\x00"+api.Path]; ok { continue } rules = append(rules, newPolicyRule(888, api.Path, api.Method)) } if len(rules) == 0 { return nil } return clean.Session(&gorm.Session{NewDB: true}).Create(&rules).Error } // reconcileReferenceIndexes removes constraints created by older Kra builds // that are not part of the administration data model. Business services own // duplicate checks and their user-facing error messages. func reconcileReferenceIndexes(db *gorm.DB) error { clean := db.Session(&gorm.Session{NewDB: true}) obsolete := []struct { model any name string }{ {&apiPO{}, "idx_api_path_method"}, {&dictionaryPO{}, "idx_sys_dictionaries_type"}, {¶meterPO{}, "idx_sys_params_key"}, {&apiTokenPO{}, "idx_sys_api_tokens_token"}, {&exportTemplatePO{}, "idx_sys_export_templates_template_id"}, } for _, item := range obsolete { migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator() if migrator.HasIndex(item.model, item.name) { if err := migrator.DropIndex(item.model, item.name); err != nil { return fmt.Errorf("drop obsolete index %s: %w", item.name, err) } } } for _, item := range []struct { name string field string }{{"idx_sys_users_uuid", "UUID"}, {"idx_sys_users_username", "Username"}} { unique, err := indexIsUnique(clean.Session(&gorm.Session{NewDB: true}), &userPO{}, item.name) if err != nil { return err } if !unique { continue } migrator := clean.Session(&gorm.Session{NewDB: true}).Migrator() if err = migrator.DropIndex(&userPO{}, item.name); err != nil { return fmt.Errorf("drop legacy unique index %s: %w", item.name, err) } if err = migrator.CreateIndex(&userPO{}, item.field); err != nil { return fmt.Errorf("create reference index %s: %w", item.name, err) } } return nil } func indexIsUnique(db *gorm.DB, model any, name string) (bool, error) { indexes, err := db.Migrator().GetIndexes(model) if err != nil { // Some third-party GORM drivers do not implement index inspection. // Fresh schemas are already correct; skip only the legacy repair there. return false, nil } for _, index := range indexes { if index.Name() == name { unique, known := index.Unique() return known && unique, nil } } return false, nil }