fix: add purchaser role permissions (#85)
This commit is contained in:
@@ -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
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -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”和系统确认框“打开”属于允许动作。
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
@@ -210,8 +210,8 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="用户名称" prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入用户名称" />
|
||||
<el-form-item label="登录名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="请输入登录时使用的账号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
@@ -257,7 +257,7 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="角色">
|
||||
<el-form-item label="角色" prop="roleId">
|
||||
<el-select v-model="form.roleId" placeholder="请选择" @change="$forceUpdate()">
|
||||
<el-option
|
||||
v-for="item in roleOptions"
|
||||
@@ -397,10 +397,11 @@ export default {
|
||||
},
|
||||
// 表单校验
|
||||
rules: {
|
||||
username: [{ required: true, message: '用户名称不能为空', trigger: 'blur' }],
|
||||
username: [{ required: true, message: '登录名不能为空', trigger: 'blur' }],
|
||||
nickName: [{ required: true, message: '用户昵称不能为空', trigger: 'blur' }],
|
||||
deptId: [{ required: true, message: '归属部门不能为空', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '用户密码不能为空', trigger: 'blur' }],
|
||||
roleId: [{ required: true, message: '请选择角色', trigger: 'change' }],
|
||||
email: [
|
||||
{ required: true, message: '邮箱地址不能为空', trigger: 'blur' },
|
||||
{ type: 'email', message: "'请输入正确的邮箱地址", trigger: ['blur', 'change'] }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<el-card class="rule-card" shadow="never">
|
||||
<div class="page-heading">
|
||||
<div><h1>采集规则</h1><p>通过安全表单维护 PDD 页面适配参数;规则保存后立即用于新任务。</p></div>
|
||||
<el-button type="primary" :icon="Plus" @click="openCreate">创建 PDD 采集规则</el-button>
|
||||
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openCreate">创建 PDD 采集规则</el-button>
|
||||
</div>
|
||||
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="handleQuery">
|
||||
<el-form-item label="规则名称"><el-input v-model="query.name" placeholder="输入规则名称" clearable @keyup.enter="handleQuery" /></el-form-item>
|
||||
@@ -15,7 +15,7 @@
|
||||
<el-table-column label="类型" width="180"><template #default="{ row }"><el-tag :type="row.content.schemaVersion === 2 ? 'success' : 'info'">{{ ruleType(row.content) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="规则摘要" min-width="360"><template #default="{ row }"><span class="rule-summary">{{ summary(row.content) }}</span></template></el-table-column>
|
||||
<el-table-column label="更新时间" width="180"><template #default="{ row }">{{ parseTime(row.updatedAt) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openEdit(row)">编辑</el-button><el-button type="danger" link @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
<el-table-column v-if="isAdmin" label="操作" width="150" fixed="right"><template #default="{ row }"><el-button type="primary" link @click="openEdit(row)">编辑</el-button><el-button type="danger" link @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.page" v-model:page-size="query.pageSize" :total="total" @pagination="getList" />
|
||||
</el-card>
|
||||
@@ -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 }
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="190" fixed="right">
|
||||
<el-table-column v-if="isAdmin" label="操作" width="190" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="danger"
|
||||
@@ -116,6 +116,9 @@ export default {
|
||||
query: { page: 1, pageSize: 20, name: '', status: '' }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdmin() { return (this.$store.getters.roles || []).includes('admin') }
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user