feat(goauto): add configurable module menus (#137)

This commit is contained in:
QiuSW
2026-08-28 21:21:40 +08:00
parent 679f4691a4
commit 72f54f76ac
9 changed files with 585 additions and 29 deletions
@@ -0,0 +1,129 @@
package version_local
import (
"fmt"
"runtime"
"go-admin/app/goauto/access"
"go-admin/cmd/migrate/migration"
migrationmodels "go-admin/cmd/migrate/migration/models"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateGoAutoMenus)
}
func migrateGoAutoMenus(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := ensureGoAutoMenusAndPurchaserBindings(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureGoAutoMenusAndPurchaserBindings(db *gorm.DB) error {
var existingRoleCount int64
if err := db.Model(&migrationmodels.SysRole{}).
Where("role_key = ?", access.RolePurchaser).Count(&existingRoleCount).Error; err != nil {
return err
}
if err := ensurePurchaserRoleAndPolicies(db); err != nil {
return err
}
var purchaser migrationmodels.SysRole
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
return err
}
for _, module := range access.GoAutoModules() {
parent, _, err := upsertGoAutoMenu(db, migrationmodels.SysMenu{
MenuName: module.RouteName + "Root",
Title: module.Title,
Icon: module.Icon,
Path: module.Path,
MenuType: "M",
Action: "无",
ParentId: 0,
Component: "Layout",
Sort: module.Sort,
Visible: "0",
IsFrame: "1",
})
if err != nil {
return err
}
if err := db.Model(&parent).Update("paths", fmt.Sprintf("/0/%d", parent.MenuId)).Error; err != nil {
return err
}
child, childCreated, err := upsertGoAutoMenu(db, migrationmodels.SysMenu{
MenuName: module.RouteName,
Title: module.Title,
Icon: module.Icon,
Path: module.Path + "/index",
MenuType: "C",
Action: "无",
ParentId: parent.MenuId,
Component: module.Component,
Sort: 1,
Visible: "0",
IsFrame: "1",
})
if err != nil {
return err
}
if err := db.Model(&child).Update("paths", fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId)).Error; err != nil {
return err
}
apis := make([]migrationmodels.SysApi, 0, len(module.APIs))
for _, permission := range module.APIs {
api := migrationmodels.SysApi{}
if err := db.Where("path = ? AND action = ?", permission.Path, permission.Method).
Assign(migrationmodels.SysApi{Title: permission.Title, Type: "BUS"}).
FirstOrCreate(&api).Error; err != nil {
return err
}
apis = append(apis, api)
}
if err := db.Model(&child).Association("SysApi").Replace(apis); err != nil {
return err
}
if module.PurchaserHardHidden {
if err := db.Model(&purchaser).Association("SysMenu").Delete(&parent, &child); err != nil {
return err
}
continue
}
if module.PurchaserDefault && (existingRoleCount == 0 || childCreated) {
if err := db.Model(&purchaser).Association("SysMenu").Append(&child); err != nil {
return err
}
}
}
return nil
}
func upsertGoAutoMenu(db *gorm.DB, desired migrationmodels.SysMenu) (migrationmodels.SysMenu, bool, error) {
var menu migrationmodels.SysMenu
err := db.Unscoped().Where("menu_name = ?", desired.MenuName).First(&menu).Error
if err != nil && err != gorm.ErrRecordNotFound {
return menu, false, err
}
created := err == gorm.ErrRecordNotFound
if created {
menu = desired
return menu, true, db.Create(&menu).Error
}
desired.MenuId = menu.MenuId
desired.CreatedAt = menu.CreatedAt
return desired, false, db.Unscoped().Save(&desired).Error
}
@@ -0,0 +1,143 @@
package version_local
import (
"testing"
"go-admin/app/goauto/access"
migrationmodels "go-admin/cmd/migrate/migration/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureGoAutoMenusAndPurchaserBindings(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(
&migrationmodels.SysRole{}, &migrationmodels.SysMenu{}, &migrationmodels.SysApi{},
&purchaserCasbinRule{},
); err != nil {
t.Fatal(err)
}
custom := migrationmodels.SysMenu{MenuName: "UserCustomMenu", Title: "用户自定义菜单", Path: "/custom"}
if err = db.Create(&custom).Error; err != nil {
t.Fatal(err)
}
staleAIGrant := purchaserCasbinRule{
Ptype: "p", V0: access.RolePurchaser,
V1: "/api/admin/v1/ai-matching-settings", V2: "GET",
}
if err = db.Create(&staleAIGrant).Error; err != nil {
t.Fatal(err)
}
if err = ensureGoAutoMenusAndPurchaserBindings(db); err != nil {
t.Fatal(err)
}
assertGoAutoMenuState(t, db)
var customAfter migrationmodels.SysMenu
if err = db.First(&customAfter, custom.MenuId).Error; err != nil {
t.Fatal("migration must not remove a user menu:", err)
}
if customAfter.Title != custom.Title || customAfter.Path != custom.Path {
t.Fatalf("migration changed a user menu: %#v", customAfter)
}
var readGrantCount int64
if err = db.Model(&purchaserCasbinRule{}).
Where("v0 = ? AND v1 = ? AND v2 = ?", access.RolePurchaser, "/api/admin/v1/ai-matching-settings", "GET").
Count(&readGrantCount).Error; err != nil {
t.Fatal(err)
}
if readGrantCount != 1 {
t.Fatal("purchaser AI enabled-status read policy must remain unchanged")
}
var forbiddenAIGrantCount int64
if err = db.Model(&purchaserCasbinRule{}).
Where("v0 = ? AND v1 LIKE ? AND v2 <> ?", access.RolePurchaser, "/api/admin/v1/ai-matching-settings%", "GET").
Count(&forbiddenAIGrantCount).Error; err != nil {
t.Fatal(err)
}
if forbiddenAIGrantCount != 0 {
t.Fatal("purchaser must not gain AI settings write/test policies")
}
var purchaser migrationmodels.SysRole
if err = db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
t.Fatal(err)
}
var devices migrationmodels.SysMenu
if err = db.Where("menu_name = ?", "GoAutoDeviceList").First(&devices).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&purchaser).Association("SysMenu").Delete(&devices); err != nil {
t.Fatal(err)
}
var aiParent, aiChild migrationmodels.SysMenu
if err = db.Where("menu_name = ?", "GoAutoAiMatchingSettingsRoot").First(&aiParent).Error; err != nil {
t.Fatal(err)
}
if err = db.Where("menu_name = ?", "GoAutoAiMatchingSettings").First(&aiChild).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&purchaser).Association("SysMenu").Append(&aiParent, &aiChild); err != nil {
t.Fatal(err)
}
if err = ensureGoAutoMenusAndPurchaserBindings(db); err != nil {
t.Fatalf("reconcile must be repeatable: %v", err)
}
assertRoleHasMenu(t, db, purchaser.RoleId, devices.MenuId, false)
assertRoleHasMenu(t, db, purchaser.RoleId, aiParent.MenuId, false)
assertRoleHasMenu(t, db, purchaser.RoleId, aiChild.MenuId, false)
assertGoAutoMenuState(t, db)
// A genuinely new default module is added once. Deleting one code-owned
// menu simulates introducing that module into an existing deployment.
if err = db.Unscoped().Delete(&devices).Error; err != nil {
t.Fatal(err)
}
if err = ensureGoAutoMenusAndPurchaserBindings(db); err != nil {
t.Fatal(err)
}
devices = migrationmodels.SysMenu{}
if err = db.Where("menu_name = ?", "GoAutoDeviceList").First(&devices).Error; err != nil {
t.Fatal(err)
}
assertRoleHasMenu(t, db, purchaser.RoleId, devices.MenuId, true)
}
func assertGoAutoMenuState(t *testing.T, db *gorm.DB) {
t.Helper()
var count int64
if err := db.Model(&migrationmodels.SysMenu{}).Where("menu_name LIKE ?", "GoAuto%").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != int64(len(access.GoAutoModules())*2) {
t.Fatalf("got %d GoAuto menu rows, want %d", count, len(access.GoAutoModules())*2)
}
for _, module := range access.GoAutoModules() {
var child migrationmodels.SysMenu
if err := db.Preload("SysApi").Where("menu_name = ?", module.RouteName).First(&child).Error; err != nil {
t.Fatal(err)
}
if child.Path != module.Path+"/index" || child.Component != module.Component || child.ParentId == 0 {
t.Fatalf("unexpected menu for %s: %#v", module.Key, child)
}
if len(child.SysApi) != len(module.APIs) {
t.Fatalf("%s has %d APIs, want %d", module.Key, len(child.SysApi), len(module.APIs))
}
}
}
func assertRoleHasMenu(t *testing.T, db *gorm.DB, roleID, menuID int, want bool) {
t.Helper()
var count int64
if err := db.Table("sys_role_menu").Where("role_id = ? AND menu_id = ?", roleID, menuID).Count(&count).Error; err != nil {
t.Fatal(err)
}
if (count > 0) != want {
t.Fatalf("role %d menu %d present=%v, want %v", roleID, menuID, count > 0, want)
}
}