资讯动态

基于Spring Security的霸王餐系统接口权限设计实践

发布时间:2026/10/14 7:16:27 来源:尧图企业网站定制
如果你是第一次接触霸王餐这类业务可能会把它当成一个简单的报名加核销系统。但实际上一个霸王餐活动背后站着一长串接口用户抢券、商家验券、运营配置活动、财务复核结算哪个环节少了一道权限校验都可能被人从“吃霸王餐”变成“薅系统羊毛”。我之前在负责某个餐饮平台的霸王餐活动系统时做过一次接口权限改造核心任务就是基于 Spring Security 把接口权限做到足够细。这篇主要聊聊我的权限模型怎么设计、三层授权怎么落地以及那些让人头大的细节。1. “霸王餐”业务下的权限矩阵从角色粗粒度到操作细粒度1.1 霸王餐接口为什么需要“细到操作和数据范围”的权限霸王餐本质上不是“白吃”而是餐饮平台为了拉新搞的免费试吃活动平台选一批门店做活动用户在报名期内提交申请系统抽取或先到先得地发放免费餐名额用户拿着券码去门店核销门店垫资后由平台结算。这个过程涉及四类角色用户、商家门店店员、平台运营、财务审核。每类角色能碰的数据完全不一样。用最典型的核销接口举例门店店员有“核销霸王餐券”的权限但只能核销自己门店出的券不能把隔壁门店的券也给核了。运营可以查看活动的报名数据和核销进度但某个区域运营只应该看到自己负责城市的数据而不是全国数据。财务能看到结算单和发票但肯定不能让财务去改活动名额。这些需求如果用“管理员/普通用户”二元角色去做要么权限给得太大要么业务没法走通。所以在设计权限时我一开始就把权限粒度拆成了三层接口自身能不能访问URL层、这个接口的方法能不能执行方法层、以及访问的数据对象到底在不在你的授权范围内数据层。三层缺一不可。如果只做了前两层仍然会出现横向越权一个用户传了别人的券号只要接口本身是开放的数据一样会泄露。1.2 拆一条真实越权链路来验证权限矩阵我在内网模拟过一条攻击路径。霸王餐活动里有一个“查询报名状态”的接口最初版本是这样写的用户登录后把手机号传给后端后端拿着手机号查报名记录返回给前端。看起来没有任何问题但测试时发现只要把请求里的手机号换成另一个人的手机号响应里就能看到对方的报名时间、券码状态、核销门店。原因很简单这个接口只做了登录认证没有验证“当前登录用户是否等于被查询用户”。这就是典型的数据级越权也是精细权限控制要解决的核心矛盾你不仅能访问这个接口你只能访问属于你自己的、或者属于你管理范围内的数据。类似的越权在商家端也存在。比如门店核销接口请求参数是“券码”如果后端只判断“登录者是门店店员”而无条件核销店员把别人门店的券码拿来也能核销掉那整个结算体系就乱了。所以权限矩阵里必须写明某个角色对某个资源、某个操作、哪个范围的数据拥有权限。1.3 霸王餐接口权限矩阵参考我整理了一份简化版权限矩阵开发时可以直接拿去做数据库种子数据接口角色权限编码数据范围提交报名用户activity:signup本人取消报名用户activity:cancel:self本人查询我的券码用户coupon:view:self本人门店核销商家店员coupon:verify:shop本门店查看门店核销记录商家店长coupon:view:shop本门店创建活动平台运营activity:create负责区域调整活动名额平台运营activity:update负责区域审核结算单财务settlement:audit全部这个矩阵里“操作”不是 hasRole而是独立的权限编码例如coupon:verify:shop表示“校验券码且范围在本门店”。“数据范围”可以在运行时通过当前登录用户的组织属性比如门店ID、区域ID计算出来。后面所有具体的授权实现都是围绕这张表展开的。2. 为什么 hasRole 硬编码撑不住这种场景2.1 hasRole 只能判断角色身份判断不了资源归属Spring Security 自带的PreAuthorize(hasRole(ADMIN))接口简单直接但它有一个根本局限角色是一个集合概念它把“你能做什么”和“你是什么人”绑死在了一起。在霸王餐场景里同样是平台运营有人负责华东区有人负责华南区同样是门店店员A门店的店员凭什么能核销B门店的券hasRole回答不了这类问题。它只能告诉你“这是个运营”不能告诉你“这个运营对这条活动记录有没有管理权”。还有一个工程层面的问题一旦判断逻辑写成hasRole(ADMIN)或者hasAnyRole(ADMIN, OPERATOR)这些角色判断就会散布在 Controller 和 Service 的各个角落。某天新需求要求“客服也能查看报名数据但不能改”你得去把所有接口的注解翻出来改一遍有些接口甚至要推翻重写。这种硬编码的权限判断本质上违背了“权限变更应该尽量低成本”的原则。2.2 角色是权限的合集我们应该校验权限而不是角色在 RBAC 的经典模型里用户和权限之间隔着一个角色用户拥有角色角色拥有权限。但 Spring Security 的设计其实很灵活Authentication 里的 authorities 可以是权限编码而不一定是角色。hasAuthority(activity:update)和hasRole(ADMIN)的区别在于角色是粗粒度的标签权限是细粒度的能力。我们完全可以在数据库里把权限编码做成一行一行的数据让运营管理后台去绑定“哪个角色拥有哪些权限编码”。这样做的最大好处是权限变更从“改代码重新发布”变成了“改数据库配置即时生效”。比如原本只有“活动管理员”能取消用户的霸王餐名额后来产品说“客服也要能取消”你只需要在后台给客服角色增加一条activity:cancel权限不用再走一次代码发布流程。当然前提是我们设计好了权限表结构并且权限缓存支持动态刷新这一点我会在第五节展开讲。2.3 真正的权重在于把“数据范围”纳入权限模型角色与权限编码解决了“能不能操作”的问题但霸王餐这类业务还有一个隐藏维度操作同一个动作时不同人能看到的数据范围不同。这需要给权限模型增加“范围域”的概念。我这边没有直接上 Spring Security ACL 那套重型方案而是采用了“权限编码 数据范围属性”的组合后端把当前用户的组织属性门店ID、城市ID、区域ID放进一个全局可访问的上下文里在数据权限校验时用这些属性去过滤数据。举个例子定义一个权限编码coupon:verify表示具备核销券码的权限然后根据当前用户关联的 storeId 判断待核销的券码是否属于该门店。这种设计的核心是把“权限校验”从 Controller 下沉到 Service 层甚至 Dao 层Service 在执行业务前先从安全上下文里拿到当前用户的 storeId再把它作为查询条件传到 SQL 里。这样既能防横向越权又能让代码职责非常清晰而不是每个人都在接口里自己写 if 判断。3. 基于 Spring Security 的三层授权体系设计3.1 第一层URL 级权限控制把好入口的第一道门页面/接口层面Spring Security 通过SecurityFilterChain和requestMatchers配置来实现。霸王餐项目的后端是单体服务我把需要权限控制的接口按模块分成了 activity、coupon、settlement 几组然后在SecurityConfig里配置。Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeHttpRequests(auth - auth .requestMatchers(/api/auth/login, /api/auth/refresh).permitAll() .requestMatchers(/api/activity/**).hasAuthority(activity:view) .requestMatchers(/api/activity).hasAuthority(activity:create) .requestMatchers(/api/coupon/verify).hasAuthority(coupon:verify) .requestMatchers(/api/settlement/**).hasAuthority(settlement:audit) .anyRequest().authenticated() ) .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService), UsernamePasswordAuthenticationFilter.class) .exceptionHandling().authenticationEntryPoint(restAuthenticationEntryPoint()) .and().exceptionHandling().accessDeniedHandler(restAccessDeniedHandler()); return http.build(); } }这套配置的作用是像/api/activity/**这类路径只要当前登录用户的 authorities 里包含activity:view就放行否则直接抛 403。选 URL 层做第一道门的好处是拦截性能高、规则清晰而且可以在请求进到业务代码之前就拒绝掉明显越权的调用。注意这里我用的是 hasAuthority 而不是 hasRole就是因为权限编码已经细化为动作级别了。3.2 第二层方法级注解把权限校验贴近业务语义URL 层适合做粗粒度的模块拦截但很多细粒度控制出现在 Service 层。例如某个活动只能由创建它的运营修改或者用户只能撤销自己的报名。这种场景我用 Spring Security 的方法级安全注解显式地标注在每个方法上PreAuthorize(hasAuthority(activity:cancel:self) and #userId authentication.principal.id) public void cancelSignup(Long signupId, Long userId) { signupRepository.cancelById(signupId, userId); }这里有两个条件一是 hasAuthority 检查当前用户具备“取消报名”的权限编码二是 SpEL 表达式#userId authentication.principal.id校验传入的用户ID等于当前登录用户。如果只验第一个用户仍然可以传任意 userId 去取消别人的报名第二个条件就把数据归属校验从 SQL 里提到了方法入口等于多了一层保护。方法级注解的好处是权限判断和业务逻辑待在同一个方法上代码阅读时不需要翻 Filter 配置维护成本低得多。3.3 第三层自定义数据权限注解 AOP 切面方法级注解虽然灵活但如果每个业务方法都要写一长串 SpEL代码会非常啰嗦而且校验规则会散落。于是我又做了一层更贴合业务的数据权限组件自定义DataPermission注解 AOP 切面。原理是用注解声明“这个方法的某个参数代表数据归属方”切面在方法执行前自动解析参数并检查当前用户是否有权访问该归属方。Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DataPermission { String resource() default ; String paramName() default ; }例如核销接口上标注DataPermission(resource coupon, paramName couponId)切面会用 couponId 去查这张券属于哪个门店然后判断当前用户的 storeId 是否匹配不匹配直接抛异常。这样的好处是业务代码里几乎没有权限杂质越权校验全部沉淀在切面里新人对项目的学习成本也低。后续如果想调整规则比如允许平台客服跨店核销只需要扩展切面逻辑或者在数据范围里增加规则不会影响 Controller 和 Service 的主流程。4. 完整落地步骤从权限表到拦截器一次打通4.1 权限数据模型权限编码、角色、用户关系表先把权限模型落到数据库。注意我是用权限编码authority作为 Spring Security 的 authority 值而不是直接在角色表里写死。下面是一组能在本地复现的关键表。CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY, permission_code VARCHAR(128) NOT NULL, permission_name VARCHAR(64) NOT NULL, parent_id BIGINT DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role( id BIGINT PRIMARY KEY, role_code VARCHAR(32) NOT NULL, role_name VARCHAR(32) NOT NULL ); CREATE TABLE sys_role_permission ( id BIGINT PRIMARY KEY, role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL ); CREATE TABLE sys_user_role ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL );初始化数据时我给“商家店员”这个角色分配了coupon:verify、coupon:view:shop等权限编码给“运营”分配了activity:create、activity:update:region等。这里需要提醒的是权限编码最好能体现出“资源:操作”的层次。比如activity:create和activity:update语义清晰调试时一眼就能看出问题不要用简单的数字1、2、3代替权限编码否则日志和配置都没法读。4.2 登录认证与权限装载把权限编码塞进 Authentication权限数据要生效必须在用户登录后把这些 authority 装载到 SecurityContext 里。我这边用的是 JWT Spring Security 的标准流程登录接口校验用户名密码成功后生成包含用户标识的 JWT后续请求在 JwtAuthenticationFilter 中解析 token拿到用户ID后调用自定义的 UserDetailsService 去加载用户和权限编码。Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user userMapper.findByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在); } ListString authorities permissionMapper.findPermissionCodesByUserId(user.getId()); return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), authorities.stream() .map(SimpleGrantedAuthority::new) .collect(Collectors.toList())); }这里有个容易忽略的点authorities 不仅要包含方法的操作权限也可以把某些范围属性编码放进去比如store:1001表示当前用户属于 1001 号门店。这样在数据权限切面里我可以直接从 Authentication 的 authorities 中解析出用户的门店范围而不用每次再查一次数据库。4.3 实现自定义数据权限切面越权检查不再散落在业务代码有了权限编码之后最关键的还是数据级权限怎么落地。我实现了一个精简但可用的切面。假设核销方法长这样Service public class CouponService { DataPermission(resource coupon, paramName couponId) public void verifyCoupon(Long couponId) { // 具体业务更新券状态、记录核销门店 } }切面逻辑在方法前拦截先从 Spring SecurityContext 拿到当前用户再到上下文里解析其可用门店范围然后根据 couponId 查券归属门店与范围做匹配。如果不匹配抛出 AccessDeniedException由全局异常处理器统一返回 403。Aspect Component public class DataPermissionAspect { Before(annotation(dataPermission)) public void check(JoinPoint joinPoint, DataPermission dataPermission) throws Exception { Authentication auth SecurityContextHolder.getContext().getAuthentication(); Long currentUserId ((CustomUser) auth.getPrincipal()).getId(); // 根据 resource 类型从 arguments 中取出目标ID Object[] args joinPoint.getArgs(); String paramName dataPermission.paramName(); Long targetId extractParamById(args, paramName); // 检查归属以 coupon 为例 Long belongStoreId couponMapper.findStoreIdByCouponId(targetId); Long currentStoreId getCurrentUserStoreId(auth); if (!belongStoreId.equals(currentStoreId)) { throw new AccessDeniedException(无权操作该券码); } } }当然真实场景里还要处理参数不是简单的 Long 类型、以及资源类型较多的情况但核心思路是一样的把“归属匹配”集中管理。这套切面后来也复用在活动配置、结算单审核等接口上只是把 resource 类型换掉即可代码没有大的膨胀。4.4 统一异常处理与响应权限控制做得再多如果异常响应不一致前端的处理也会很痛苦。我的做法是统一使用RestControllerAdvice拦截两个异常AuthenticationException 返回 401AccessDeniedException 返回 403。响应体固定为{ code: 403, message: 无权限访问, data: null }。这样前端只需要根据 HTTP 状态码就能做跳转或提示不需要去解析各式各样的错误信息。另外那些通过自定义切面抛出的数据越权异常也继承 AccessDeniedException保持语义一致。5. 性能缓存与权限动态刷新的工程实践5.1 谁在频繁查询权限表必须引入缓存权限模型落库之后最直观的性能压力来自 JwtAuthenticationFilter。因为过滤器每个请求都会根据 token 里的用户ID去数据库查一遍权限编码哪怕是几个简单 SELECT在高并发下也会把数据库连接池打满。所以我在正式环境里把“用户的权限编码集合”放进了 Rediskey 是 userIdvalue 是 ListGrantedAuthority 的序列化结果过期时间设为 30 分钟。登录拿到的 JWT 本身是短期的权限缓存 30 分钟刷新一次对后台管理场景完全够用。这里要注意缓存穿透一个用户如果没有任何权限也要在 Redis 里缓存一个空的 list而不是每次查询都落库。我还会在用户信息变更时主动删除这个 key让下一次请求重新加载。删除动作通过一个简单的权限变更事件发送到 Redis channel所有服务实例统一监听后清掉本地缓存避免多实例环境下的缓存不一致。5.2 数据范围缓存的粒度权限编码缓存的是“能不能做某类操作”而数据权限校验依赖的“当前用户归属门店ID/区域ID”也是高频数据。我把它直接放到 JWT 的 payload 里或者放进 SecurityContext 的 UserDetails 属性中这样查询归属时不需要额外查库。不过要注意放到 JWT 里的归属信息在过期前不会自动更新如果运营把某个门店从用户身上调走最坏情况要等 token 过期。对内部运营后台来说JWT 有效期设短一点比如2小时或者改用服务端会话保存可变属性能明显降低数据范围更新的延迟。5.3 动态权限变更的刷新机制权限如果只在后台改数据不向在线用户推送用户的权限要等 Redis 缓存过期才生效这在运营场景里会让人抓狂。我采用的方案是管理端操作权限数据时不直接改数据库而是通过一个“权限变更服务”去更新更新后发一条带 userId 的事件。服务端收到事件后删除对应 userId 的缓存并主动向在线用户推送一条 “permissionChanged” 消息。前端收到后清理本地权限状态并提示重新登录下次请求时过滤器自然加载最新权限。这个机制上线后权限调整基本能做到秒级生效不用再等缓存时间。6. 我在霸王餐项目中实打实踩过的坑6.1 坑一自定义过滤器加到了过滤器链后面结果权限判断全部失效第一次搭 JWT 过滤链时我把 JwtAuthenticationFilter 放到了错误位置导致 Spring Security 的授权过滤器在它之前执行SecurityContext 里永远是匿名用户。表现症状是所有需要认证的接口全部 403但是/api/auth/login能通。排查方向就是去看过滤器链的顺序确认 JwtAuthenticationFilter 必须放在 UsernamePasswordAuthenticationFilter 之前或者说至少在 AuthorizeHttpRequests 之前。肉眼可见的方法是通过日志打印 SecurityFilterChain 里的过滤器清单确认顺序。6.2 坑二方法级注解一直不生效忘记了开启方法安全Spring Security 的 URL 配置只作用于 Web 层PreAuthorize 这类方法级注解必须显式开启方法安全才有效。我刚开始只加了 EnableWebSecurity忘记加EnableGlobalMethodSecurity(prePostEnabled true)结果注解完全没生效接口照样能跑这其实比直接报错更危险因为权限形同虚设。后来我在配置类上补上注解并在启动日志里观察 AOP 代理是否创建才算真正把方法安全激活了。新版 Spring Security 里推荐使用EnableMethodSecurity配置项名字不同但作用一致。6.3 坑三AOP 切面内部自调用导致数据权限失效我的数据权限切面本质上是 Spring AOP只有通过代理对象调用方法时拦截才会生效。如果一个 Service 方法内部用this.verifyCoupon(couponId)调用了带注解的方法切面根本不会执行因为 this 是原始对象而不是代理对象。解决办法要么把需要拦截的方法拆分到另一个 Bean要么使用AopContext.currentProxy()做自调用时的代理获取。这个问题隐藏得很深我是在联调时发现“切面提示无权限但业务成功了”才定位到的。6.4 坑四SpEL 里参数名解析失败导致注解里的 userId 找不到我在 PreAuthorize 里写#userId时如果编译时没有开启-parameters参数或者 Spring 的 LocalVariableTableParameterNameDiscoverer 找不到参数名表达式会直接抛异常。现象是权限校验接口一调用就报 SpelEvaluationException。解决很简单在 pom.xml 里给 maven-compiler-plugin 配置parameterstrue/parameters或者把参数名明确写在 PreAuthorize 注解里。这个坑基本上每个用 SpEL 访问方法参数的人都会遇到找一次就知道哪里的配置了。还有个小建议数据权限切面里一定要对异常做兜底比如在参数中拿不到目标ID时宁可把请求拒绝掉也不要漏过校验否则前面所有精细化的努力都会白费。这就是我在霸王餐接口权限改造过程中主要做的事情。这套方案走到生产后最明显的改变是我们不再担心“拿着运营账号的人能改别人的门店数据”这类事故审计日志里也基本看不到越权告警。最后再分享一个小技巧权限编码的命名一定不要随意写最好全项目拉一个规范比如模块加冒号加动作加冒号加范围宁可长一点也别含糊否则半年后你自己看着都头疼。希望这篇能帮你的接口权限设计少走点弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