fix(distribution): 支持客服在主站后台查看与管理分站二维码
- 路由权限支持:新增 RequireAnyPermission 中间件,允许持有 qrcode:view 或 distribution:site_view 的管理员调用 GET /admin/distribution/sites 获取分站列表 - 服务层站点穿透:ListSitesForAdmin 允许持有 qrcode:view 权限的管理员(客服)获取全部可用分销站点用于码池维护;站长/分销商等站点成员仍按其绑定站点收窄 - 前端交互与权限分组:分站二维码池 loadSites 补充异常捕获,分配权限弹窗补充「分销管理」分组展示名 - 权限来源:cs 角色的 qrcode:view/manage 由既有迁移 000016 提供,故本次不新增数据库迁移 - 权限边界:不采用 distribution:all_view 实现该需求——它是跨站写操作的总开关(改站/删站/重置站长密码),而该权限依赖的 ops 角色同时是分站站长,授予后会击穿多租户隔离;并补充回归断言锁定该边界 - 验证方式:全量门禁 ./scripts/check.sh 通过(SQL 约束检查、Go 单测与覆盖率、路由测试、前端类型检查、单测与生产构建)
This commit is contained in:
@@ -22,6 +22,16 @@ func RequirePermission(permCode string, rdb *redis.Client) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// RequireAnyPermission 检查当前管理员是否拥有任意一个指定的权限。
|
||||
// 超级管理员(拥有 super_admin 角色或 * 权限)自动放行。
|
||||
func RequireAnyPermission(rdb *redis.Client, permCodes ...string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if checkAnyPermission(c, rdb, permCodes...) {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RequirePermissionIf(permCode string, rdb *redis.Client, predicate func(*gin.Context) bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if predicate == nil || !predicate(c) {
|
||||
@@ -34,7 +44,7 @@ func RequirePermissionIf(permCode string, rdb *redis.Client, predicate func(*gin
|
||||
}
|
||||
}
|
||||
|
||||
func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
|
||||
func checkAnyPermission(c *gin.Context, rdb *redis.Client, permCodes ...string) bool {
|
||||
value, ok := c.Get(ContextAdminID)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "缺少管理员上下文")
|
||||
@@ -56,9 +66,14 @@ func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
|
||||
}
|
||||
|
||||
for _, code := range codes {
|
||||
if code == permCode || code == "*" {
|
||||
if code == "*" {
|
||||
return true
|
||||
}
|
||||
for _, target := range permCodes {
|
||||
if code == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限")
|
||||
@@ -66,6 +81,10 @@ func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
|
||||
return checkAnyPermission(c, rdb, permCode)
|
||||
}
|
||||
|
||||
func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) {
|
||||
if rdb == nil {
|
||||
return nil, errors.New("redis unavailable")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func TestRequireAnyPermission(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: "127.0.0.1:16379",
|
||||
DB: 14,
|
||||
})
|
||||
ctx := t.Context()
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
t.Skipf("跳过测试:未检测到测试 Redis: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = rdb.FlushDB(context.Background()).Err()
|
||||
_ = rdb.Close()
|
||||
})
|
||||
_ = rdb.FlushDB(ctx)
|
||||
|
||||
adminID := uint64(101)
|
||||
permKey := fmt.Sprintf("admin:perms:%d", adminID)
|
||||
|
||||
setPerms := func(perms []string) {
|
||||
raw, err := json.Marshal(perms)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rdb.Set(ctx, permKey, string(raw), time.Minute).Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
setupRouter := func() *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set(ContextAdminID, adminID)
|
||||
c.Next()
|
||||
})
|
||||
r.GET("/test-any", RequireAnyPermission(rdb, "distribution:site_view", "qrcode:view"), func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
router := setupRouter()
|
||||
|
||||
// 1. 无任何权限 -> 403
|
||||
setPerms([]string{"dashboard:view"})
|
||||
req := httptest.NewRequest(http.MethodGet, "/test-any", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", w.Code)
|
||||
}
|
||||
|
||||
// 2. 具备 qrcode:view -> 200
|
||||
setPerms([]string{"qrcode:view"})
|
||||
req = httptest.NewRequest(http.MethodGet, "/test-any", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// 3. 具备 distribution:site_view -> 200
|
||||
setPerms([]string{"distribution:site_view"})
|
||||
req = httptest.NewRequest(http.MethodGet, "/test-any", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// 4. 超管通配符 * -> 200
|
||||
setPerms([]string{"*"})
|
||||
req = httptest.NewRequest(http.MethodGet, "/test-any", nil)
|
||||
w = httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user