From cb8aceb43375c4a5bf187e17dfedfc5d3f446ea1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 11 Oct 2026 16:52:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(distribution):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=AE=A2=E6=9C=8D=E5=9C=A8=E4=B8=BB=E7=AB=99=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=9F=A5=E7=9C=8B=E4=B8=8E=E7=AE=A1=E7=90=86=E5=88=86=E7=AB=99?= =?UTF-8?q?=E4=BA=8C=E7=BB=B4=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 路由权限支持:新增 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 单测与覆盖率、路由测试、前端类型检查、单测与生产构建) --- backend/internal/middleware/permission.go | 23 ++++- .../internal/middleware/permission_test.go | 94 +++++++++++++++++++ .../modules/distribution/repository.go | 27 ++++++ .../modules/distribution/repository_test.go | 36 ++++++- .../internal/modules/distribution/service.go | 12 +++ backend/internal/router/router.go | 5 +- .../components/AssignPermissionsDialog.vue | 1 + .../views/AdminDistributionQrCodePoolView.vue | 12 ++- 8 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 backend/internal/middleware/permission_test.go diff --git a/backend/internal/middleware/permission.go b/backend/internal/middleware/permission.go index 2075821..3915cc9 100644 --- a/backend/internal/middleware/permission.go +++ b/backend/internal/middleware/permission.go @@ -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") diff --git a/backend/internal/middleware/permission_test.go b/backend/internal/middleware/permission_test.go new file mode 100644 index 0000000..51e68f5 --- /dev/null +++ b/backend/internal/middleware/permission_test.go @@ -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) + } +} diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 73eabbb..d08b78a 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -188,6 +188,33 @@ func (r *Repository) hasGlobalDistributionAccessWithDB(ctx context.Context, db * 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) { var members []model.DistributionSiteMember if err := r.db.WithContext(ctx).Where("admin_user_id = ? AND status = 'active'", adminID).Find(&members).Error; err != nil { diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index 4b983c4..a9d7630 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -359,6 +359,41 @@ func TestScopeSiteIDRestrictsBoundMember(t *testing.T) { if err != nil || len(superSites) != 2 { 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) { @@ -1787,4 +1822,3 @@ func TestListOrdersPerformanceAndScoping(t *testing.T) { t.Fatalf("expected 1 receive order for site2, got %d", recvPage.Total) } } - diff --git a/backend/internal/modules/distribution/service.go b/backend/internal/modules/distribution/service.go index 9726fde..59c23c0 100644 --- a/backend/internal/modules/distribution/service.go +++ b/backend/internal/modules/distribution/service.go @@ -68,6 +68,18 @@ func (s *Service) ListSitesForAdmin(ctx context.Context, adminID uint64) ([]Site if global { 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) if err != nil { return nil, err diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 610ddda..a8cc58b 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -449,6 +449,9 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { requirePerm := func(code string) gin.HandlerFunc { return middleware.RequirePermission(code, deps.Redis) } + requireAnyPerm := func(codes ...string) gin.HandlerFunc { + return middleware.RequireAnyPermission(deps.Redis, codes...) + } 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("/files/upload", fileHandler.Upload) 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/sites/:id", requirePerm("distribution:site_view"), distributionHandler.GetSite) adminRoutes.POST("/distribution/sites", requirePerm("distribution:site_manage"), distributionHandler.CreateSite) diff --git a/frontend/src/features/admin/components/AssignPermissionsDialog.vue b/frontend/src/features/admin/components/AssignPermissionsDialog.vue index a0d0965..0df015b 100644 --- a/frontend/src/features/admin/components/AssignPermissionsDialog.vue +++ b/frontend/src/features/admin/components/AssignPermissionsDialog.vue @@ -113,6 +113,7 @@ const resourceLabels: Record = { system_config: '系统配置', audit_log: '审计日志', ad_tracking: '推广追踪', + distribution: '分销管理', } diff --git a/frontend/src/features/admin/views/AdminDistributionQrCodePoolView.vue b/frontend/src/features/admin/views/AdminDistributionQrCodePoolView.vue index a145a7e..82a7b0a 100644 --- a/frontend/src/features/admin/views/AdminDistributionQrCodePoolView.vue +++ b/frontend/src/features/admin/views/AdminDistributionQrCodePoolView.vue @@ -77,10 +77,14 @@ const maxBatchUploadCount = 20 const selectedSite = computed(() => sites.value.find(site => site.id === siteId.value)) async function loadSites() { - sites.value = await fetchDistributionSites() - const firstSite = sites.value[0] - if (!siteId.value && firstSite) { - siteId.value = firstSite.id + try { + sites.value = await fetchDistributionSites() + const firstSite = sites.value[0] + if (!siteId.value && firstSite) { + siteId.value = firstSite.id + } + } catch (error) { + ElMessage.error(readError(error, '加载分销站点失败')) } }