508 lines
18 KiB
Go
508 lines
18 KiB
Go
package system
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// legacySchemaMigration creates the complete schema and repairs legacy table
|
|
// shapes. It is intentionally called only by the one-time baseline migration.
|
|
func LegacySchemaMigration(db *gorm.DB) error {
|
|
if err := migrateLegacyIgnoreAPITable(db); err != nil {
|
|
return err
|
|
}
|
|
if err := migrateLegacyAuthorityDepartmentColumns(db); err != nil {
|
|
return err
|
|
}
|
|
return 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{},
|
|
)
|
|
}
|
|
|
|
// CurrentDataMigration contains idempotent system data and authorization
|
|
// reconciliation that belongs to an explicit gormigrate version.
|
|
func CurrentDataMigration(db *gorm.DB) error {
|
|
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)
|
|
}
|
|
|
|
// EnsureAdminSurface upgrades an existing database with menu/API metadata
|
|
// contributed by a feature module. The operation is idempotent and safe to run
|
|
// before the root authority exists; bootstrap seeding links the full menu tree
|
|
// when the first administrator is created.
|
|
func EnsureAdminSurface(db *gorm.DB, surface AdminSurface) error {
|
|
clean := db.Session(&gorm.Session{NewDB: true})
|
|
return clean.Transaction(func(tx *gorm.DB) error {
|
|
menus := make([]menuPO, 0, len(surface.Menus))
|
|
for _, item := range surface.Menus {
|
|
parentID := uint(0)
|
|
if item.ParentName != "" {
|
|
var parent menuPO
|
|
if err := tx.Where("name = ?", item.ParentName).First(&parent).Error; err != nil {
|
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return err
|
|
}
|
|
parent = menuPO{Path: item.ParentName, Name: item.ParentName, Component: "view/routerHolder.vue", Title: item.ParentName, Sort: item.Sort}
|
|
if err := tx.Create(&parent).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
parentID = parent.ID
|
|
}
|
|
menu := menuPO{MenuLevel: 1, ParentID: parentID, Path: item.Path, Name: item.Name, Component: item.Component, Title: item.Title, Icon: item.Icon, Sort: item.Sort}
|
|
if item.ParentName == "" {
|
|
menu.MenuLevel = 0
|
|
}
|
|
menus = append(menus, menu)
|
|
}
|
|
for _, item := range menus {
|
|
var current menuPO
|
|
err := tx.Where("name = ?", item.Name).First(¤t).Error
|
|
switch {
|
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
|
if err := tx.Create(&item).Error; err != nil {
|
|
return err
|
|
}
|
|
case err != nil:
|
|
return err
|
|
default:
|
|
if err := tx.Model(¤t).Updates(map[string]any{
|
|
"menu_level": item.MenuLevel, "parent_id": parent.ID, "path": item.Path,
|
|
"component": item.Component, "title": item.Title, "icon": item.Icon, "sort": item.Sort,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
apis := make([]apiPO, 0, len(surface.APIs))
|
|
for _, item := range surface.APIs {
|
|
apis = append(apis, apiPO{Path: item.Path, Method: item.Method, APIGroup: item.APIGroup, Description: item.Description})
|
|
}
|
|
for _, item := range apis {
|
|
var current apiPO
|
|
err := tx.Where("path = ? AND method = ?", item.Path, item.Method).First(¤t).Error
|
|
switch {
|
|
case errors.Is(err, gorm.ErrRecordNotFound):
|
|
if err := tx.Create(&item).Error; err != nil {
|
|
return err
|
|
}
|
|
case err != nil:
|
|
return err
|
|
default:
|
|
if err := tx.Model(¤t).Updates(map[string]any{"api_group": item.APIGroup, "description": item.Description}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
var authorityCount int64
|
|
if err := tx.Model(&authorityPO{}).Where("authority_id = ?", 888).Count(&authorityCount).Error; err != nil {
|
|
return err
|
|
}
|
|
if authorityCount == 0 {
|
|
return nil
|
|
}
|
|
for _, item := range menus {
|
|
var current menuPO
|
|
if err := tx.Where("name = ?", item.Name).First(¤t).Error; err != nil {
|
|
return err
|
|
}
|
|
var count int64
|
|
if err := tx.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, current.ID).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
if err := tx.Create(&authorityMenuPO{SysAuthorityAuthorityID: 888, SysBaseMenuID: current.ID}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
for _, item := range apis {
|
|
var current apiPO
|
|
if err := tx.Where("path = ? AND method = ?", item.Path, item.Method).First(¤t).Error; err != nil {
|
|
return err
|
|
}
|
|
exists, err := policyExists(tx, 888, current.Path, current.Method)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !exists {
|
|
if err := tx.Create(&casbinRulePO{Ptype: "p", V0: "888", V1: current.Path, V2: current.Method}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|