From ae9a9fef6f0bc0555ac4a281c5779f5886333e75 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 25 Aug 2026 14:25:22 +0800 Subject: [PATCH] fix: add purchaser role permissions (#85) --- docs/03-business-rules-and-glossary.md | 14 ++- server/app/admin/service/dto/sys_user.go | 4 +- server/app/admin/service/sys_user.go | 21 ++++ .../app/admin/service/sys_user_role_test.go | 34 +++++++ server/app/goauto/access/purchaser.go | 97 +++++++++++++++++++ server/app/goauto/access/purchaser_test.go | 28 ++++++ server/app/goauto/device/router.go | 4 +- server/app/goauto/rule/router.go | 6 +- server/app/goauto/sybimport/router.go | 2 +- server/app/goauto/sybshop/router.go | 8 +- .../1786701700000_purchaser_role.go | 78 +++++++++++++++ .../1786701700000_purchaser_role_test.go | 48 +++++++++ server/common/middleware/role_key.go | 26 +++++ server/common/middleware/role_key_test.go | 31 ++++++ server/common/middleware/settings.go | 3 +- web/src/views/admin/sys-user/index.vue | 9 +- .../views/goauto/collection-rules/index.vue | 5 +- web/src/views/goauto/devices/index.vue | 5 +- 18 files changed, 400 insertions(+), 23 deletions(-) create mode 100644 server/app/admin/service/sys_user_role_test.go create mode 100644 server/app/goauto/access/purchaser.go create mode 100644 server/app/goauto/access/purchaser_test.go create mode 100644 server/cmd/migrate/migration/version-local/1786701700000_purchaser_role.go create mode 100644 server/cmd/migrate/migration/version-local/1786701700000_purchaser_role_test.go create mode 100644 server/common/middleware/role_key.go create mode 100644 server/common/middleware/role_key_test.go diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index ab84eb9..79139e2 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: 73e38d6204acee7b6ea2c2eb9e9c918e807dbf3a -synchronized_at: 2026-08-25T01:39:35Z +wiki_revision: 6bad462cbf91d592ba95fbe2003402d20a589cc3 +synchronized_at: 2026-08-25T06:21:31Z # 业务规则与术语 @@ -152,6 +152,16 @@ synchronized_at: 2026-08-25T01:39:35Z - 只有正式采购、未进入不可逆边界、没有订单号或下单时间、且仍是同一 SYB 最新记录的 `failed` 任务可重试。原设备离线、停用、忙碌或能力不足时该项失败且不自动换机;未指定设备时仍由空闲设备领取。 - 批量重试逐项处理并允许部分成功;同一请求幂等重放不会重复创建。失败任务不再使用一次性重新采购授权,该授权只保留给已经创建过订单且满足条件的任务。 +## 管理端角色与权限 + +- 管理端固定支持 `admin`(管理员)和 `purchaser`(采购员)两类业务角色;用户必须绑定一个存在且启用的角色,`role_id=0` 或停用角色不能创建或保存。 +- 采购员可读取设备状态,维护 PDD/虾皮商品和规格映射,查看与修正 SYB 商品,读取 SYB 店铺及同步记录,读取采集规则,创建/重置/删除采集任务,并创建、查看、重试及人工处理采购任务。 +- 仅管理员可管理用户、角色、菜单、接口、部门和岗位;停用设备或吊销 Device Token;新增、改名、启停、删除或发现 SYB 店铺;手动启动 SYB 同步;新增、编辑或删除采集规则;保存或测试 AI Provider 配置。 +- 采购员只能读取 AI 匹配是否启用,不得读取 Provider 地址、模型、API Key 等敏感配置。 +- GoAuto 管理接口使用 Casbin 按 HTTP 方法和路径授权;设备凭据、规则和外部服务配置等高风险写操作另有管理员角色守卫,前端隐藏入口不能替代服务端授权。 +- 管理员继续使用 `admin` 旁路;采购员必须由明确的 `purchaser` 策略授权,不得通过赋予管理员角色临时解决登录或权限问题。 +- 任何角色都不得执行自动支付。 + ## 自动化边界 - 浏览器“打开拼多多APP”和系统确认框“打开”属于允许动作。 diff --git a/server/app/admin/service/dto/sys_user.go b/server/app/admin/service/dto/sys_user.go index b1de094..4a408d2 100644 --- a/server/app/admin/service/dto/sys_user.go +++ b/server/app/admin/service/dto/sys_user.go @@ -94,7 +94,7 @@ type SysUserInsertReq struct { Password string `json:"password" comment:"密码"` NickName string `json:"nickName" comment:"昵称" vd:"len($)>0"` Phone string `json:"phone" comment:"手机号" vd:"len($)>0"` - RoleId int `json:"roleId" comment:"角色ID"` + RoleId int `json:"roleId" comment:"角色ID" vd:"$>0"` Avatar string `json:"avatar" comment:"头像"` Sex string `json:"sex" comment:"性别"` Email string `json:"email" comment:"邮箱" vd:"len($)>0,email"` @@ -133,7 +133,7 @@ type SysUserUpdateReq struct { Username string `json:"username" comment:"用户名" vd:"len($)>0"` NickName string `json:"nickName" comment:"昵称" vd:"len($)>0"` Phone string `json:"phone" comment:"手机号" vd:"len($)>0"` - RoleId int `json:"roleId" comment:"角色ID"` + RoleId int `json:"roleId" comment:"角色ID" vd:"$>0"` Avatar string `json:"avatar" comment:"头像"` Sex string `json:"sex" comment:"性别"` Email string `json:"email" comment:"邮箱" vd:"len($)>0,email"` diff --git a/server/app/admin/service/sys_user.go b/server/app/admin/service/sys_user.go index 2a03c03..58fddef 100644 --- a/server/app/admin/service/sys_user.go +++ b/server/app/admin/service/sys_user.go @@ -74,6 +74,9 @@ func (e *SysUser) Insert(c *dto.SysUserInsertReq) error { e.Log.Errorf("db error: %s", err) return err } + if err = validateEnabledRole(e.Orm, c.RoleId); err != nil { + return err + } c.Generate(&data) err = e.Orm.Create(&data).Error if err != nil { @@ -98,6 +101,9 @@ func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission) err return errors.New("无权更新该数据") } + if err = validateEnabledRole(e.Orm, c.RoleId); err != nil { + return err + } c.Generate(&model) update := e.Orm.Model(&model).Where("user_id = ?", &model.UserId).Omit("password", "salt").Updates(&model) if err = update.Error; err != nil { @@ -112,6 +118,21 @@ func (e *SysUser) Update(c *dto.SysUserUpdateReq, p *actions.DataPermission) err return nil } +func validateEnabledRole(db *gorm.DB, roleID int) error { + if roleID <= 0 { + return errors.New("请选择有效角色") + } + var count int64 + if err := db.Model(&models.SysRole{}). + Where("role_id = ? AND status = ?", roleID, "2").Count(&count).Error; err != nil { + return err + } + if count != 1 { + return errors.New("所选角色不存在或已停用") + } + return nil +} + // UpdateAvatar 更新用户头像 func (e *SysUser) UpdateAvatar(c *dto.UpdateSysUserAvatarReq, p *actions.DataPermission) error { var err error diff --git a/server/app/admin/service/sys_user_role_test.go b/server/app/admin/service/sys_user_role_test.go new file mode 100644 index 0000000..d1efe58 --- /dev/null +++ b/server/app/admin/service/sys_user_role_test.go @@ -0,0 +1,34 @@ +package service + +import ( + "testing" + + "go-admin/app/admin/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestValidateEnabledRole(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&models.SysRole{}); err != nil { + t.Fatal(err) + } + if err = db.Create(&models.SysRole{RoleName: "采购员", RoleKey: "purchaser", Status: "2"}).Error; err != nil { + t.Fatal(err) + } + var role models.SysRole + db.Where("role_key = ?", "purchaser").First(&role) + if err = validateEnabledRole(db, role.RoleId); err != nil { + t.Fatalf("enabled role rejected: %v", err) + } + if err = validateEnabledRole(db, 0); err == nil { + t.Fatal("role zero must be rejected") + } + if err = validateEnabledRole(db, role.RoleId+100); err == nil { + t.Fatal("missing role must be rejected") + } +} diff --git a/server/app/goauto/access/purchaser.go b/server/app/goauto/access/purchaser.go new file mode 100644 index 0000000..551f89c --- /dev/null +++ b/server/app/goauto/access/purchaser.go @@ -0,0 +1,97 @@ +package access + +// RolePurchaser is the fixed role key used by the GoAuto purchaser account. +const RolePurchaser = "purchaser" + +// APIPermission describes one admin API known to GoAuto. Purchaser marks the +// APIs that the purchaser role may call; every other API remains admin-only. +type APIPermission struct { + Title string + Path string + Method string + Purchaser bool +} + +// AdminAPIs is the auditable permission matrix for GoAuto's authenticated +// admin surface. Agent APIs are intentionally excluded from management roles. +var AdminAPIs = []APIPermission{ + {"查看设备", "/api/admin/v1/devices", "GET", true}, + {"停用设备", "/api/admin/v1/devices/:deviceId/disable", "POST", false}, + {"吊销设备令牌", "/api/admin/v1/devices/:deviceId/token/revoke", "POST", false}, + + {"查看 PDD 商品", "/api/admin/v1/pdd-products", "GET", true}, + {"新增 PDD 商品", "/api/admin/v1/pdd-products", "POST", true}, + {"查看 PDD 商品详情", "/api/admin/v1/pdd-products/:productId", "GET", true}, + {"修改 PDD 商品", "/api/admin/v1/pdd-products/:productId", "PATCH", true}, + + {"查看虾皮商品", "/api/admin/v1/shopee-products", "GET", true}, + {"新增虾皮商品", "/api/admin/v1/shopee-products", "POST", true}, + {"批量删除虾皮商品", "/api/admin/v1/shopee-products/batch-delete", "POST", true}, + {"查看虾皮商品详情", "/api/admin/v1/shopee-products/:productId", "GET", true}, + {"修改虾皮商品", "/api/admin/v1/shopee-products/:productId", "PATCH", true}, + {"关联 PDD 商品", "/api/admin/v1/shopee-products/:productId/link-pdd", "POST", true}, + {"恢复虾皮商品", "/api/admin/v1/shopee-products/:productId/restore", "POST", true}, + {"新增虾皮规格值", "/api/admin/v1/shopee-products/:productId/specs/values", "POST", true}, + {"删除虾皮规格值", "/api/admin/v1/shopee-products/:productId/specs/values", "DELETE", true}, + {"设置规格映射", "/api/admin/v1/shopee-products/:productId/specs/mapping", "PUT", true}, + {"清除规格映射", "/api/admin/v1/shopee-products/:productId/specs/mapping", "DELETE", true}, + {"确认规格映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/confirm", "POST", true}, + {"确认精确规格映射", "/api/admin/v1/shopee-products/:productId/specs/mapping/confirm-exact-matches", "POST", true}, + {"预览尺码匹配", "/api/admin/v1/shopee-products/:productId/specs/mapping/preview-auto-size", "POST", true}, + + {"查看 SYB 商品", "/api/admin/v1/syb-products", "GET", true}, + {"查看 SYB 商品详情", "/api/admin/v1/syb-products/:productId", "GET", true}, + {"重新解析 SYB 商品", "/api/admin/v1/syb-products/:productId/reparse", "POST", true}, + {"批量重新解析 SYB 商品", "/api/admin/v1/syb-products/reparse-batch", "POST", true}, + {"人工修正 SYB 商品", "/api/admin/v1/syb-products/:productId/correction", "PATCH", true}, + {"手动同步 SYB 商品", "/api/admin/v1/syb-products/import", "POST", false}, + {"查看 SYB 同步记录", "/api/admin/v1/syb-products/sync-runs", "GET", true}, + {"查看 SYB 同步详情", "/api/admin/v1/syb-products/sync-runs/:runId", "GET", true}, + + {"查看 SYB 店铺", "/api/admin/v1/syb-shops", "GET", true}, + {"新增 SYB 店铺", "/api/admin/v1/syb-shops", "POST", false}, + {"修改 SYB 店铺名称", "/api/admin/v1/syb-shops/:shopId/name", "PATCH", false}, + {"启停 SYB 店铺", "/api/admin/v1/syb-shops/:shopId/enabled", "PATCH", false}, + {"删除 SYB 店铺", "/api/admin/v1/syb-shops/:shopId", "DELETE", false}, + {"从 SYB 发现店铺", "/api/admin/v1/syb-shops/discover", "POST", false}, + + {"查看采集规则模板", "/api/admin/v1/collection-rules/templates/:templateId", "GET", true}, + {"查看采集规则", "/api/admin/v1/collection-rules", "GET", true}, + {"新增采集规则", "/api/admin/v1/collection-rules", "POST", false}, + {"修改采集规则", "/api/admin/v1/collection-rules/:ruleId", "PATCH", false}, + {"删除采集规则", "/api/admin/v1/collection-rules/:ruleId", "DELETE", false}, + + {"查看采集任务", "/api/admin/v1/collection-tasks", "GET", true}, + {"创建采集任务", "/api/admin/v1/collection-tasks", "POST", true}, + {"批量创建采集任务", "/api/admin/v1/collection-tasks/batch", "POST", true}, + {"查看采集任务详情", "/api/admin/v1/collection-tasks/:taskId", "GET", true}, + {"重置采集任务", "/api/admin/v1/collection-tasks/:taskId/reset", "POST", true}, + {"删除采集任务", "/api/admin/v1/collection-tasks/:taskId", "DELETE", true}, + + {"查看采购任务", "/api/admin/v1/purchase-tasks", "GET", true}, + {"预检批量采购", "/api/admin/v1/purchase-tasks/batch-preview", "POST", true}, + {"批量创建采购任务", "/api/admin/v1/purchase-tasks/batch", "POST", true}, + {"批量重试采购任务", "/api/admin/v1/purchase-tasks/batch-retry", "POST", true}, + {"查看采购任务详情", "/api/admin/v1/purchase-tasks/:taskId", "GET", true}, + {"创建采购任务", "/api/admin/v1/purchase-tasks", "POST", true}, + {"处理采购规格", "/api/admin/v1/purchase-tasks/:taskId/spec-decision", "POST", true}, + {"授权重新采购", "/api/admin/v1/purchase-tasks/:taskId/authorize-repurchase", "POST", true}, + {"复核支付状态", "/api/admin/v1/purchase-tasks/:taskId/payment-review", "POST", true}, + {"选择回填候选", "/api/admin/v1/purchase-tasks/:taskId/writeback-candidate", "POST", true}, + {"取消采购任务", "/api/admin/v1/purchase-tasks/:taskId/cancel", "POST", true}, + {"处理结果不明确任务", "/api/admin/v1/purchase-tasks/:taskId/resolve-unknown", "POST", true}, + + {"查看 AI 匹配状态", "/api/admin/v1/ai-matching-settings", "GET", true}, + {"保存 AI 匹配设置", "/api/admin/v1/ai-matching-settings", "PUT", false}, + {"测试 AI 服务连接", "/api/admin/v1/ai-matching-settings/test", "POST", false}, +} + +func PurchaserAPIs() []APIPermission { + result := make([]APIPermission, 0, len(AdminAPIs)) + for _, permission := range AdminAPIs { + if permission.Purchaser { + result = append(result, permission) + } + } + return result +} diff --git a/server/app/goauto/access/purchaser_test.go b/server/app/goauto/access/purchaser_test.go new file mode 100644 index 0000000..12338d0 --- /dev/null +++ b/server/app/goauto/access/purchaser_test.go @@ -0,0 +1,28 @@ +package access + +import "testing" + +func TestPurchaserPermissionMatrixHasNoDuplicates(t *testing.T) { + seen := map[string]bool{} + for _, permission := range AdminAPIs { + key := permission.Method + " " + permission.Path + if seen[key] { + t.Fatalf("duplicate API permission: %s", key) + } + seen[key] = true + } +} + +func TestPurchaserExcludesAdministratorOperations(t *testing.T) { + denied := map[string]bool{ + "POST /api/admin/v1/devices/:deviceId/disable": true, + "POST /api/admin/v1/syb-products/import": true, + "POST /api/admin/v1/collection-rules": true, + "PUT /api/admin/v1/ai-matching-settings": true, + } + for _, permission := range PurchaserAPIs() { + if denied[permission.Method+" "+permission.Path] { + t.Fatalf("administrator-only API granted to purchaser: %s %s", permission.Method, permission.Path) + } + } +} diff --git a/server/app/goauto/device/router.go b/server/app/goauto/device/router.go index 3dd1fab..0e62220 100644 --- a/server/app/goauto/device/router.go +++ b/server/app/goauto/device/router.go @@ -21,6 +21,6 @@ func InitRouter(engine *gin.Engine, authMiddleware *jwt.GinJWTMiddleware) { admin := engine.Group("/api/admin/v1/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()) admin.GET("", handler.List) - admin.POST("/:deviceId/disable", handler.Disable) - admin.POST("/:deviceId/token/revoke", handler.RevokeToken) + admin.POST("/:deviceId/disable", middleware.RequireRoleKey("admin"), handler.Disable) + admin.POST("/:deviceId/token/revoke", middleware.RequireRoleKey("admin"), handler.RevokeToken) } diff --git a/server/app/goauto/rule/router.go b/server/app/goauto/rule/router.go index 6f7cb8d..e23e865 100644 --- a/server/app/goauto/rule/router.go +++ b/server/app/goauto/rule/router.go @@ -11,7 +11,7 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { admin := engine.Group("/api/admin/v1/collection-rules").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()) admin.GET("/templates/:templateId", handler.Template) admin.GET("", handler.List) - admin.POST("", handler.Create) - admin.PATCH("/:ruleId", handler.Update) - admin.DELETE("/:ruleId", handler.Delete) + admin.POST("", middleware.RequireRoleKey("admin"), handler.Create) + admin.PATCH("/:ruleId", middleware.RequireRoleKey("admin"), handler.Update) + admin.DELETE("/:ruleId", middleware.RequireRoleKey("admin"), handler.Delete) } diff --git a/server/app/goauto/sybimport/router.go b/server/app/goauto/sybimport/router.go index 8e0552d..c153033 100644 --- a/server/app/goauto/sybimport/router.go +++ b/server/app/goauto/sybimport/router.go @@ -27,6 +27,6 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { // 「从 SYB 发现店铺」挂在店铺路径下,但实现放在本包:它需要 SYB 客户端, // 而本包已经依赖 sybshop 做导入过滤,反过来会形成包循环。 engine.Group("/api/admin/v1/syb-shops").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()). - POST("/discover", handler.Discover) + POST("/discover", middleware.RequireRoleKey("admin"), handler.Discover) admin.PATCH("/:productId/correction", handler.ManualCorrect) } diff --git a/server/app/goauto/sybshop/router.go b/server/app/goauto/sybshop/router.go index 16ee21c..3b86590 100644 --- a/server/app/goauto/sybshop/router.go +++ b/server/app/goauto/sybshop/router.go @@ -23,8 +23,8 @@ func InitRouter(engine *gin.Engine, auth *jwt.GinJWTMiddleware) { group.GET("", handler.List) // 配置:仅管理员(授权在系统管理里配置) - group.POST("", handler.Create) - group.PATCH("/:shopId/name", handler.Rename) - group.PATCH("/:shopId/enabled", handler.SetEnabled) - group.DELETE("/:shopId", handler.Delete) + group.POST("", middleware.RequireRoleKey("admin"), handler.Create) + group.PATCH("/:shopId/name", middleware.RequireRoleKey("admin"), handler.Rename) + group.PATCH("/:shopId/enabled", middleware.RequireRoleKey("admin"), handler.SetEnabled) + group.DELETE("/:shopId", middleware.RequireRoleKey("admin"), handler.Delete) } diff --git a/server/cmd/migrate/migration/version-local/1786701700000_purchaser_role.go b/server/cmd/migrate/migration/version-local/1786701700000_purchaser_role.go new file mode 100644 index 0000000..8e0fa45 --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786701700000_purchaser_role.go @@ -0,0 +1,78 @@ +package version_local + +import ( + "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), migratePurchaserRole) +} + +func migratePurchaserRole(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := ensurePurchaserRoleAndPolicies(tx); err != nil { + return err + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} + +// purchaserCasbinRule deliberately targets the table used by gorm-adapter. +// The inherited go-admin migration model points at an unused +// `sys_casbin_rule` table and must not be used for runtime authorization. +type purchaserCasbinRule struct { + ID uint `gorm:"primaryKey;autoIncrement"` + Ptype string `gorm:"size:100"` + V0 string `gorm:"size:100"` + V1 string `gorm:"size:100"` + V2 string `gorm:"size:100"` + V3 string `gorm:"size:100"` + V4 string `gorm:"size:100"` + V5 string `gorm:"size:100"` +} + +func (purchaserCasbinRule) TableName() string { return "casbin_rule" } + +func ensurePurchaserRoleAndPolicies(db *gorm.DB) error { + role := migrationmodels.SysRole{} + if err := db.Where("role_key = ?", access.RolePurchaser). + Assign(migrationmodels.SysRole{ + RoleName: "采购员", Status: "2", RoleSort: 20, Admin: false, + DataScope: "1", Remark: "GoAuto 采购业务角色(系统维护)", + }).FirstOrCreate(&role, migrationmodels.SysRole{RoleKey: access.RolePurchaser}).Error; err != nil { + return err + } + + for _, permission := range access.AdminAPIs { + api := migrationmodels.SysApi{} + if err := db.Where("path = ? AND action = ?", permission.Path, permission.Method). + Attrs(migrationmodels.SysApi{Title: permission.Title, Type: "BUS"}). + FirstOrCreate(&api).Error; err != nil { + return err + } + } + + // The role is system-maintained: reconcile its policies to the reviewed + // matrix so stale grants cannot survive a permission reduction. + if err := db.Where("ptype = ? AND v0 = ?", "p", access.RolePurchaser). + Delete(&purchaserCasbinRule{}).Error; err != nil { + return err + } + for _, permission := range access.PurchaserAPIs() { + rule := purchaserCasbinRule{ + Ptype: "p", V0: access.RolePurchaser, V1: permission.Path, V2: permission.Method, + } + if err := db.Create(&rule).Error; err != nil { + return err + } + } + return nil +} diff --git a/server/cmd/migrate/migration/version-local/1786701700000_purchaser_role_test.go b/server/cmd/migrate/migration/version-local/1786701700000_purchaser_role_test.go new file mode 100644 index 0000000..bd3913d --- /dev/null +++ b/server/cmd/migrate/migration/version-local/1786701700000_purchaser_role_test.go @@ -0,0 +1,48 @@ +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 TestEnsurePurchaserRoleAndPolicies(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.SysApi{}, &purchaserCasbinRule{}); err != nil { + t.Fatal(err) + } + if err = ensurePurchaserRoleAndPolicies(db); err != nil { + t.Fatal(err) + } + if err = ensurePurchaserRoleAndPolicies(db); err != nil { + t.Fatalf("reconcile must be repeatable: %v", err) + } + + var role migrationmodels.SysRole + if err = db.Where("role_key = ?", access.RolePurchaser).First(&role).Error; err != nil { + t.Fatal(err) + } + if role.RoleName != "采购员" || role.Status != "2" || role.Admin { + t.Fatalf("unexpected purchaser role: %#v", role) + } + + var count int64 + db.Model(&purchaserCasbinRule{}).Where("ptype = ? AND v0 = ?", "p", access.RolePurchaser).Count(&count) + if count != int64(len(access.PurchaserAPIs())) { + t.Fatalf("got %d policies, want %d", count, len(access.PurchaserAPIs())) + } + var forbidden int64 + db.Model(&purchaserCasbinRule{}). + Where("v0 = ? AND v1 = ? AND v2 = ?", access.RolePurchaser, "/api/admin/v1/syb-products/import", "POST"). + Count(&forbidden) + if forbidden != 0 { + t.Fatal("manual SYB import must remain administrator-only") + } +} diff --git a/server/common/middleware/role_key.go b/server/common/middleware/role_key.go new file mode 100644 index 0000000..dfc7c76 --- /dev/null +++ b/server/common/middleware/role_key.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +// RequireRoleKey adds defense in depth for administrator-only operations. +// Casbin remains the primary API permission layer; this guard prevents a +// future policy/configuration mistake from widening a sensitive operation. +func RequireRoleKey(roleKey string) gin.HandlerFunc { + return func(c *gin.Context) { + role, _ := jwt.ExtractClaims(c)["rolekey"].(string) + if role != roleKey { + c.JSON(http.StatusForbidden, gin.H{ + "code": "FORBIDDEN", + "message": "只有管理员可以执行此操作", + }) + c.Abort() + return + } + c.Next() + } +} diff --git a/server/common/middleware/role_key_test.go b/server/common/middleware/role_key_test.go new file mode 100644 index 0000000..ba600ee --- /dev/null +++ b/server/common/middleware/role_key_test.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +func TestRequireRoleKey(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, test := range []struct { + role string + want int + }{{role: "admin", want: http.StatusNoContent}, {role: "purchaser", want: http.StatusForbidden}} { + engine := gin.New() + engine.Use(func(c *gin.Context) { + c.Set(jwt.JwtPayloadKey, jwt.MapClaims{"rolekey": test.role}) + c.Next() + }) + engine.POST("/write", RequireRoleKey("admin"), func(c *gin.Context) { c.Status(http.StatusNoContent) }) + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/write", nil) + engine.ServeHTTP(response, request) + if response.Code != test.want { + t.Fatalf("role %s got %d, want %d", test.role, response.Code, test.want) + } + } +} diff --git a/server/common/middleware/settings.go b/server/common/middleware/settings.go index daf8d30..a98e834 100644 --- a/server/common/middleware/settings.go +++ b/server/common/middleware/settings.go @@ -38,6 +38,5 @@ var CasbinExclude = []UrlInfo{ {Url: "/", Method: "GET"}, {Url: "/api/v1/server-monitor", Method: "GET"}, {Url: "/api/v1/public/uploadFile", Method: "POST"}, - {Url: "/api/v1/user/pwd/set", Method: "PUT"}, - {Url: "/api/v1/sys-user", Method: "PUT"}, + {Url: "/api/v1/user/pwd/set", Method: "PUT"}, } diff --git a/web/src/views/admin/sys-user/index.vue b/web/src/views/admin/sys-user/index.vue index 61c1812..c3c238e 100644 --- a/web/src/views/admin/sys-user/index.vue +++ b/web/src/views/admin/sys-user/index.vue @@ -210,8 +210,8 @@ - - + + @@ -257,7 +257,7 @@ - +

采集规则

通过安全表单维护 PDD 页面适配参数;规则保存后立即用于新任务。

- 创建 PDD 采集规则 + 创建 PDD 采集规则
@@ -15,7 +15,7 @@ - + @@ -143,6 +143,7 @@ export default { } }, computed: { + isAdmin() { return (this.$store.getters.roles || []).includes('admin') }, browserOptions() { const labels = { 'com.heytap.browser': 'OPPO / 一加浏览器', 'com.android.chrome': 'Google Chrome', 'com.android.browser': 'Android 系统浏览器' }; return this.constraints.browserPackages.map(value => ({ value, label: labels[value] || value })) }, directionOptions() { const labels = { up: '向上', down: '向下', left: '向左', right: '向右' }; return this.constraints.hookDirections.map(value => ({ value, label: labels[value] || value })) }, previewText() { return this.form.mode === 'v2' ? JSON.stringify(this.buildV2Content(), null, 2) : this.form.contentText } diff --git a/web/src/views/goauto/devices/index.vue b/web/src/views/goauto/devices/index.vue index 77ef7df..3444566 100644 --- a/web/src/views/goauto/devices/index.vue +++ b/web/src/views/goauto/devices/index.vue @@ -68,7 +68,7 @@ - +