feat(#142): group GoAuto admin navigation

This commit is contained in:
QiuSW
2026-08-29 09:44:06 +08:00
parent fc8798479b
commit f84ea5df3b
7 changed files with 409 additions and 26 deletions
+62
View File
@@ -32,6 +32,57 @@ type ModuleDefinition struct {
APIs []APIPermission
}
const (
MenuGroupCollectionPurchase = "collection_purchase"
MenuGroupCollectionManagement = "collection_management"
)
// MenuGroupDefinition describes the two structural navigation groups used by
// Admin. ModuleKeys is ordered and therefore also defines child menu sorting.
type MenuGroupDefinition struct {
Key string
MenuName string
Title string
Path string
Icon string
Sort int
ModuleKeys []string
}
var goAutoMenuGroupMetadata = []MenuGroupDefinition{
{
Key: MenuGroupCollectionPurchase,
MenuName: "GoAutoCollectionPurchase",
Title: "采集采购",
Path: "/collection-purchase",
Icon: "shopping",
Sort: 50,
ModuleKeys: []string{
ModuleSYBProducts,
ModuleSYBSyncRuns,
ModuleSYBInnerCodes,
ModuleShopeeProducts,
ModulePDDProducts,
ModuleCollectionTasks,
ModulePurchaseTasks,
},
},
{
Key: MenuGroupCollectionManagement,
MenuName: "GoAutoCollectionManagement",
Title: "采采管理",
Path: "/collection-management",
Icon: "system",
Sort: 51,
ModuleKeys: []string{
ModuleSYBShops,
ModuleCollectionRules,
ModuleDevices,
ModuleAIMatching,
},
},
}
var goAutoModuleMetadata = []ModuleDefinition{
{Key: ModuleDevices, Title: "设备列表", Path: "/devices", RouteName: "GoAutoDeviceList", Component: "/goauto/devices/index", Icon: "monitor", Sort: 50, PurchaserDefault: true},
{Key: ModulePDDProducts, Title: "PDD 商品", Path: "/pdd-products", RouteName: "GoAutoPddProducts", Component: "/goauto/pdd-products/index", Icon: "shopping", Sort: 51, PurchaserDefault: true},
@@ -61,6 +112,17 @@ func GoAutoModules() []ModuleDefinition {
return modules
}
// GoAutoMenuGroups returns deep copies so callers cannot change global menu
// ordering while building migrations or permission trees.
func GoAutoMenuGroups() []MenuGroupDefinition {
groups := make([]MenuGroupDefinition, len(goAutoMenuGroupMetadata))
copy(groups, goAutoMenuGroupMetadata)
for index := range groups {
groups[index].ModuleKeys = append([]string(nil), goAutoMenuGroupMetadata[index].ModuleKeys...)
}
return groups
}
func moduleKeyForAPI(path string) string {
switch {
case strings.HasPrefix(path, "/api/admin/v1/devices"):
+40
View File
@@ -61,6 +61,46 @@ func TestGoAutoMenuSortsFitMySQLSignedTinyInt(t *testing.T) {
}
}
func TestGoAutoMenuGroupsCoverModulesExactlyOnce(t *testing.T) {
groups := GoAutoMenuGroups()
if len(groups) != 2 {
t.Fatalf("got %d groups, want 2", len(groups))
}
if groups[0].Title != "采集采购" || groups[1].Title != "采采管理" {
t.Fatalf("unexpected group titles: %#v", groups)
}
wantOrder := [][]string{
{ModuleSYBProducts, ModuleSYBSyncRuns, ModuleSYBInnerCodes, ModuleShopeeProducts, ModulePDDProducts, ModuleCollectionTasks, ModulePurchaseTasks},
{ModuleSYBShops, ModuleCollectionRules, ModuleDevices, ModuleAIMatching},
}
seen := make(map[string]int)
for groupIndex, group := range groups {
if group.Sort < 0 || group.Sort > 127 {
t.Fatalf("group %s sort %d exceeds MySQL signed TINYINT", group.Key, group.Sort)
}
if len(group.ModuleKeys) != len(wantOrder[groupIndex]) {
t.Fatalf("group %s modules = %#v, want %#v", group.Key, group.ModuleKeys, wantOrder[groupIndex])
}
for index, key := range group.ModuleKeys {
if key != wantOrder[groupIndex][index] {
t.Fatalf("group %s module %d = %s, want %s", group.Key, index, key, wantOrder[groupIndex][index])
}
seen[key]++
}
}
for _, module := range GoAutoModules() {
if seen[module.Key] != 1 {
t.Fatalf("module %s belongs to %d groups, want exactly one", module.Key, seen[module.Key])
}
}
groups[0].ModuleKeys[0] = "mutated"
if GoAutoMenuGroups()[0].ModuleKeys[0] != ModuleSYBProducts {
t.Fatal("GoAutoMenuGroups must return deep copies")
}
}
func TestGoAutoRoutesAreGeneratedFromServerMenus(t *testing.T) {
_, fileName, _, ok := runtime.Caller(0)
if !ok {
@@ -0,0 +1,117 @@
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), migrateGoAutoMenuGroups)
}
func migrateGoAutoMenuGroups(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := ensureGoAutoMenuGroups(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
func ensureGoAutoMenuGroups(db *gorm.DB) error {
modules := make(map[string]access.ModuleDefinition)
for _, module := range access.GoAutoModules() {
modules[module.Key] = module
}
var purchaser migrationmodels.SysRole
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
return err
}
allowedPurchaserMenus := make([]migrationmodels.SysMenu, 0, len(access.GoAutoModules())+len(access.GoAutoMenuGroups()))
for _, group := range access.GoAutoMenuGroups() {
groupMenu, _, err := upsertGoAutoMenu(db, migrationmodels.SysMenu{
MenuName: group.MenuName,
Title: group.Title,
Icon: group.Icon,
Path: group.Path,
MenuType: "M",
Action: "无",
ParentId: 0,
Component: "Layout",
Sort: group.Sort,
Visible: "0",
IsFrame: "1",
})
if err != nil {
return err
}
if err := db.Model(&groupMenu).Update("paths", fmt.Sprintf("/0/%d", groupMenu.MenuId)).Error; err != nil {
return err
}
allowedPurchaserMenus = append(allowedPurchaserMenus, groupMenu)
for childIndex, moduleKey := range group.ModuleKeys {
module, ok := modules[moduleKey]
if !ok {
return fmt.Errorf("GoAuto menu group %s references unknown module %s", group.Key, moduleKey)
}
var child migrationmodels.SysMenu
if err := db.Unscoped().Where("menu_name = ?", module.RouteName).First(&child).Error; err != nil {
return fmt.Errorf("find GoAuto child menu %s: %w", module.RouteName, err)
}
updates := map[string]any{
"parent_id": groupMenu.MenuId,
"paths": fmt.Sprintf("/0/%d/%d", groupMenu.MenuId, child.MenuId),
"sort": childIndex + 1,
"deleted_at": nil,
}
if err := db.Unscoped().Model(&child).Updates(updates).Error; err != nil {
return err
}
if module.PurchaserHardHidden {
if err := db.Model(&purchaser).Association("SysMenu").Delete(&child); err != nil {
return err
}
} else if module.PurchaserDefault {
allowedPurchaserMenus = append(allowedPurchaserMenus, child)
}
}
}
if err := db.Model(&purchaser).Association("SysMenu").Append(allowedPurchaserMenus); err != nil {
return err
}
oldRootNames := make([]string, 0, len(access.GoAutoModules()))
for _, module := range access.GoAutoModules() {
oldRootNames = append(oldRootNames, module.RouteName+"Root")
}
var oldRoots []migrationmodels.SysMenu
if err := db.Unscoped().Where("menu_name IN ?", oldRootNames).Find(&oldRoots).Error; err != nil {
return err
}
if len(oldRoots) == 0 {
return nil
}
oldRootIDs := make([]int, 0, len(oldRoots))
for _, root := range oldRoots {
oldRootIDs = append(oldRootIDs, root.MenuId)
}
if err := db.Table("sys_role_menu").Where("menu_id IN ?", oldRootIDs).Delete(nil).Error; err != nil {
return err
}
if err := db.Table("sys_menu_api_rule").Where("sys_menu_menu_id IN ?", oldRootIDs).Delete(nil).Error; err != nil {
return err
}
return db.Unscoped().Where("menu_id IN ?", oldRootIDs).Delete(&migrationmodels.SysMenu{}).Error
}
@@ -0,0 +1,112 @@
package version_local
import (
"fmt"
"testing"
"go-admin/app/goauto/access"
migrationmodels "go-admin/cmd/migrate/migration/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestEnsureGoAutoMenuGroups(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)
}
if err = ensureGoAutoMenusAndPurchaserBindings(db); err != nil {
t.Fatal(err)
}
childIDs := make(map[string]int)
for _, module := range access.GoAutoModules() {
var child migrationmodels.SysMenu
if err = db.Where("menu_name = ?", module.RouteName).First(&child).Error; err != nil {
t.Fatal(err)
}
childIDs[module.RouteName] = child.MenuId
}
if err = ensureGoAutoMenuGroups(db); err != nil {
t.Fatal(err)
}
assertGoAutoGroupedMenuState(t, db, childIDs)
if err = ensureGoAutoMenuGroups(db); err != nil {
t.Fatalf("group reconciliation must be repeatable: %v", err)
}
assertGoAutoGroupedMenuState(t, db, childIDs)
var customAfter migrationmodels.SysMenu
if err = db.First(&customAfter, custom.MenuId).Error; err != nil {
t.Fatal("migration must not remove a user menu:", err)
}
}
func assertGoAutoGroupedMenuState(t *testing.T, db *gorm.DB, childIDs map[string]int) {
t.Helper()
modules := make(map[string]access.ModuleDefinition)
for _, module := range access.GoAutoModules() {
modules[module.Key] = module
var oldRootCount int64
if err := db.Unscoped().Model(&migrationmodels.SysMenu{}).
Where("menu_name = ?", module.RouteName+"Root").Count(&oldRootCount).Error; err != nil {
t.Fatal(err)
}
if oldRootCount != 0 {
t.Fatalf("old root %s still exists", module.RouteName+"Root")
}
}
var purchaser migrationmodels.SysRole
if err := db.Where("role_key = ?", access.RolePurchaser).First(&purchaser).Error; err != nil {
t.Fatal(err)
}
for _, group := range access.GoAutoMenuGroups() {
var parent migrationmodels.SysMenu
if err := db.Where("menu_name = ?", group.MenuName).First(&parent).Error; err != nil {
t.Fatal(err)
}
if parent.ParentId != 0 || parent.Title != group.Title || parent.Path != group.Path || parent.Paths != fmt.Sprintf("/0/%d", parent.MenuId) {
t.Fatalf("unexpected group menu: %#v", parent)
}
assertRoleHasMenu(t, db, purchaser.RoleId, parent.MenuId, true)
for index, moduleKey := range group.ModuleKeys {
module := modules[moduleKey]
var child migrationmodels.SysMenu
if err := db.Preload("SysApi").Where("menu_name = ?", module.RouteName).First(&child).Error; err != nil {
t.Fatal(err)
}
if child.MenuId != childIDs[module.RouteName] {
t.Fatalf("child %s ID changed from %d to %d", module.RouteName, childIDs[module.RouteName], child.MenuId)
}
if child.ParentId != parent.MenuId || child.Paths != fmt.Sprintf("/0/%d/%d", parent.MenuId, child.MenuId) || child.Sort != index+1 {
t.Fatalf("unexpected grouped child %s: %#v", module.RouteName, child)
}
if child.Path != module.Path+"/index" || child.Component != module.Component || len(child.SysApi) != len(module.APIs) {
t.Fatalf("route or API association changed for %s: %#v", module.RouteName, child)
}
assertRoleHasMenu(t, db, purchaser.RoleId, child.MenuId, !module.PurchaserHardHidden)
}
}
var goAutoCount int64
if err := db.Model(&migrationmodels.SysMenu{}).Where("menu_name LIKE ?", "GoAuto%").Count(&goAutoCount).Error; err != nil {
t.Fatal(err)
}
if want := int64(len(access.GoAutoModules()) + len(access.GoAutoMenuGroups())); goAutoCount != want {
t.Fatalf("got %d GoAuto rows, want %d", goAutoCount, want)
}
}