社群团购类快团团系统开发——RBAC 权限设计源码分析:当一个微信号有 4 种身份时怎么办

前言. 先给结论

上一篇的登录链路解决"你是谁",这一篇解决"你能干什么"。普通商城的权限模型是"一个用户一个身份",这套系统不成立——帮卖团长自己也会下单买东西,供货团长也可能在帮别人卖货。同一个微信号,同时可能是消费者、帮卖团长、供货团长、运营中的几种组合。

先给三个结论,本篇围绕它们展开:

  1. 角色存关系表,不存用户表字段——一人多角色、角色可审核可吊销,都靠关系表承载;
  2. 接口级权限用"自定义注解 + AOP"实现,权限判断收敛到一处,禁止散落在业务代码里;
  3. 数据权限(帮卖只能看自己的单)在 Service 层强制注入,这是这套系统里最容易被漏掉、被攻击价值又最高的一层。

RBAC(Role-Based Access Control,基于角色的访问控制)的教科书定义只有一句话:用户不直接挂权限,用户关联角色、角色关联权限。但"教科书 RBAC"到"能扛住越权攻击的生产代码"之间隔着三层落地,本篇就是这三层。

技术架构


1. 四种身份的业务现实:模型必须先行

先看这个系统的四类角色,各自的来源和权限边界:

角色 身份来源 关键权限 与其他角色互斥?
CONSUMER 消费者 注册即有 浏览、下单、售后 不互斥(人人都是)
LEADER 帮卖团长 申请 + 平台审核 一键帮卖、加价、看佣金、提现 不互斥(同时是消费者)
SUPPLIER 供货团长 入驻签约 开团、定三级价格、订单导出、发货 不互斥(可兼帮卖)
ADMIN 运营 后台分配 审核、风控、对账、大盘 不互斥(可兼团长)

四行全部"不互斥"——这就是"一个微信号 4 种身份"的含义,也直接宣判了两种常见错误方案的死刑:用户表加 role 字段(只能存一个角色)和建四张用户表(同一人多号,数据永远对不齐)。唯一正确的建模是把角色做成用户和角色之间的多对多关系。

权限的判断依据也有讲究:这套系统的角色是有限枚举(就这四种,短时间不会膨胀),所以角色可以用字符串枚举;但接口数量会持续增长,"哪个角色能访问哪个接口"的映射必须可配置,硬编码在业务代码里的 if (user.role == LEADER) 会随着接口增多失控——第 3 节的注解方案就是解药。


2. RBAC 三层模型落地:五张表

标准 RBAC 用五张表承载,这套系统做了适配性地裁剪:

-- 用户表(第 7 篇已建,此处略)
-- 角色表:角色是有限枚举,表可以退化为字典表,但保留表结构给未来的自定义角色留路
CREATE TABLE `t_role` (
  `id`        BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `role_code` VARCHAR(20) NOT NULL COMMENT 'CONSUMER/LEADER/SUPPLIER/ADMIN',
  `role_name` VARCHAR(32) NOT NULL,
  `status`    TINYINT     NOT NULL DEFAULT 20 COMMENT '10停用 20启用',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_role_code` (`role_code`)
) ENGINE=InnoDB COMMENT='角色表';

-- 用户-角色关系:一人多角色的物理载体(第 7 篇已建 t_user_role)
-- 关键:status 支持"申请-审核-冻结"生命周期,帮卖资格审核就是插一行 status=10

-- 权限表:权限码是系统里所有可授权动作的清单
CREATE TABLE `t_permission` (
  `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `perm_code`   VARCHAR(64) NOT NULL COMMENT '权限码,如 groupbuy:create',
  `perm_name`   VARCHAR(64) NOT NULL COMMENT '如 创建团购',
  `perm_type`   TINYINT     NOT NULL DEFAULT 10 COMMENT '10接口 20按钮 30菜单',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_perm_code` (`perm_code`)
) ENGINE=InnoDB COMMENT='权限表';

-- 角色-权限关系
CREATE TABLE `t_role_permission` (
  `id`         BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `role_code`  VARCHAR(20) NOT NULL,
  `perm_code`  VARCHAR(64) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_role_perm` (`role_code`, `perm_code`)
) ENGINE=InnoDB COMMENT='角色权限关系表';

五张表的关系一图看清:

