优化结构

This commit is contained in:
Yvan
2026-08-20 21:46:52 +08:00
parent 23bc29ce0f
commit 63dbb4cfbc
78 changed files with 767 additions and 1082 deletions
+3 -3
View File
@@ -6,8 +6,8 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/gormkit"
"kra/pkg/pagination"
"kra/internal/data/gormkit"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
@@ -23,7 +23,7 @@ type announcementPO struct {
Attachments gormkit.JSON
}
func (announcementPO) TableName() string { return "kra_announcements_info" }
func (announcementPO) TableName() string { return "sys_announcements" }
type announcementRepo struct{ data Provider }
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
-25
View File
@@ -10,31 +10,6 @@ type IgnoredAPI struct {
Path string
}
// AdminMenu describes a menu entry contributed by a module. ParentName is
// resolved by the system persistence layer so feature modules do not need to
// know the menu PO shape.
type AdminMenu struct {
Name string
Path string
ParentName string
Component string
Title string
Icon string
Sort int
}
type AdminAPI struct {
Path string
Method string
APIGroup string
Description string
}
type AdminSurface struct {
Menus []AdminMenu
APIs []AdminAPI
}
func DefaultIgnoredAPIs(staticPath string) []IgnoredAPI {
staticRoute := "/" + strings.Trim(staticPath, "/") + "/*filepath"
return []IgnoredAPI{
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+21 -498
View File
@@ -1,507 +1,30 @@
package system
import (
"errors"
"fmt"
"strings"
"time"
"kra/internal/data/migration"
"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{}, &parameterPO{}, &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(&current).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(&current).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(&current).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(&current).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(&current).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(&current).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)
// Migrations returns schema steps owned by the built-in system module.
// Administrators, menus, APIs, and policies are created only by SeedSystem
// during the explicit first-install flow.
func Migrations() []migration.Step {
return []migration.Step{
{
ID: "202608200002_system_schema",
Migrate: func(db *gorm.DB) error {
return db.AutoMigrate(
&userPO{}, &authorityPO{}, &menuPO{}, &userAuthorityPO{}, &authorityMenuPO{}, &menuParameterPO{},
&apiPO{}, &ignoredAPIPO{}, &authorityAPIPO{}, &casbinRulePO{}, &menuButtonPO{}, &authorityButtonPO{},
&departmentPO{}, &positionPO{}, &userDepartmentPO{}, &userPositionPO{}, &authorityDepartmentPO{},
&dictionaryPO{}, &dictionaryDetailPO{}, &parameterPO{}, &apiTokenPO{}, &jwtBlacklistPO{}, &SecurityConfigPO{},
&versionPO{}, &exportTemplatePO{}, &exportConditionPO{}, &exportJoinPO{},
&operationPO{}, &loginLogPO{}, &dataAccessLogPO{}, &errorRecordPO{},
&taskPO{}, &taskLogPO{}, &mediaPO{}, &categoryPO{}, &uploadSessionPO{}, &uploadChunkPO{},
&announcementPO{},
)
},
},
}
}
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"},
{&parameterPO{}, "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
}
+11 -245
View File
@@ -1,265 +1,31 @@
package system
import (
"testing"
import "testing"
"kra/internal/data/migration"
)
func TestMigrateAllUsesVersionTableAndIsIdempotent(t *testing.T) {
func TestMigrationsCreateOnlySystemSchema(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err = migrateAll(db); err != nil {
t.Fatalf("first migration: %v", err)
}
if err = migrateAll(db); err != nil {
t.Fatalf("second migration: %v", err)
}
if !db.Migrator().HasTable(migration.TableName) {
t.Fatalf("missing migration table %q", migration.TableName)
}
var count int64
if err = db.Table(migration.TableName).Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("migration rows = %d, want 2", count)
}
}
func TestEnsurePaymentAdminSurfaceIsIdempotent(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err = db.AutoMigrate(&menuPO{}, &authorityMenuPO{}, &authorityPO{}, &apiPO{}, &casbinRulePO{}); err != nil {
t.Fatal(err)
}
rootParentID := uint(0)
if err = db.Create(&authorityPO{AuthorityID: 888, AuthorityName: "root", ParentID: &rootParentID}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&menuPO{Name: "extensions", Path: "legacy-extensions", Title: "旧扩展"}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&menuPO{Name: "paymentOrders", Component: "legacy.vue", Title: "旧支付订单"}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&apiPO{Path: "/payment/configs", Method: "GET", APIGroup: "legacy"}).Error; err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
if err = ensurePaymentAdminSurface(db); err != nil {
t.Fatalf("ensure payment admin surface pass %d: %v", i+1, err)
for _, table := range []string{"sys_users", "sys_base_menus", "sys_apis", "sys_departments", "sys_announcements"} {
if !db.Migrator().HasTable(table) {
t.Fatalf("system migration did not create %s", table)
}
}
var parent menuPO
if err = db.Where("name = ?", "extensions").First(&parent).Error; err != nil {
var users, menus, apis int64
if err = db.Model(&userPO{}).Count(&users).Error; err != nil {
t.Fatal(err)
}
for name, component := range map[string]string{
"paymentOrders": "view/systemTools/payment/orders.vue",
"paymentConfig": "view/systemTools/payment/config.vue",
} {
var menu menuPO
if err = db.Where("name = ?", name).First(&menu).Error; err != nil {
t.Fatal(err)
}
if menu.ParentID != parent.ID || menu.Component != component {
t.Fatalf("menu %s = %#v", name, menu)
}
var linkCount int64
if err = db.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, menu.ID).Count(&linkCount).Error; err != nil {
t.Fatal(err)
}
if linkCount != 1 {
t.Fatalf("menu %s root links = %d, want 1", name, linkCount)
}
}
for _, item := range []struct{ method, path string }{
{"GET", "/payment/configs"}, {"POST", "/payment/config"},
{"GET", "/payment/orders"}, {"POST", "/payment/order"},
} {
var apiCount int64
if err = db.Model(&apiPO{}).Where("path = ? AND method = ?", item.path, item.method).Count(&apiCount).Error; err != nil {
t.Fatal(err)
}
if apiCount != 1 {
t.Fatalf("API %s %s rows = %d, want 1", item.method, item.path, apiCount)
}
var policyCount int64
if err = policyScope(db).Where("v0 = ? AND v1 = ? AND v2 = ?", "888", item.path, item.method).Count(&policyCount).Error; err != nil {
t.Fatal(err)
}
if policyCount != 1 {
t.Fatalf("policy %s %s rows = %d, want 1", item.method, item.path, policyCount)
}
}
}
func TestMigrateLegacyAuthorityAPIsWithoutDeletedAt(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
if err = db.Model(&menuPO{}).Count(&menus).Error; err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
for _, statement := range []string{
`CREATE TABLE sys_apis (id integer primary key, path text, method text)`,
`CREATE TABLE sys_authority_apis (authority_id integer, api_id integer)`,
`INSERT INTO sys_apis (id, path, method) VALUES (1, '/legacy', 'GET')`,
`INSERT INTO sys_authority_apis (authority_id, api_id) VALUES (888, 1)`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err = db.AutoMigrate(&casbinRulePO{}, &errorRecordPO{}); err != nil {
t.Fatal(err)
}
legacyError := errorRecordPO{Status: "未解决"}
if err = db.Create(&legacyError).Error; err != nil {
t.Fatal(err)
}
if err = migrateLegacyAuthorityAPIsToCasbinRules(db); err != nil {
t.Fatalf("legacy migration failed without sys_apis.deleted_at: %v", err)
}
if err = normalizeErrorRecordStatuses(db); err != nil {
t.Fatalf("following migration inherited the legacy table alias: %v", err)
}
var count int64
if err = db.Model(&casbinRulePO{}).
Where("ptype = ? AND v0 = ? AND v1 = ? AND v2 = ?", "p", "888", "/legacy", "GET").
Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("migrated policy count = %d, want 1", count)
}
if err = db.First(&legacyError, legacyError.ID).Error; err != nil {
t.Fatal(err)
}
if legacyError.Status != "未处理" {
t.Fatalf("normalized status = %q, want 未处理", legacyError.Status)
}
}
func TestReconcileRootAuthorityAPIsUsesIndependentQueries(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
if err = db.AutoMigrate(&authorityPO{}, &apiPO{}, &ignoredAPIPO{}, &casbinRulePO{}); err != nil {
t.Fatal(err)
}
if err = db.Create(&authorityPO{AuthorityID: 888, AuthorityName: "root"}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&apiPO{Path: "/allowed", Method: "GET"}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&apiPO{Path: "/ignored", Method: "POST"}).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&ignoredAPIPO{Path: "/ignored", Method: "POST"}).Error; err != nil {
t.Fatal(err)
}
// Deliberately pass a handle carrying an unrelated model and predicate.
// Migration queries must not inherit either state.
dirty := db.Model(&authorityPO{}).Where("authority_id = ?", 999)
if err = reconcileRootAuthorityAPIs(dirty); err != nil {
t.Fatalf("reconcile root policies with dirty DB state: %v", err)
}
var rules []casbinRulePO
if err = db.Where("ptype = ? AND v0 = ?", "p", "888").Find(&rules).Error; err != nil {
t.Fatal(err)
}
if len(rules) != 1 || rules[0].V1 != "/allowed" || rules[0].V2 != "GET" {
t.Fatalf("root rules = %#v, want only GET /allowed", rules)
}
}
func TestMigrateLegacyAuthorityDepartmentColumns(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
for _, statement := range []string{
`CREATE TABLE sys_authority_departments (authority_id integer, department_id integer, PRIMARY KEY (authority_id, department_id))`,
`INSERT INTO sys_authority_departments (authority_id, department_id) VALUES (888, 7)`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateLegacyAuthorityDepartmentColumns(db); err != nil {
t.Fatalf("migrate legacy authority-department columns: %v", err)
}
if err = db.AutoMigrate(&authorityDepartmentPO{}); err != nil {
t.Fatal(err)
}
var relation authorityDepartmentPO
if err = db.First(&relation).Error; err != nil {
t.Fatal(err)
}
if relation.AuthorityID != 888 || relation.DepartmentID != 7 {
t.Fatalf("migrated relation = %#v", relation)
}
for _, column := range []string{"sys_authority_authority_id", "sys_department_id"} {
if !tableHasColumn(db, "sys_authority_departments", column) {
t.Fatalf("missing compatible column %s", column)
}
}
hasPrimaryKey, err := tableHasPrimaryKey(db, "sys_authority_departments")
if err != nil {
t.Fatal(err)
}
if hasPrimaryKey {
t.Fatal("authority-department table retained the legacy primary key")
}
if err = db.Create(&authorityDepartmentPO{AuthorityID: 888, DepartmentID: 7}).Error; err != nil {
t.Fatalf("duplicate administration relation was rejected: %v", err)
}
var count int64
if err = db.Model(&authorityDepartmentPO{}).Where("sys_authority_authority_id = ? AND sys_department_id = ?", 888, 7).Count(&count).Error; err != nil {
if err = db.Model(&apiPO{}).Count(&apis).Error; err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("duplicate relation count = %d, want 2", count)
if users != 0 || menus != 0 || apis != 0 {
t.Fatalf("schema migration seeded business data: users=%d menus=%d apis=%d", users, menus, apis)
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"gorm.io/gorm"
"gorm.io/gorm/clause"
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"kra/internal/biz"
"kra/internal/conf"
"kra/pkg/adminauth"
"kra/internal/security/adminauth"
jwt "github.com/golang-jwt/jwt/v5"
)
+48 -1
View File
@@ -5,6 +5,7 @@ import (
"strings"
"time"
"kra/internal/adminsurface"
"kra/internal/biz"
"github.com/google/uuid"
@@ -12,7 +13,7 @@ import (
"gorm.io/gorm"
)
func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) error {
func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig, surfaces ...adminsurface.Surface) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
rootParentID := uint(0)
authority := authorityPO{AuthorityID: 888, AuthorityName: "超级管理员", ParentID: &rootParentID, DataScope: 1, DefaultRouter: "dashboard"}
@@ -28,6 +29,11 @@ func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) err
return err
}
}
for _, surface := range surfaces {
if err := seedAdminSurface(tx, surface); err != nil {
return err
}
}
var persisted []menuPO
if err := tx.Order("sort asc, id asc").Find(&persisted).Error; err != nil {
return err
@@ -140,6 +146,47 @@ func SeedSystem(ctx context.Context, db *gorm.DB, input *biz.DatabaseConfig) err
})
}
func seedAdminSurface(tx *gorm.DB, surface adminsurface.Surface) error {
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 {
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
}
if err := tx.Where("name = ?", item.Name).FirstOrCreate(&menu).Error; err != nil {
return err
}
}
for _, item := range surface.APIs {
api := apiPO{
Path: item.Path,
Method: strings.ToUpper(item.Method),
APIGroup: item.APIGroup,
Description: item.Description,
}
if err := tx.Where("path = ? AND method = ?", api.Path, api.Method).FirstOrCreate(&api).Error; err != nil {
return err
}
}
return nil
}
func defaultMenus() []menuPO {
root := func(path, name, title, icon string, sort int) menuPO {
return menuPO{Path: path, Name: name, Component: "view/routerHolder.vue", Title: title, Icon: icon, Sort: sort}
+56
View File
@@ -0,0 +1,56 @@
package system
import (
"context"
"testing"
"kra/internal/adminsurface"
"kra/internal/biz"
)
func TestSeedSystemCreatesInitialDataAndModuleSurface(t *testing.T) {
db, err := openWithDriver("sqlite", "file:"+t.Name()+"?mode=memory&cache=shared")
if err != nil {
t.Fatal(err)
}
if err = migrateAll(db); err != nil {
t.Fatal(err)
}
surface := adminsurface.Surface{
Menus: []adminsurface.Menu{{Name: "orders", Path: "orders", ParentName: "extensions", Component: "view/orders.vue", Title: "订单", Sort: 6}},
APIs: []adminsurface.API{{Path: "/orders", Method: "GET", APIGroup: "订单", Description: "订单列表"}},
}
input := &biz.DatabaseConfig{AdminPassword: "admin-password", APIs: []*biz.API{{Path: "/healthz", Method: "GET", APIGroup: "系统"}}}
if err = SeedSystem(context.Background(), db, input, surface); err != nil {
t.Fatal(err)
}
var admin userPO
if err = db.Where("username = ?", "admin").First(&admin).Error; err != nil {
t.Fatal(err)
}
var menu menuPO
if err = db.Where("name = ?", "orders").First(&menu).Error; err != nil {
t.Fatal(err)
}
var parent menuPO
if err = db.Where("name = ?", "extensions").First(&parent).Error; err != nil {
t.Fatal(err)
}
if menu.ParentID != parent.ID {
t.Fatalf("module menu parent = %d, want %d", menu.ParentID, parent.ID)
}
var links int64
if err = db.Model(&authorityMenuPO{}).Where("sys_authority_authority_id = ? AND sys_base_menu_id = ?", 888, menu.ID).Count(&links).Error; err != nil {
t.Fatal(err)
}
if links != 1 {
t.Fatalf("root menu links = %d, want 1", links)
}
var policies int64
if err = policyScope(db).Where("v0 = ? AND v1 = ? AND v2 = ?", "888", "/orders", "GET").Count(&policies).Error; err != nil {
t.Fatal(err)
}
if policies != 1 {
t.Fatalf("root policies = %d, want 1", policies)
}
}
+2 -2
View File
@@ -3,8 +3,8 @@ package system
import (
"context"
"kra/internal/biz"
"kra/pkg/gormkit"
"kra/pkg/pagination"
"kra/internal/data/gormkit"
"kra/internal/data/pagination"
"time"
"gorm.io/gorm"
+6 -5
View File
@@ -8,7 +8,6 @@ import (
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"kra/internal/conf"
"kra/internal/data/migration"
)
// Data is a small in-package test harness. Production repositories depend on
@@ -69,8 +68,10 @@ func openWithDriver(driver, dsn string) (*gorm.DB, error) {
}
func migrateAll(db *gorm.DB) error {
return migration.Run(db, []migration.Step{
{ID: "test_baseline", Migrate: LegacySchemaMigration},
{ID: "test_reconcile", Migrate: CurrentDataMigration},
})
for _, step := range Migrations() {
if err := step.Migrate(db); err != nil {
return err
}
}
return nil
}
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"time"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
"github.com/google/uuid"
"gorm.io/gorm"
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"gorm.io/gorm"
"kra/internal/biz"
"kra/pkg/pagination"
"kra/internal/data/pagination"
)
type versionPO struct {