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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user