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
+4 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Architecture-and-Code-Map
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Architecture-and-Code-Map.-
wiki_revision: 5ccc58883979c6593f9d16d7865d5b44716cb374
synchronized_at: 2026-08-28T12:43:45Z
wiki_revision: 267a75be046b2faedddbad86232e2c78b08ba92c
synchronized_at: 2026-08-28T13:17:46Z
<!-- gitea-wiki-mirror:end -->
# 架构与代码地图
@@ -129,7 +129,8 @@ Android Portal/Agent
| 管理端闭环页面 | `web/src/views/goauto/` |
| 备货采购服务端路径 | `purchase_task.task_type` 与迁移 `1787885300000_stock_purchase.go`;`POST /api/admin/v1/purchase-tasks/stock` 由 `server/app/goauto/purchase/service.go` 校验 PDD 当前可选规格并固化 `direct_select`,复用既有任务状态机和设备/账号互斥;重试、替换和 SYB 回填显式排除 `stock` |
| Admin 采购任务列表、详情与人工处理 | `web/src/views/goauto/purchase-tasks/`、`web/src/api/goauto/purchase-tasks.js`;创建入口不在本模块 |
| Admin AI 规格匹配设置 | `web/src/views/goauto/ai-matching-settings/`、`web/src/api/goauto/ai-matching-settings.js`;管理员可查看、维护和测试 Provider(包括内部明文 API Key),采购员只可查看启用状态 |
| Admin AI 规格匹配设置 | `web/src/views/goauto/ai-matching-settings/`、`web/src/api/goauto/ai-matching-settings.js`;管理员可查看、维护和测试 Provider(包括内部明文 API Key);采购员菜单硬排除,仍只可通过受控 API 查看启用状态 |
| GoAuto 系统菜单与采购员权限基线 | `server/app/goauto/access/` 统一声明 11 个模块、路由元数据和 Admin API 权限矩阵;迁移 `1787885400000_goauto_menus.go` 幂等维护 `sys_menu`、`sys_menu_api_rule`、采购员默认菜单绑定和 Casbin 固定白名单。菜单可见性与 API 授权彼此独立 |
| Admin 失败采购任务批量重试 | `POST /api/admin/v1/purchase-tasks/batch-retry`;服务端 `server/app/goauto/purchase/retry.go` 负责资格判定、逐项幂等创建与部分成功结果,Admin 页面只允许选择服务端标记可重试的行;不修改 Android Agent |
| SYB 店铺管理页面与接口封装 | `web/src/views/goauto/syb-shops/`、`web/src/api/goauto/syb-shops.js`;确认原型快照 `prototypes/49/v1/index.html` |
| SYB 异步导入、商品筛选、当前页采购选择/确认/逐条结果与同步记录页面 | `web/src/views/goauto/syb-products/`、`web/src/views/goauto/syb-sync-runs/`、`web/src/api/goauto/syb-products.js`、`web/src/api/goauto/purchase-tasks.js`;商品列表按店铺名称包含匹配,并支持最多 100 个多行订单号精确筛选;确认原型见 #44 设计证据,导入原型快照为 `prototypes/50/v2/index.html` |
+5 -3
View File
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
wiki_revision: b78825c5e140a3e24948107628d6ced0db6f6a37
synchronized_at: 2026-08-28T12:43:51Z
wiki_revision: 92a67263229d3f98861b2526422d12ce13bb81c1
synchronized_at: 2026-08-28T13:17:54Z
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -160,7 +160,9 @@ synchronized_at: 2026-08-28T12:43:51Z
- 管理端固定支持 `admin`(管理员)和 `purchaser`(采购员)两类业务角色;用户必须绑定一个存在且启用的角色,`role_id=0` 或停用角色不能创建或保存。
- 采购员可读取设备状态,维护 PDD/虾皮商品和规格映射,查看与修正 SYB 商品,读取 SYB 店铺及同步记录,读取采集规则,创建/重置/删除采集任务,并创建、查看、重试及人工处理采购任务。
- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;手动启动 SYB 同步;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。
- 采购员只能读取 AI 匹配是否启用,不得读取 Provider 地址、模型、API Key 等敏感配置。
- AI 规格匹配菜单对采购员硬排除:采购员不显示该菜单,角色配置也不能为采购员选中该模块;既有 API 权限不变,仍只允许读取是否启用,不得读取 Provider 地址、模型、API Key,也不得保存或测试。
- GoAuto 菜单由代码维护的模块定义幂等写入系统菜单和菜单/API 关联;迁移只为采购员追加首次引入且默认开放的模块,不会把采购员人工取消勾选的既有模块重新加回。
- 采购员 Casbin API 白名单由代码权限矩阵全量重建,不从角色菜单勾选反推;减少权限时旧策略必须删除。菜单勾选只控制可见模块,不能扩大采购员 API 权限。
- GoAuto 管理接口使用 Casbin 按 HTTP 方法和路径授权;设备凭据、规则和外部服务配置等高风险写操作另有管理员角色守卫,前端隐藏入口不能替代服务端授权。
- 管理员继续使用 `admin` 旁路;采购员必须由明确的 `purchaser` 策略授权,不得通过赋予管理员角色临时解决登录或权限问题。
- 任何角色都不得执行自动支付。
+24 -23
View File
@@ -13,6 +13,7 @@ import (
"go-admin/app/admin/models"
"go-admin/app/admin/service/dto"
"go-admin/app/goauto/access"
cDto "go-admin/common/dto"
)
@@ -66,6 +67,11 @@ func (e *SysRole) Insert(c *dto.SysRoleInsertReq, cb *casbin.SyncedEnforcer) err
var err error
var data models.SysRole
var dataMenu []models.SysMenu
if c.RoleKey == access.RolePurchaser {
if err = validatePurchaserMenuSelection(e.Orm, c.MenuIds); err != nil {
return err
}
}
err = e.Orm.Preload("SysApi").Where("menu_id in ?", c.MenuIds).Find(&dataMenu).Error
if err != nil {
e.Log.Errorf("db error:%s", err)
@@ -103,16 +109,7 @@ func (e *SysRole) Insert(c *dto.SysRoleInsertReq, cb *casbin.SyncedEnforcer) err
return err
}
mp := make(map[string]interface{}, 0)
polices := make([][]string, 0)
for _, menu := range dataMenu {
for _, api := range menu.SysApi {
if mp[data.RoleKey+"-"+api.Path+"-"+api.Action] != "" {
mp[data.RoleKey+"-"+api.Path+"-"+api.Action] = ""
polices = append(polices, []string{data.RoleKey, api.Path, api.Action})
}
}
}
polices := policiesForRole(data.RoleKey, dataMenu)
if len(polices) <= 0 {
return nil
@@ -143,8 +140,22 @@ func (e *SysRole) Update(c *dto.SysRoleUpdateReq, cb *casbin.SyncedEnforcer) err
}
var model = models.SysRole{}
var mlist = make([]models.SysMenu, 0)
tx.Preload("SysMenu").First(&model, c.GetId())
tx.Preload("SysApi").Where("menu_id in ?", c.MenuIds).Find(&mlist)
if err = tx.Preload("SysMenu").First(&model, c.GetId()).Error; err != nil {
return err
}
if model.RoleKey == access.RolePurchaser {
if c.RoleKey != access.RolePurchaser {
return errors.New("采购员角色标识由系统维护,不能修改")
}
if err = validatePurchaserMenuSelection(tx, c.MenuIds); err != nil {
return err
}
} else if c.RoleKey == access.RolePurchaser {
return errors.New("采购员角色标识由系统维护,不能转移给其他角色")
}
if err = tx.Preload("SysApi").Where("menu_id in ?", c.MenuIds).Find(&mlist).Error; err != nil {
return err
}
err = tx.Model(&model).Association("SysMenu").Delete(model.SysMenu)
if err != nil {
e.Log.Errorf("delete policy error:%s", err)
@@ -169,17 +180,7 @@ func (e *SysRole) Update(c *dto.SysRoleUpdateReq, cb *casbin.SyncedEnforcer) err
e.Log.Errorf("delete policy error:%s", err)
return err
}
mp := make(map[string]interface{}, 0)
polices := make([][]string, 0)
for _, menu := range mlist {
for _, api := range menu.SysApi {
if mp[model.RoleKey+"-"+api.Path+"-"+api.Action] != "" {
mp[model.RoleKey+"-"+api.Path+"-"+api.Action] = ""
//_, err = cb.AddNamedPolicy("p", model.RoleKey, api.Path, api.Action)
polices = append(polices, []string{model.RoleKey, api.Path, api.Action})
}
}
}
polices := policiesForRole(model.RoleKey, mlist)
if len(polices) <= 0 {
return nil
}
@@ -0,0 +1,60 @@
package service
import (
"errors"
"go-admin/app/admin/models"
"go-admin/app/goauto/access"
"gorm.io/gorm"
)
func validatePurchaserMenuSelection(db *gorm.DB, menuIDs []int) error {
if len(menuIDs) == 0 {
return nil
}
hardHiddenNames := make([]string, 0)
for _, module := range access.GoAutoModules() {
if module.PurchaserHardHidden {
hardHiddenNames = append(hardHiddenNames, module.RouteName, module.RouteName+"Root")
}
}
var count int64
if err := db.Model(&models.SysMenu{}).
Where("menu_id IN ? AND menu_name IN ?", menuIDs, hardHiddenNames).
Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("采购员角色不能配置 AI 规格匹配菜单")
}
return nil
}
func policiesForRole(roleKey string, menus []models.SysMenu) [][]string {
policies := make([][]string, 0)
seen := make(map[string]struct{})
if roleKey == access.RolePurchaser {
for _, permission := range access.PurchaserAPIs() {
key := permission.Path + "\x00" + permission.Method
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
policies = append(policies, []string{roleKey, permission.Path, permission.Method})
}
return policies
}
for _, menu := range menus {
for _, api := range menu.SysApi {
key := api.Path + "\x00" + api.Action
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
policies = append(policies, []string{roleKey, api.Path, api.Action})
}
}
return policies
}
@@ -0,0 +1,50 @@
package service
import (
"testing"
"go-admin/app/admin/models"
"go-admin/app/goauto/access"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestPoliciesForPurchaserIgnoreSelectedMenuAPIs(t *testing.T) {
menus := []models.SysMenu{{
SysApi: []models.SysApi{{Path: "/api/admin/v1/ai-matching-settings/test", Action: "POST"}},
}}
policies := policiesForRole(access.RolePurchaser, menus)
if len(policies) != len(access.PurchaserAPIs()) {
t.Fatalf("got %d policies, want %d", len(policies), len(access.PurchaserAPIs()))
}
for _, policy := range policies {
if policy[1] == "/api/admin/v1/ai-matching-settings/test" {
t.Fatal("selected menu must not grant an administrator-only API")
}
}
}
func TestValidatePurchaserMenuSelectionRejectsAI(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&models.SysMenu{}); err != nil {
t.Fatal(err)
}
ai := models.SysMenu{MenuName: "GoAutoAiMatchingSettings", Title: "AI 规格匹配"}
other := models.SysMenu{MenuName: "GoAutoPurchaseTasks", Title: "采购管理"}
if err = db.Create(&ai).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&other).Error; err != nil {
t.Fatal(err)
}
if err = validatePurchaserMenuSelection(db, []int{other.MenuId}); err != nil {
t.Fatalf("ordinary purchaser menu rejected: %v", err)
}
if err = validatePurchaserMenuSelection(db, []int{ai.MenuId}); err == nil {
t.Fatal("AI matching menu selection must be rejected")
}
}
+100
View File
@@ -0,0 +1,100 @@
package access
import "strings"
const (
ModuleDevices = "devices"
ModulePDDProducts = "pdd_products"
ModuleShopeeProducts = "shopee_products"
ModuleSYBProducts = "syb_products"
ModuleSYBShops = "syb_shops"
ModuleSYBSyncRuns = "syb_sync_runs"
ModuleSYBInnerCodes = "syb_inner_codes"
ModuleCollectionRules = "collection_rules"
ModuleCollectionTasks = "collection_tasks"
ModulePurchaseTasks = "purchase_tasks"
ModuleAIMatching = "ai_matching"
)
// ModuleDefinition is the single source of truth shared by menu migration,
// role protection and the Web dynamic-route migration. APIs are derived from
// AdminAPIs so the permission matrix remains the authoritative API inventory.
type ModuleDefinition struct {
Key string
Title string
Path string
RouteName string
Component string
Icon string
Sort int
PurchaserDefault bool
PurchaserHardHidden bool
APIs []APIPermission
}
var goAutoModuleMetadata = []ModuleDefinition{
{Key: ModuleDevices, Title: "设备列表", Path: "/devices", RouteName: "GoAutoDeviceList", Component: "/goauto/devices/index", Icon: "monitor", Sort: 100, PurchaserDefault: true},
{Key: ModulePDDProducts, Title: "PDD 商品", Path: "/pdd-products", RouteName: "GoAutoPddProducts", Component: "/goauto/pdd-products/index", Icon: "shopping", Sort: 110, PurchaserDefault: true},
{Key: ModuleShopeeProducts, Title: "虾皮商品", Path: "/shopee-products", RouteName: "GoAutoShopeeProducts", Component: "/goauto/shopee-products/index", Icon: "goods", Sort: 120, PurchaserDefault: true},
{Key: ModuleSYBProducts, Title: "SYB 商品", Path: "/syb-products", RouteName: "GoAutoSybProducts", Component: "/goauto/syb-products/index", Icon: "tickets", Sort: 130, PurchaserDefault: true},
{Key: ModuleSYBShops, Title: "SYB 店铺", Path: "/syb-shops", RouteName: "GoAutoSybShops", Component: "/goauto/syb-shops/index", Icon: "shopping", Sort: 140, PurchaserDefault: true},
{Key: ModuleSYBSyncRuns, Title: "SYB 同步记录", Path: "/syb-sync-runs", RouteName: "GoAutoSybSyncRuns", Component: "/goauto/syb-sync-runs/index", Icon: "time", Sort: 150, PurchaserDefault: true},
{Key: ModuleSYBInnerCodes, Title: "档口入库码", Path: "/syb-inner-codes", RouteName: "GoAutoSybInnerCodes", Component: "/goauto/syb-inner-codes/index", Icon: "list", Sort: 160, PurchaserDefault: true},
{Key: ModuleCollectionRules, Title: "采集规则", Path: "/collection-rules", RouteName: "GoAutoCollectionRules", Component: "/goauto/collection-rules/index", Icon: "code", Sort: 170, PurchaserDefault: true},
{Key: ModuleCollectionTasks, Title: "采集任务", Path: "/collection-tasks", RouteName: "GoAutoCollectionTasks", Component: "/goauto/collection-tasks/index", Icon: "list", Sort: 180, PurchaserDefault: true},
{Key: ModulePurchaseTasks, Title: "采购管理", Path: "/purchase-tasks", RouteName: "GoAutoPurchaseTasks", Component: "/goauto/purchase-tasks/index", Icon: "shopping", Sort: 190, PurchaserDefault: true},
{Key: ModuleAIMatching, Title: "AI 规格匹配", Path: "/ai-matching-settings", RouteName: "GoAutoAiMatchingSettings", Component: "/goauto/ai-matching-settings/index", Icon: "setting", Sort: 200, PurchaserHardHidden: true},
}
// GoAutoModules returns independent copies so callers cannot mutate the
// process-wide permission metadata.
func GoAutoModules() []ModuleDefinition {
modules := make([]ModuleDefinition, len(goAutoModuleMetadata))
copy(modules, goAutoModuleMetadata)
for index := range modules {
for _, permission := range AdminAPIs {
if moduleKeyForAPI(permission.Path) == modules[index].Key {
modules[index].APIs = append(modules[index].APIs, permission)
}
}
}
return modules
}
func moduleKeyForAPI(path string) string {
switch {
case strings.HasPrefix(path, "/api/admin/v1/devices"):
return ModuleDevices
case strings.HasPrefix(path, "/api/admin/v1/pdd-product"):
return ModulePDDProducts
case strings.HasPrefix(path, "/api/admin/v1/shopee-products"):
return ModuleShopeeProducts
case strings.HasPrefix(path, "/api/admin/v1/syb-products/sync-runs"):
return ModuleSYBSyncRuns
case strings.HasPrefix(path, "/api/admin/v1/syb-products"):
return ModuleSYBProducts
case strings.HasPrefix(path, "/api/admin/v1/syb-shops"):
return ModuleSYBShops
case strings.HasPrefix(path, "/api/admin/v1/syb-inner-codes"):
return ModuleSYBInnerCodes
case strings.HasPrefix(path, "/api/admin/v1/collection-rules"):
return ModuleCollectionRules
case strings.HasPrefix(path, "/api/admin/v1/collection-tasks"):
return ModuleCollectionTasks
case strings.HasPrefix(path, "/api/admin/v1/purchase-tasks"):
return ModulePurchaseTasks
case strings.HasPrefix(path, "/api/admin/v1/ai-matching-settings"):
return ModuleAIMatching
default:
return ""
}
}
func ModuleByRouteName(routeName string) (ModuleDefinition, bool) {
for _, module := range GoAutoModules() {
if module.RouteName == routeName {
return module, true
}
}
return ModuleDefinition{}, false
}
+70
View File
@@ -0,0 +1,70 @@
package access
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestGoAutoModulesOwnEveryAdminAPIExactlyOnce(t *testing.T) {
modules := GoAutoModules()
if len(modules) != 11 {
t.Fatalf("got %d modules, want 11", len(modules))
}
owners := make(map[string]int)
for _, module := range modules {
if module.Key == "" || module.Path == "" || module.RouteName == "" || module.Component == "" {
t.Fatalf("module metadata is incomplete: %#v", module)
}
for _, permission := range module.APIs {
owners[permission.Method+" "+permission.Path]++
}
}
for _, permission := range AdminAPIs {
key := permission.Method + " " + permission.Path
if owners[key] != 1 {
t.Fatalf("%s belongs to %d modules, want exactly one", key, owners[key])
}
}
}
func TestAIMatchingIsHardHiddenFromPurchaser(t *testing.T) {
module, ok := ModuleByRouteName("GoAutoAiMatchingSettings")
if !ok {
t.Fatal("AI matching module not found")
}
if module.PurchaserDefault || !module.PurchaserHardHidden {
t.Fatalf("unexpected AI purchaser flags: %#v", module)
}
if len(module.APIs) == 0 {
t.Fatal("AI matching module must own its APIs")
}
}
func TestGoAutoModuleRoutesMatchCurrentWebRouter(t *testing.T) {
_, fileName, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate test source")
}
routerPath := filepath.Join(filepath.Dir(fileName), "..", "..", "..", "..", "web", "src", "router", "index.js")
content, err := os.ReadFile(routerPath)
if err != nil {
t.Fatal(err)
}
router := string(content)
for _, module := range GoAutoModules() {
want := []string{
"path: '" + module.Path + "'",
"component: () => import('@/views" + module.Component + "')",
"name: '" + module.RouteName + "'",
}
for _, fragment := range want {
if !strings.Contains(router, fragment) {
t.Fatalf("Web router is inconsistent with module %s: missing %q", module.Key, fragment)
}
}
}
}
@@ -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)
}
}