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:
yml2213
2026-10-11 17:11:46 +08:00
parent 6a99c1121b
commit cb8aceb433
8 changed files with 202 additions and 8 deletions
+21 -2
View File
@@ -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 { func RequirePermissionIf(permCode string, rdb *redis.Client, predicate func(*gin.Context) bool) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if predicate == nil || !predicate(c) { 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) value, ok := c.Get(ContextAdminID)
if !ok { if !ok {
response.Unauthorized(c, "缺少管理员上下文") response.Unauthorized(c, "缺少管理员上下文")
@@ -56,9 +66,14 @@ func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
} }
for _, code := range codes { for _, code := range codes {
if code == permCode || code == "*" { if code == "*" {
return true return true
} }
for _, target := range permCodes {
if code == target {
return true
}
}
} }
response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限") response.Error(c, http.StatusForbidden, "forbidden", "没有操作权限")
@@ -66,6 +81,10 @@ func checkPermission(c *gin.Context, permCode string, rdb *redis.Client) bool {
return false 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) { func getPermCodes(c *gin.Context, rdb *redis.Client, adminID uint64) ([]string, error) {
if rdb == nil { if rdb == nil {
return nil, errors.New("redis unavailable") 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)
}
}
@@ -188,6 +188,33 @@ func (r *Repository) hasGlobalDistributionAccessWithDB(ctx context.Context, db *
return n > 0, nil return n > 0, nil
} }
func (r *Repository) hasPermission(ctx context.Context, adminID uint64, permCode string) (bool, error) {
if adminID == 0 {
return true, nil
}
if r == nil || r.db == nil {
return false, nil
}
var n int64
if err := r.db.WithContext(ctx).Table("roles").
Joins("JOIN admin_user_roles aur ON aur.role_id = roles.id").
Where("aur.admin_user_id = ? AND roles.code = ?", adminID, "super_admin").
Count(&n).Error; err != nil {
return false, err
}
if n > 0 {
return true, nil
}
if err := r.db.WithContext(ctx).Table("permissions").
Joins("JOIN role_permissions rp ON rp.permission_id = permissions.id").
Joins("JOIN admin_user_roles aur ON aur.role_id = rp.role_id").
Where("aur.admin_user_id = ? AND permissions.code = ?", adminID, permCode).
Count(&n).Error; err != nil {
return false, err
}
return n > 0, nil
}
func (r *Repository) listActiveMemberSiteIDs(ctx context.Context, adminID uint64) ([]uint64, error) { func (r *Repository) listActiveMemberSiteIDs(ctx context.Context, adminID uint64) ([]uint64, error) {
var members []model.DistributionSiteMember var members []model.DistributionSiteMember
if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil { if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil {
@@ -359,6 +359,41 @@ func TestScopeSiteIDRestrictsBoundMember(t *testing.T) {
if err != nil || len(superSites) != 2 { if err != nil || len(superSites) != 2 {
t.Fatalf("superadmin should list all sites, got %d err=%v", len(superSites), err) t.Fatalf("superadmin should list all sites, got %d err=%v", len(superSites), err)
} }
qrView := model.Permission{Code: "qrcode:view", Name: "查看二维码池", Resource: "qrcode", Action: "view"}
if err := db.Create(&qrView).Error; err != nil {
t.Fatal(err)
}
csRole := model.Role{Code: "customer_service_role", Name: "客服"}
if err := db.Create(&csRole).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.RolePermission{RoleID: csRole.ID, PermissionID: qrView.ID}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.AdminUserRole{AdminID: 66, RoleID: csRole.ID}).Error; err != nil {
t.Fatal(err)
}
csSites, err := svc.ListSitesForAdmin(t.Context(), 66)
if err != nil || len(csSites) != 2 {
t.Fatalf("cs admin with qrcode:view should list all sites for qr pool, got %d err=%v", len(csSites), err)
}
// 码池维护只需要读站点列表,必须止步于此:客服不得因此获得跨站写权限。
// distribution:all_view 是跨站写操作(改站/删站/重置站长密码)的总开关,
// 而它挂在被分站站长共用的 ops 角色上,一旦授予即击穿多租户隔离。
if global, err := repo.hasGlobalDistributionAccess(t.Context(), 66); err != nil || global {
t.Fatalf("qrcode admin must stay non-global, global=%v err=%v", global, err)
}
if _, err := repo.ScopeSiteID(t.Context(), 66, other.ID); err != ErrSiteAccessDenied {
t.Fatalf("qrcode admin must not scope into other site, err=%v", err)
}
if err := repo.ResetSiteAdminPassword(t.Context(), 66, other.ID, "whatever123"); err != ErrSiteAccessDenied {
t.Fatalf("qrcode admin must not reset other site admin password, err=%v", err)
}
if err := repo.DeleteSite(t.Context(), 66, other.ID); err != ErrSiteAccessDenied {
t.Fatalf("qrcode admin must not delete other site, err=%v", err)
}
} }
func TestSyncSiteAccountsAndPublicListings(t *testing.T) { func TestSyncSiteAccountsAndPublicListings(t *testing.T) {
@@ -1787,4 +1822,3 @@ func TestListOrdersPerformanceAndScoping(t *testing.T) {
t.Fatalf("expected 1 receive order for site2, got %d", recvPage.Total) t.Fatalf("expected 1 receive order for site2, got %d", recvPage.Total)
} }
} }
@@ -68,6 +68,18 @@ func (s *Service) ListSitesForAdmin(ctx context.Context, adminID uint64) ([]Site
if global { if global {
return items, nil return items, nil
} }
// 客服(qrcode:view)是平台级可信角色,统一维护各分站二维码池。
// 二维码池接口本身只校验 qrcode:* 权限、不按站点归属收窄,因此这里直接放行全量站点,
// 让码池页面能拿到站点 id;站长/分销商等站点成员仍在下方按其绑定站点收窄。
// 注意:不要为了这个需求给共享角色(如 ops,同时是分站站长)授予 distribution:all_view——
// 该权限是跨站写操作的总开关(改站/删站/重置站长密码),会击穿多租户隔离。
canViewQr, err := s.repo.hasPermission(ctx, adminID, "qrcode:view")
if err != nil {
return nil, err
}
if canViewQr {
return items, nil
}
siteIDs, err := s.repo.listActiveMemberSiteIDs(ctx, adminID) siteIDs, err := s.repo.listActiveMemberSiteIDs(ctx, adminID)
if err != nil { if err != nil {
return nil, err return nil, err
+4 -1
View File
@@ -449,6 +449,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
requirePerm := func(code string) gin.HandlerFunc { requirePerm := func(code string) gin.HandlerFunc {
return middleware.RequirePermission(code, deps.Redis) return middleware.RequirePermission(code, deps.Redis)
} }
requireAnyPerm := func(codes ...string) gin.HandlerFunc {
return middleware.RequireAnyPermission(deps.Redis, codes...)
}
api := engine.Group("/api") api := engine.Group("/api")
{ {
@@ -708,7 +711,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
adminRoutes.POST("/distribution/auth/logout", distributionHandler.Logout) adminRoutes.POST("/distribution/auth/logout", distributionHandler.Logout)
adminRoutes.POST("/files/upload", fileHandler.Upload) adminRoutes.POST("/files/upload", fileHandler.Upload)
adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary) adminRoutes.GET("/dashboard", requirePerm("dashboard:view"), adminDashboardHandler.Summary)
adminRoutes.GET("/distribution/sites", requirePerm("distribution:site_view"), distributionHandler.ListSites) adminRoutes.GET("/distribution/sites", requireAnyPerm("distribution:site_view", "qrcode:view"), distributionHandler.ListSites)
adminRoutes.GET("/distribution/site-defaults", requirePerm("distribution:site_view"), distributionHandler.GetSiteDefaults) adminRoutes.GET("/distribution/site-defaults", requirePerm("distribution:site_view"), distributionHandler.GetSiteDefaults)
adminRoutes.GET("/distribution/sites/:id", requirePerm("distribution:site_view"), distributionHandler.GetSite) adminRoutes.GET("/distribution/sites/:id", requirePerm("distribution:site_view"), distributionHandler.GetSite)
adminRoutes.POST("/distribution/sites", requirePerm("distribution:site_manage"), distributionHandler.CreateSite) adminRoutes.POST("/distribution/sites", requirePerm("distribution:site_manage"), distributionHandler.CreateSite)
@@ -113,6 +113,7 @@ const resourceLabels: Record<string, string> = {
system_config: '系统配置', system_config: '系统配置',
audit_log: '审计日志', audit_log: '审计日志',
ad_tracking: '推广追踪', ad_tracking: '推广追踪',
distribution: '分销管理',
} }
</script> </script>
@@ -77,10 +77,14 @@ const maxBatchUploadCount = 20
const selectedSite = computed(() => sites.value.find(site => site.id === siteId.value)) const selectedSite = computed(() => sites.value.find(site => site.id === siteId.value))
async function loadSites() { async function loadSites() {
sites.value = await fetchDistributionSites() try {
const firstSite = sites.value[0] sites.value = await fetchDistributionSites()
if (!siteId.value && firstSite) { const firstSite = sites.value[0]
siteId.value = firstSite.id if (!siteId.value && firstSite) {
siteId.value = firstSite.id
}
} catch (error) {
ElMessage.error(readError(error, '加载分销站点失败'))
} }
} }