ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Spring Security的餐饮平台接口权限精细化控制实战

基于Spring Security的餐饮平台接口权限精细化控制实战 做餐饮行业的开发十有八九都接过“霸王餐”这类营销活动的需求。本质上是商家拿出免费套餐做引流平台负责发券、核销、结算这整个闭环。业务本身不复杂真正让很多人头疼的是权限控制——一个活动从创建到结算涉及的角色至少五类平台运营、商家管理员、门店店员、普通用户、平台客服后面可能还要加上财务。几十个业务接口分布在运营后台、商家后台、用户端三个入口如果权限控制只停留在“登录就能访问”这种粗粒度上线之后必然出事。我这次负责的霸王餐接口项目选择基于Spring Security做了一套接口权限精细化控制方案从URL级、方法级、数据级三个维度去管控覆盖商家端和运营端的全部核心场景。效果不错踩的坑也不少。这篇文章就把整个方案的建模思路、数据表设计、核心代码实现、以及排查问题的经验完整梳理一遍给正在做同类餐饮平台权限的同学一个可落地的参考样本。1. 业务模型与权限控制思路1.1 霸王餐业务中的角色与权限痛点先把这个业务的角色模型梳理清楚。霸王餐活动虽然面向C端用户但后台参与方很多而且不同角色之间的操作范围有大量重叠平台超管查看全局数据管理所有商家、所有活动配置角色权限。平台运营按区域分工比如华北运营、华东运营只能管理自己辖区内的商家和活动。商家管理员管理自己门店的活动、菜品、券码核销、预约记录。门店店员只能做券码核销、修改预约状态不能创建或修改活动。平台客服查看订单和券码使用记录处理客诉但不能改商家信息。C端用户领取霸王餐券、预约到店、提交评价。这个模型里最典型的问题是“同一个接口不同角色的操作边界不一样”。比如券码核销接口商家管理员和门店店员都能调用但管理员还额外拥有“作废券码”的权限店员却没有。再比如活动审核接口平台运营只能审核自己辖区内的商家报名超管可以审核全部。这种“同一接口、同一资源、不同角色、不同范围”的需求单纯的URL拦截根本做不了必须在方法层和数据层分别做控制。1.2 为什么选Spring Security而不是自己写拦截器很多人觉得权限控制就是加一个拦截器判断一下用户角色就完事了。但在这个项目里如果只做角色判断会遇到几个绕不开的问题第一角色是粗粒度集合。一个角色可能拥有几十个操作权限授权判断时不能每次都遍历角色去对比接口名这会让业务代码充满if判断后期维护成本极高。第二数据范围无法用拦截器表达。运营A只能看华北区运营B只能看华东区这个规则写在URL拦截器里是完全没办法处理的必须要在SQL层动态注入过滤条件。第三方法级权限和业务异常需要统一处理。用Spring Security的PreAuthorize注解可以让权限断言在业务代码之前执行并且统一抛出AccessDeniedException再由全局异常处理器统一转换响应。这样业务代码里不用再写try-catch捕获权限异常代码干净很多。另外Spring Security本身是一个成熟的框架它提供了完整的认证过滤器链、匿名认证机制、会话管理策略以及PermissionEvaluator这样可扩展的授权组件。虽然学习曲线比拦截器陡但一旦跑通后面扩展新角色、新权限点只需要改配置和元数据不用动业务代码。相比之下自研权限系统前期看着灵活后期边界场景一多很容易把自己绕进去。2. 权限模型与数据表设计2.1 经典的RBAC模型怎么扩展这个项目采用了RBAC基于角色的访问控制模型但在此基础上增加了一个维度数据范围。核心思路是把权限拆成两部分操作权限用户能做什么比如创建活动、审核活动、导出券码。用权限点字符串标识。数据权限用户能看到哪些数据比如只看本商家、看本区域、看全部。用数据范围标识。操作权限挂在角色上角色挂在用户上这个和标准RBAC一致。数据权限则单独处理每个角色带一个数据范围代码比如MERCHANT仅本商家、REGION本区域、ALL全部。同时用户的归属关系也要记录——用户属于哪个商家、哪个区域这样在数据过滤时才能计算出具体的数据范围。实际运行中判断一个请求是否被允许分成两步先通过用户的角色集合解析出操作权限列表判断他能不能做这件事再做数据权限校验判断他操作的数据是否在允许范围内。两步都通过才放行。2.2 核心数据表设计整个权限模型最终落到几张表上。我按实际建表的结构整理一下字段已经做了精简核心思想保留-- 用户表 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(64) NOT NULL, password VARCHAR(255) NOT NULL, status TINYINT DEFAULT 1 COMMENT 1启用 0禁用, merchant_id BIGINT DEFAULT NULL COMMENT 所属商家用户角色为商家时必填, region_code VARCHAR(16) DEFAULT NULL COMMENT 所属区域用户角色为运营时必填, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 角色表 CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(64) NOT NULL COMMENT 角色编码如PLATFORM_ADMIN, role_name VARCHAR(128) NOT NULL, data_scope VARCHAR(32) NOT NULL COMMENT MERCHANT/REGION/ALL ); -- 权限点表 CREATE TABLE sys_permission ( id BIGINT PRIMARY KEY AUTO_INCREMENT, perm_code VARCHAR(128) NOT NULL COMMENT 权限点编码如activity:create, perm_name VARCHAR(128) NOT NULL, url_pattern VARCHAR(255) NOT NULL COMMENT 关联的URL表达式用于URL级控制 ); -- 用户角色关联表 CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ); -- 角色权限关联表 CREATE TABLE sys_role_permission ( role_id BIGINT NOT NULL, permission_id BIGINT NOT NULL, PRIMARY KEY (role_id, permission_id) );这里有一个设计关键点权限点为什么用字符串标识而不是数字ID。在PreAuthorize注解里直接写字面量hasAuthority(activity:create)可读性和可维护性会好很多。如果权限点用数字ID写注解时根本没法做静态检查代码评审时也看不出这个接口到底需要什么权限。字符串权限点本质上就是业务语义的命名比如活动创建就是activity:create活动审核就是activity:review核销就是coupon:verify可读性强且能复用。2.3 权限点清单的梳理方法权限点的定义是方案里最需要花心思的部分直接影响后续所有代码。我在做设计时按模块逐一列出操作动词模块权限点说明活动管理activity:create创建活动活动管理activity:update修改活动活动管理activity:review审核商家报名活动管理activity:offline手动下线活动商家管理merchant:apply商家报名活动商家管理merchant:audit商家资质审核券码管理coupon:grant发放券码券码管理coupon:verify核销券码券码管理coupon:void作废券码券码管理coupon:export导出券码列表财务模块finance:settlement结算操作财务模块finance:export导出结算报表系统管理user:assignRole分配用户角色这里有个值得分享的经验权限点要按“操作”命名不能按“菜单”命名。比如“活动审核”和“活动查看”是两个权限点不能因为页面菜单里它们都属于活动管理就合并成一个。没有这种粒度后面运营提出“客服只能看活动详情但不能审核”之类需求时你就得改表改代码非常被动。3. 核心实现认证授权链路3.1 认证过滤器和JWT的无状态方案这个项目的前后端完全分离服务端不能依赖Session所以认证采用JWT配合Spring Security的过滤器链实现无状态认证。JWT令牌采用HS256签名里面只存放用户ID和登录时间不存放权限数据。为什么权限数据不放JWT里因为权限是可能被运营管理员随时调整的如果放进JWT权限变更后必须等token过期才会生效这样的响应周期太慢。正确的做法是JWT只认证用户身份权限数据在每次请求时从Redis实时加载这样权限变更可以在秒级生效。核心的认证过滤器如下Component public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private JwtTokenService jwtTokenService; Autowired private PermissionCacheService permissionCacheService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (StringUtils.hasText(token) jwtTokenService.validateToken(token)) { Long userId jwtTokenService.getUserIdFromToken(token); LoginUser loginUser permissionCacheService.loadLoginUser(userId); if (loginUser ! null) { UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken( loginUser, null, loginUser.getAuthorities() ); authentication.setDetails( new WebAuthenticationDetailsSource().buildDetails(request) ); SecurityContextHolder.getContext().setAuthentication(authentication); } } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer request.getHeader(Authorization); if (StringUtils.hasText(bearer) bearer.startsWith(Bearer )) { return bearer.substring(7); } return null; } }loadLoginUser方法会先从Redis缓存取用户信息和权限集合如果缓存不存在再从数据库加载并写入缓存。这里有一个重要的细节LoginUser对象必须显式实现getAuthorities()方法返回一个SimpleGrantedAuthority列表其中每个权限点字符串都会转换为一个GrantedAuthority对象。这样Spring Security的hasAuthority表达式才能正常工作。3.2 URL级权限控制的实现方式URL级控制是整个方案的第一道防线。我使用的是基于数据库动态加载的URL与权限点映射关系而不是在配置类里硬编码URL规则。硬编码的问题在于每新增一个接口都要改代码重新发版运营那边调整权限完全依赖开发介入这不是一个合格的后台系统该有的状态。实现思路是构建一个自定义的FilterInvocationSecurityMetadataSource从数据库加载所有URL表达式与所需权限点的映射关系在每次请求进来时根据当前请求的URL匹配出所需权限再做动态授权Component public class DynamicSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { Autowired private SysPermissionMapper permissionMapper; private MapString, String urlPermMappingCache; PostConstruct public void loadUrlPermissionMapping() { // 从sys_permission表查询所有记录构建 url_pattern - perm_code 的映射 ListSysPermission permissions permissionMapper.selectList(null); urlPermMappingCache permissions.stream() .collect(Collectors.toMap(SysPermission::getUrlPattern, SysPermission::getPermCode)); } Override public CollectionConfigAttribute getAttributes(Object object) throws IllegalArgumentException { FilterInvocation invocation (FilterInvocation) object; String requestUrl invocation.getRequest().getRequestURI(); String httpMethod invocation.getRequest().getMethod(); // 先做精确匹配匹配不到再用AntPath匹配 for (String pattern : urlPermMappingCache.keySet()) { if (pathMatcher.match(pattern, requestUrl)) { String permCode urlPermMappingCache.get(pattern); if (permCode ! null) { return SecurityConfig.createList(permCode); } } } return SecurityConfig.createList(ROLE_ANONYMOUS); } Override public CollectionConfigAttribute getAllConfigAttributes() { return null; } Override public boolean supports(Class? clazz) { return FilterInvocation.class.isAssignableFrom(clazz); } }这个实现有一个性能注意事项urlPermMappingCache是本地内存缓存不会自动感知数据库的权限变更。所以在权限点或映射关系被修改后需要主动调用一次刷新方法比如重新加载PostConstruct的逻辑否则改动不会生效。我踩过这个坑后面会在常见问题里详细说。3.3 方法级权限控制PreAuthorize的真正价值URL级控制处理的是“这个接口需要什么权限”但同一个接口内部不同操作行为的权限粒度可能很不同。比如/api/platform/activity/{id}这个urlGET请求是查看活动详情DELETE请求是下线活动。查看可能所有运营角色都可以下线却只有活动审核权限的人能操作。这种场景在URL层面虽然可以用method区分但代码会变得很别扭。方法级控制是我更推荐的方案。它直接在Service层的方法上声明权限需求由Spring Security AOP在执行方法前拦截判断Service public class ActivityServiceImpl implements ActivityService { Override PreAuthorize(hasAuthority(activity:create)) public Long createActivity(ActivityCreateDTO dto) { // 创建活动的业务逻辑 } Override PreAuthorize(hasAuthority(activity:review)) public void reviewActivity(Long activityId, ReviewResultDTO reviewResult) { // 审核活动逻辑 } Override PreAuthorize(hasAuthority(coupon:verify)) public void verifyCoupon(String verifyCode, Long merchantId) { // 券码核销逻辑 } Override PreAuthorize(hasAuthority(coupon:export)) public ListCouponExportVO exportCoupons(Long activityId) { // 券码导出逻辑 } }PreAuthorize注解放在Service方法上比放在Controller方法上更安全。因为Controller层只是做参数绑定和数据转换真正的数据操作逻辑都在Service层权限控制放在数据入口处可以避免“同一个业务数据通过不同Controller被间接操作”的绕过风险。同时要注意在启动类或配置类上显式开启方法级安全Configuration EnableWebSecurity EnableGlobalMethodSecurity(prePostEnabled true) public class SecurityConfig { // ... }这个prePostEnabled true是必须的少了它所有的PreAuthorize注解都不会生效而且不报错是排查时特别容易忽略的一个点。4. 数据权限与精细化控制落地4.1 自定义PermissionEvaluator扩展hasPermission前面讲过操作权限用hasAuthority解决数据权限必须在数据层面做过滤。Spring Security提供了一种扩展方式继承PermissionEvaluator配合PreAuthorize中使用hasPermission表达式。Component public class CustomPermissionEvaluator implements PermissionEvaluator { Autowired private MerchantService merchantService; Override public boolean hasPermission(Authentication authentication, Object targetDomainObject, Object permission) { if (!(authentication.getPrincipal() instanceof LoginUser)) { return false; } LoginUser loginUser (LoginUser) authentication.getPrincipal(); // 超管直接通过 if (loginUser.isSuperAdmin()) { return true; } // 目标对象是数据ID比如活动ID、商家ID Long dataId Long.valueOf(String.valueOf(targetDomainObject)); String permCode String.valueOf(permission); // 根据权限码做不同的数据归属校验 switch (permCode) { case activity:update: case activity:review: return merchantService.checkActivityBelongs(dataId, loginUser.getMerchantId()); case merchant:audit: return checkRegionContain(loginUser.getRegionCode(), dataId); default: return false; } } Override public boolean hasPermission(Authentication authentication, Serializable targetId, String targetType, Object permission) { return false; } }配置类中注入Configuration public class PermissionConfig extends GlobalMethodSecurityConfiguration { Autowired private CustomPermissionEvaluator permissionEvaluator; Override protected MethodSecurityExpressionHandler createExpressionHandler() { DefaultMethodSecurityExpressionHandler handler new DefaultMethodSecurityExpressionHandler(); handler.setPermissionEvaluator(permissionEvaluator); return handler; } }使用方式就是在方法级注解里写Override PreAuthorize(hasPermission(#activityId, activity:update)) public void updateActivity(Long activityId, ActivityUpdateDTO dto) { // 业务逻辑 }这里传入的#activityId会作为参数绑定到hasPermission的第一个参数targetDomainObject上。CustomPermissionEvaluator内部会先判断用户是否属于该活动的归属商家再做后续逻辑。这样就实现了“操作权限数据归属”的双重校验而且校验逻辑内聚在PermissionEvaluator中业务代码几乎零侵入。4.2 数据范围内置SQL过滤的实现数据权限还有一种更隐蔽的需求场景列表查询。比如商家管理员查询活动列表只能查到自己的活动区域运营查询商家列表只能看到辖区内的商家。这种场景没法靠PermissionEvaluator做因为目标数据不是单个ID而是一张数据列表。我的方案是定义自定义注解DataScope配合AOP切面在Mapper查询前自动拼接过滤条件Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DataScope { String merchantAlias() default ; String regionAlias() default ; }切面逻辑Aspect Component public class DataScopeAspect { Around(annotation(dataScope)) public Object handleDataScope(ProceedingJoinPoint joinPoint, DataScope dataScope) throws Throwable { LoginUser loginUser SecurityUtils.getCurrentLoginUser(); // 超管不过滤 if (loginUser.isSuperAdmin()) { return joinPoint.proceed(); } if (dataScope.merchantAlias().length() 0) { String condition dataScope.merchantAlias() .merchant_id loginUser.getMerchantId(); SqlContextHolder.setDataCondition(condition); } else if (dataScope.regionAlias().length() 0) { String condition dataScope.regionAlias() .region_code loginUser.getRegionCode() ; SqlContextHolder.setDataCondition(condition); } try { return joinPoint.proceed(); } finally { SqlContextHolder.clear(); } } }Mapper的XML中需要配合使用${}或者通过MyBatis拦截器动态拼接条件select idselectActivityList resultTypeActivityVO SELECT a.* FROM activity a where if testkeyword ! null and keyword ! AND a.activity_name LIKE CONCAT(%, #{keyword}, %) /if if testdataCondition ! null AND ${dataCondition} /if /where /select这种方法有一个要注意的点条件拼接必须使用白名单方式不能直接接受外部传入的任意SQL片段。DataScope切面中拼条件时merchant_id和region_code都是从当前用户上下文取出的数值或编码不会受到请求参数污染相对安全。4.3 权限缓存与权限变更的实时同步权限数据的高频读取需要一个可靠的缓存方案。我的选择是用Redis存储用户的权限集合key设计为login:user:permissions:{userId}value是一个JSON数组里面是该用户当前的全部权限点字符串。权限变更时涉及两个层面的同步一是在用户角色重新分配之后调用一个统一的permissionCacheService.refreshUserPermissions(userId)方法删除该用户对应的权限缓存。这样下一次请求进来时JwtAuthenticationFilter会重新从数据库加载权限数据并刷新缓存。二是在权限点本身发生变化时比如给角色添加了新权限点需要批量刷新所有拥有该角色的用户缓存。这里可以用一个简单的方案记录每个角色和用户的关联关系角色权限变更时查询出所有关联用户ID逐个删除缓存。这里有一个隐藏的坑如果用户信息是从Redis反序列化出来的权限集合被修改后必须保证LoginUser和缓存的一致。我最初遇到的问题是权限更新后Redis中缓存的LoginUser里还带着旧的权限集合导致用Redis中的LoginUser创建Authentication时authorities依然是旧的。后来我调整了策略Redis中只缓存权限字符串集合Authentication对象里的LoginUser每次都从权限字符串重建这样权限更新后重建的LoginUser自然就是新权限。5. 常见问题与排查技巧5.1 URL权限映射不刷新的问题现象运营在管理后台给某个角色添加了新的权限点但普通用户访问对应接口还是被拒绝重启服务后正常。原因DynamicSecurityMetadataSource里的urlPermMappingCache是启动时加载到本地内存的数据库变更不会主动触发刷新。解决提供一个显式的刷新方法在权限点变更接口中调用。我当时的做法是发布一个Spring事件public class PermissionChangedEvent extends ApplicationEvent { public PermissionChangedEvent(Object source) { super(source); } }然后在DynamicSecurityMetadataSource中监听EventListener(PermissionChangedEvent.class) public void reloadUrlPermissionMapping() { this.urlPermMappingCache permissionMapper.selectList(null) .stream() .collect(Collectors.toMap(SysPermission::getUrlPattern, SysPermission::getPermCode)); }同时在角色权限变更的Service方法中发布事件。这样就不需要重启服务了。5.2 OPTIONS预请求被权限拦截现象前端联调时发现跨域请求失败浏览器报“Failed to execute fetch”但服务端日志显示401。原因浏览器跨域时会先发一个OPTIONS预检请求如果SecurityConfig中没有放行OPTIONS请求预检请求会被认证过滤器拦截。解决在SecurityFilterChain中显式放行所有OPTIONS请求http.authorizeRequests() .antMatchers(HttpMethod.OPTIONS, /**).permitAll() .antMatchers(/api/auth/login).permitAll() .anyRequest().authenticated();这是一个非常容易踩的坑尤其在前端使用自定义请求头比如Authorization时OPTIONS预检请求必然会出现。不放行的结果就是前端所有接口全部跨域失败排查时很难想到是权限拦截器的问题。5.3 权限更新后已登录用户仍然持有旧权限现象把某个用户从商家管理员降级为普通店员后该用户不退出登录仍然能调用活动管理接口。原因JWT无状态认证下服务端不保存session用户权限数据从Redis加载但Redis中的缓存没有被主动清除。解决在修改用户角色的Service方法中明确调用权限缓存清理public void assignRoles(Long userId, ListLong roleIds) { userRoleMapper.deleteByUserId(userId); roleIds.forEach(roleId - userRoleMapper.insert(userId, roleId)); // 关键清理该用户的权限缓存 permissionCacheService.evictUserPermissions(userId); }同时我在JwtAuthenticationFilter中做了一个二次校验从Redis拿到的LoginUser会带有一个权限版本号当前用户的版本号与Redis中最新版本号不一致时强制重新加载。这个版本号可以是一个递增的整数或用户信息的更新时间戳确保权限变更后最多一次请求就能感知。5.4 懒加载导致的LazyInitializationException现象过滤器链中的LoginUser获取角色集合时抛出LazyInitializationException。原因用户实体的角色和权限集合是延迟加载的在Service层事务提交后Session已经关闭再访问延迟加载属性就会报错。解决在用户认证阶段就强制加载全部关联数据使用EntityGraph或者手写JOIN FETCH语句一次性查出用户角色和角色权限然后转换成LoginUser对象。这个转换过程必须在事务或边界内完成后续只用已经转好的登录用户对象不再直接访问JPA实体的关联属性。6. 一点个人经验总结这套方案开发和落地的过程中我最深刻的体会是权限控制的设计不能只盯着“拦截器”本身而应该把它拆成三个独立的层次去思考。URL级控制解决的是“谁能访问这个接口”方法级控制解决的是“谁能执行这个操作”数据级控制解决的是“谁能处理这批数据”。三个层次各有各的适用场景不能互相替代。如果只做URL级同一个接口内部的细粒度操作控制不了如果只做方法级列表接口的越权数据查询依然存在如果只做数据级那又缺少第一道入口拦截性能上也不划算。在项目的实践顺序上我的建议是先做RBAC的基础模型也就是用户、角色、权限点这张网把URL级和方法级的控制跑通。数据级控制可以放在二期等基于注解的AOP方案成熟之后再上。一上来就想三个层次全部做细开发者很容易被复杂的权限矩阵绕晕反而不利于项目推进。最后分享一个小技巧权限点的命名一定要形成规范这个规范最好写进团队约定里可以节省大量沟通成本。我推荐统一采用“模块:操作”的格式全部小写多个单词用连字符分隔比如coupon:batch-grant表示批量发放券码finance:export-daily-report表示导出日报表。这种命名的好处是开发人员看到字符串基本就能猜到是什么权限运营人员在后台配置角色权限时也能快速定位长期维护下来受益非常大。这套方案后续还可以扩展的方向是把角色权限的配置做成可视化的管理页面让运营人员可以直接勾选权限点动态更新角色权限关联表配合上面提到的事件刷新机制实现完全可配置化的权限管理彻底摆脱改代码发版才能调整权限的情况。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进