diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index fcfa6b6..99c6509 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -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 # 架构与代码地图 @@ -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` | diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 2a0f1fa..a42a25a 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -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 # 业务规则与术语 @@ -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` 策略授权,不得通过赋予管理员角色临时解决登录或权限问题。 - 任何角色都不得执行自动支付。 diff --git a/server/app/admin/service/sys_role.go b/server/app/admin/service/sys_role.go index 83b2c46..9fe5cf6 100644 --- a/server/app/admin/service/sys_role.go +++ b/server/app/admin/service/sys_role.go @@ -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 } diff --git a/server/app/admin/service/sys_role_access.go b/server/app/admin/service/sys_role_access.go new file mode 100644 index 0000000..28cce39 --- /dev/null +++ b/server/app/admin/service/sys_role_access.go @@ -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 +} diff --git a/server/app/admin/service/sys_role_access_test.go b/server/app/admin/service/sys_role_access_test.go new file mode 100644 index 0000000..1d1a770 --- /dev/null +++ b/server/app/admin/service/sys_role_access_test.go @@ -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") + } +} diff --git a/server/app/goauto/access/modules.go b/server/app/goauto/access/modules.go new file mode 100644 index 0000000..f40e911 --- /dev/null +++ b/server/app/goauto/access/modules.go @@ -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 +} diff --git a/server/app/goauto/access/modules_test.go b/server/app/goauto/access/modules_test.go new file mode 100644 index 0000000..daf6ce1 --- /dev/null +++ b/server/app/goauto/access/modules_test.go @@ -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) + } + } + } +} diff --git a/server/cmd/migrate/migration/version-local/1787885400000_goauto_menus.go b/server/cmd/migrate/migration/version-local/1787885400000_goauto_menus.go new file mode 100644 index 0000000..870f6eb --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787885400000_goauto_menus.go @@ -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 +} diff --git a/server/cmd/migrate/migration/version-local/1787885400000_goauto_menus_test.go b/server/cmd/migrate/migration/version-local/1787885400000_goauto_menus_test.go new file mode 100644 index 0000000..7b6cedb --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1787885400000_goauto_menus_test.go @@ -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) + } +}