t_user ──< t_user_role >── t_role ──< t_role_permission >── t_permission
 (谁)        (是什么身份)      (身份)         (该身份能做什么)        (动作清单)

两条裁剪说明。第一,中小系统可以把"角色→权限"的映射放配置文件或常量类,省掉两张表——代价是每次改权限要发版,这套系统选了数据库配置(运营后台可以自助调整权限映射),预算允许时建议保留表结构。第二,t_user_role.status 是这张关系表的灵魂:"申请成为帮卖团长"= 插一行 status=10(待审核),"审核通过"= 改 20,"违规冻结"= 改 30——身份变更全程留痕,风控回查有据。角色的生命周期管理和 RBAC 的权限判断在此交汇。


3. 接口级权限:注解 + AOP 完整实现

接口级权限回答一个问题:这个请求允许进来吗?实现载体是自定义注解 + AOP 切面,权限校验收敛到一处:

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
    Role[] value();          // 允许访问的角色集合,任一命中即放行
}

// 使用:一行注解表达权限边界,Controller 里再无 if 判断
@RequireRole({Role.LEADER})
@PostMapping("/api/leader/groupbuy/markup")
public Result<Void> adjustMarkup(@RequestBody @Valid MarkupDTO dto) { ... }

切面实现:

@Aspect
@Component
@RequiredArgsConstructor
public class RoleCheckAspect {

    @Before("@annotation(requireRole)")
    public void check(JoinPoint jp, RequireRole requireRole) {
        // 当前用户从登录态取(第 7 篇过滤器放进 SecurityContext),绝不从参数取
        Long userId = SecurityUtil.currentUserId();
        if (userId == null) {
            throw new BizException(ErrorCode.UNAUTHORIZED);       // 未登录
        }
        Set<String> roles = SecurityUtil.currentRoles();          // JWT payload 里的角色集合
        boolean pass = Arrays.stream(requireRole.value())
                .map(Role::name)
                .anyMatch(roles::contains);
        if (!pass) {
            throw new BizException(ErrorCode.FORBIDDEN,           // 无权限:403 语义
                    "当前身份无权执行此操作");
        }
    }
}

