package data import ( "fmt" "time" "gorm.io/gorm" ) func migrateAll(db *gorm.DB) error { if err := migrateLegacyIgnoreAPITable(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) } // 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 } 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") // 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") { query = query.Where("a.deleted_at IS NULL") } var rows []legacyPolicy if err := query.Find(&rows).Error; err != nil { 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 } // 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 }