feat(goauto): add configurable module menus (#137)
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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` 策略授权,不得通过赋予管理员角色临时解决登录或权限问题。
|
||||
- 任何角色都不得执行自动支付。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user