几个设计决策值得说明。为什么不用 Spring Security 自带的 @PreAuthorize("hasRole('LEADER')") 功能上完全等价,选自定义注解的理由是业务语义:@RequireRole(Role.LEADER) 在代码里读起来就是业务规则,且后续可以在注解上扩展业务属性(如 auditLog = true 标记敏感操作强制审计)。注解和路由前缀是双保险/api/leader/** 前缀在 Security 配置层拦一道(拦住漏写注解的接口),方法上的 @RequireRole 再精判一道(前缀拦不住的角色交叉场景)。AOP 只做判断不做数据过滤——"帮卖能调加价接口"是接口级权限,"帮卖只能调自己商品的加价"是数据权限,下一节。

最后一条纪律:Controller 和 Service 的业务代码里出现 if (role == XX) 即为坏味道。权限的"能不能"归注解管,业务代码只管"做了会怎样";权限判断散落各处 = 每次加角色要全代码库搜索排查 = 迟早漏一处。

注解之前还有第一道闸——路由前缀级的 Security 配置,它是"漏写注解的接口"的兜底:

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)          // 小程序 Header 传 token,无 Cookie 场景
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/c/auth/**").permitAll()            // 登录接口放行
                        .requestMatchers("/api/c/**").authenticated()             // 消费者:登录即可
                        .requestMatchers("/api/leader/**").hasAnyRole("LEADER", "SUPPLIER")
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

两层配合的分工说清楚:Security 配置按 URL 前缀做粗判(这一段 URL 是谁的活动区),方法上的 @RequireRole精判(这个方法允许哪些角色交叉访问),任何一层单独存在都有盲区——只靠前缀,供货和帮卖混合的接口没法区分;只靠注解,新加一个 Controller 忘写注解就裸奔。

权限码的加载同样要有一条明确链路。登录响应和前端初始化时需要的 perms 集合,来自 t_user_role → t_role_permission 两跳查询,结果按"用户维度"缓存 5 分钟:

public Set<String> permsOf(Long userId) {
    return cache.get("gbs:auth:perms:" + userId, 5, TimeUnit.MINUTES,
            () -> roleMapper.selectPermsByUserId(userId));   // SELECT p.perm_code
                                                             // FROM t_user_role ur
                                                             // JOIN t_role_permission rp ON ur.role = rp.role_code
                                                             // JOIN t_permission p ON rp.perm_code = p.perm_code
                                                             // WHERE ur.user_id = #{userId} AND ur.status = 20
}

注意 JOIN 条件里带 ur.status = 20——待审核和冻结的角色不产出任何权限码,缓存自然过期或由第 6 节的角色版本号机制主动失效,冻结操作因此在权限码层面同样生效。


4. 按钮级权限:权限码下发与前端配合

PC 后台有大量按钮级差异:同样是订单页,供货团长看到"导出/发货",运营看到"作废/改址",帮卖看不到导出按钮。按钮级权限的机制是权限码:后端在登录后(或前端加载时)返回当前用户的权限码集合,前端按码控制渲染:

// 登录响应携带
{ "roles": ["LEADER"], "perms": ["order:export", "groupbuy:markup", "commission:query"] }
// 前端指令思路:无权限码的按钮直接不渲染
<button v-if="$perms.has('order:export')">导出订单</button>

但要划清一条安全边界,这是本节最重要的一句话:前端隐藏按钮是用户体验,不是安全。小程序的代码包可以被解包、接口可以被直接调用,前端权限唯一的正确作用是"别让没权限的人看到按钮",真正的防线永远是服务端——每个带 perm_code 的按钮,对应的后端接口必须有等价的 @RequireRole 或权限码校验。评审时看到"这个接口只是给后台按钮用的,前端已经控制了"的注释,直接打回。


5. 数据权限:帮卖只能看自己的单

接口级权限拦住了"帮卖团长不能进运营后台",但漏掉了危害更大的一类:同端内的水平越权。帮卖团长 A 有权访问"订单详情"接口,但"A 看订单 B 的详情"必须被拦下。攻击方式简单到令人发指——把 URL 里的订单号改成别人的:

GET /api/leader/order/detail?orderId=8829340   ← 自己的单,正常
GET /api/leader/order/detail?orderId=8829341   ← 手 +1,别人的单,含收货人手机号

如果接口实现是"参数给什么查什么",水平越权就是零成本。防线分两层。

第一层(根治):SQL 强制带属主条件,属主从登录态取。 查询类操作在 SQL 里永久带上当前用户的过滤条件,且条件值来自 SecurityContext 而非请求参数:

public Page<OrderVO> pageMyOrders(OrderPageQuery query) {
    Long currentLeader = SecurityUtil.currentUserId();   // 从登录态取,不从参数取
    // SQL 强制:WHERE help_sell_id = #{currentLeader},前端传什么都不生效
    return orderMapper.pageByLeader(query, currentLeader);
}
<!-- 即使 query 里带了其他 leaderId 也无法注入:SQL 里只认登录态值 -->
<select id="pageByLeader" resultType="OrderVO">
  SELECT * FROM t_order
  WHERE help_sell_id = #{currentLeader}
    <if test="query.status != null">AND pay_status = #{query.status}</if>
  ORDER BY create_time DESC
</select>

第二层(补漏):对象级归属校验。 详情、修改、核销这类"单对象"操作,取出对象后校验归属,不匹配按"不存在"处理(回 404 而不是 403——不向攻击者确认对象存在):

public OrderDetailVO detail(Long orderId) {
    Long currentLeader = SecurityUtil.currentUserId();
    Order order = orderMapper.selectById(orderId);
    if (order == null || !Objects.equals(order.getHelpSellId(), currentLeader)) {
        throw new BizException(ErrorCode.NOT_FOUND);   // 归属不符统一按不存在处理
    }
    return OrderDetailVO.of(order);
}

两层配合的分工:列表用第一层(SQL 条件根治),单对象用第二层(归属校验补漏)。第 24 篇安全设计会把越权测试用例整理成清单进 CI,这里的两层防线就是那些用例要验证的对象。


6. 冻结窗口期:token 里的角色不可全信

一个真实场景收个尾:运营把违规帮卖团长的角色冻结(t_user_role.status 改 30),但他的 JWT 还有 1 小时过期——这一小时里他的 token 依然带着 LEADER 角色,还能调帮卖接口、还能看佣金。这就是"冻结窗口期"问题:JWT 的无状态优点(不用查库)在这里变成了安全缺陷。

三种方案对比:

方案 实时性 每请求成本 适用
A. 信任 JWT 里的角色 差(窗口 = token 剩余寿命) 纯展示类接口
B. 每请求实时查库 一次 DB 查询 全量接口(浪费)
C. 角色版本号 + Redis 缓存 强(秒级) 一次 Redis GET 生产推荐

方案 C 的机制:Redis 存每个用户的角色版本号 gbs:auth:rolever:{userId},登录时写入初始值并放进 JWT payload;冻结/变更角色时版本号自增;AOP 校验时比对"token 里的版本号"和"Redis 当前版本号",不一致则拒绝并要求重新登录。角色数据本身缓存 60 秒 TTL,版本不一致时穿透查库——秒级实时性,成本是一次 Redis GET,第 7 篇过滤器里的黑名单机制和这里是同一套基础设施。C 端消费者接口可以继续用方案 A(被冻结的消费者损失有限),团长端和运营端必须方案 C——能动钱、能动数据的身份,实时性要求对齐资金模块。

还有一个和实时性同源的细节:运营后台要在 JWT 之外再加一层防护。运营端能动钱、能改配置,是全系统攻击价值最高的入口,除了方案 C 的实时校验,生产上还应叠加 IP 白名单(公司出口 IP 段)和二次登录态(进入后台时单独输一次后台密码,生成独立的短时 admin token)——两道闸的存在让"运维 token 泄露"这类事故的杀伤半径缩小一个量级。这套系统里权限的强度梯度因此是清晰的:消费者靠 token、团长靠版本号、运营靠版本号 + 白名单 + 二次登录态,防护强度跟着"能造成的资金损失"走


7. 越权测试用例:权限设计要能被"考试"

权限代码写完不算完,要设计用例考它。核心越权用例清单(可直接进集成测试):

编号 用例 预期
H1 帮卖 A 用自己的 token 查订单 B 的详情 404,不泄露存在性
H2 帮卖 A 修改请求参数 leaderId 为他人 ID 查列表 返回的永远是 A 自己的数据
H3 帮卖 A 用订单号 +1 遍历核销码 全部 404,且有风控告警
V1 消费者 token 调 /api/admin/** 401/403
V2 被冻结的帮卖团长,旧 token 调帮卖接口 401,要求重新登录(方案 C 生效)
V3 待审核的帮卖申请者(status=10)调帮卖接口 403,角色未生效不下发
P1 无 order:export 权限码的角色直接调导出接口 403(前端隐藏不等于后端放行)

其中 H3(核销码遍历)是最容易被忽视也最致命的——核销码等于提货凭证,遍历成功就是直接资损,第 17 篇核销码设计会专门回应这个用例。


8. 本篇小结

  1. 四种身份全不互斥,角色必须存关系表(t_user_role),status 字段承载"申请-审核-冻结"生命周期——帮卖资格审核与风控吊销都是改状态;
  2. RBAC 五张表:用户-角色-权限三层,角色枚举有限但权限映射可配置;角色-权限映射进数据库,运营可自助调整不发版;
  3. 接口级权限收敛到 @RequireRole 注解 + AOP,业务代码出现 if (role == XX) 即坏味道;路由前缀 + 注解双保险;
  4. 按钮级权限 = 权限码下发 + 前端渲染控制,但前端隐藏只是体验,后端校验永远是真正的防线;
  5. 数据权限两层防线:列表 SQL 强制带登录态属主条件,单对象做归属校验且回 404 不回 403;userId 永远从 SecurityContext 取,不从参数取;
  6. 冻结窗口期用角色版本号解决:C 端可信任 token,团长/运营端必须秒级实时;
  7. 权限设计要能被考试:水平/垂直/权限码三组越权用例进集成测试,核销码遍历是最致命的一条。

地基工程到此完整:技术栈、六张核心表、登录、权限。下一篇开始进入工程化主题:《工程化(上):一套让 5 人小团队不出事故的开发规范》——规范不是文档而是可执行的约束:阿里 Java 规范裁剪版、Git 分支模型、Code Review 清单,以及用 Checkstyle 和 CI 把规范固化成卡点的完整配置。

posted on 2026-09-07 15:05  程序员李铁牛  阅读(9)  评论(0)    收藏  举报