别再乱给权限了!用Spring Security + MyBatis-Plus搞定SaaS系统的三级权限控制(附完整代码)

张开发
2026/4/8 14:36:26 15 分钟阅读

分享文章

别再乱给权限了!用Spring Security + MyBatis-Plus搞定SaaS系统的三级权限控制(附完整代码)
实战指南Spring Security与MyBatis-Plus构建SaaS三级权限体系在SaaS系统开发中权限控制是保障数据安全与业务隔离的核心环节。本文将手把手演示如何基于Spring Security和MyBatis-Plus从数据库设计到接口鉴权构建一套完整的三级权限控制系统。不同于理论讲解我们直接切入代码实现解决实际开发中的典型问题。1. 数据库设计与多租户隔离1.1 核心表结构设计权限系统的基石在于合理的数据库设计。以下是经过实战验证的表结构方案-- 租户主表 CREATE TABLE sys_tenant ( id BIGINT PRIMARY KEY COMMENT 租户ID, name VARCHAR(100) NOT NULL COMMENT 租户名称, status TINYINT DEFAULT 1 COMMENT 状态(1:启用 0:禁用), expire_time DATETIME COMMENT 过期时间, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 部门表租户内组织架构 CREATE TABLE sys_dept ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL COMMENT 所属租户, parent_id BIGINT COMMENT 父部门ID, name VARCHAR(50) NOT NULL, INDEX idx_tenant (tenant_id) ); -- 用户表需同时关联租户和部门 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, dept_id BIGINT COMMENT 所属部门, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, status TINYINT DEFAULT 1, INDEX idx_tenant_user (tenant_id, username) ); -- 角色表租户内角色定义 CREATE TABLE sys_role ( id BIGINT PRIMARY KEY, tenant_id BIGINT NOT NULL, name VARCHAR(50) NOT NULL, data_scope TINYINT COMMENT 数据权限范围(1:全部 2:本部门 3:自定义 4:仅自己), INDEX idx_tenant_role (tenant_id) );关键设计原则所有业务表必须包含tenant_id字段需要数据权限控制的表应添加dept_id和create_by字段索引设计需考虑多租户查询模式1.2 MyBatis-Plus多租户实现通过MyBatis-Plus的租户处理器实现自动SQL拦截public class TenantHandler implements TenantLineHandler { Override public Expression getTenantId() { // 从当前线程获取租户ID通常从SecurityContext或ThreadLocal获取 return new LongValue(SecurityUtils.getTenantId()); } Override public String getTenantIdColumn() { return tenant_id; } Override public boolean ignoreTable(String tableName) { // 忽略不需要租户隔离的表 return sys_tenant.equals(tableName) || tableName.startsWith(sys_config); } }配置生效# application.yml mybatis-plus: global-config: db-config: tenant-handler: com.example.TenantHandler2. Spring Security认证与基础授权2.1 定制UserDetailsService实现租户感知的用户加载逻辑Service public class TenantUserDetailsService implements UserDetailsService { Autowired private UserMapper userMapper; Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 获取当前请求的租户ID可从请求头或子域名解析 Long tenantId TenantContext.getCurrentTenant(); User user userMapper.selectByTenantAndUsername(tenantId, username); if (user null) { throw new UsernameNotFoundException(用户不存在); } return new TenantUser( user.getId(), user.getTenantId(), user.getUsername(), user.getPassword(), getAuthorities(user.getId()) ); } private Collection? extends GrantedAuthority getAuthorities(Long userId) { // 查询用户角色和权限代码 return authorityMapper.selectByUserId(userId).stream() .map(auth - new SimpleGrantedAuthority(auth.getCode())) .collect(Collectors.toList()); } }2.2 安全配置核心逻辑Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Override protected void configure(HttpSecurity http) throws Exception { http .authorizeRequests() .antMatchers(/api/auth/**).permitAll() .antMatchers(/api/admin/**).hasRole(SYSTEM_ADMIN) .antMatchers(/api/tenant-admin/**).hasRole(TENANT_ADMIN) .anyRequest().authenticated() .and() .addFilterBefore(new TenantFilter(), UsernamePasswordAuthenticationFilter.class) .csrf().disable(); } Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }3. 数据权限的精细化控制3.1 数据权限注解设计定义数据范围注解Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DataScope { String deptAlias() default ; String userAlias() default ; }3.2 AOP切面实现SQL改写Aspect Component public class DataScopeAspect { Before(annotation(dataScope)) public void doBefore(JoinPoint joinPoint, DataScope dataScope) { // 获取当前用户数据权限范围 Integer dataScopeType SecurityUtils.getDataScopeType(); String sqlFilter ; switch (dataScopeType) { case 1: // 全部数据 break; case 2: // 本部门数据 sqlFilter String.format(%s.dept_id %d, dataScope.deptAlias(), SecurityUtils.getDeptId()); break; case 4: // 仅自己数据 sqlFilter String.format(%s.create_by %s, dataScope.userAlias(), SecurityUtils.getUserId()); break; } if (!sqlFilter.isEmpty()) { // 将条件存入ThreadLocal供MyBatis拦截器使用 DataScopeHelper.setDataScope(sqlFilter); } } }3.3 MyBatis拦截器实现SQL拼接Intercepts({ Signature(type StatementHandler.class, methodprepare, args{Connection.class, Integer.class}) }) public class DataScopeInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { String originalSql getOriginalSql(invocation); // 获取数据权限条件 String dataScope DataScopeHelper.getDataScope(); if (StringUtils.isNotEmpty(dataScope)) { String newSql appendWhereCondition(originalSql, dataScope); resetSql(invocation, newSql); } return invocation.proceed(); } private String appendWhereCondition(String sql, String condition) { // 智能识别已有WHERE条件进行拼接 if (sql.toUpperCase().contains(WHERE)) { return sql AND condition; } else { return sql WHERE condition; } } }4. 接口级权限与功能控制4.1 自定义权限注解Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface RequiresPermission { String[] value(); Logical logical() default Logical.AND; }4.2 权限校验切面Aspect Component public class PermissionAspect { Before(annotation(requiresPermission)) public void checkPermission(JoinPoint joinPoint, RequiresPermission requiresPermission) { String[] permissions requiresPermission.value(); SetString userPermissions SecurityUtils.getPermissions(); if (requiresPermission.logical() Logical.AND) { // 必须拥有所有权限 for (String permission : permissions) { if (!userPermissions.contains(permission)) { throw new AccessDeniedException(权限不足); } } } else { // 拥有任一权限即可 boolean hasAny Arrays.stream(permissions) .anyMatch(userPermissions::contains); if (!hasAny) { throw new AccessDeniedException(权限不足); } } } }4.3 控制器使用示例RestController RequestMapping(/api/customer) public class CustomerController { RequiresPermission(customer:query) DataScope(deptAlias c, userAlias c) GetMapping public PageResultCustomer list(CustomerQuery query) { return customerService.pageQuery(query); } RequiresPermission(value {customer:add, customer:edit}, logical OR) PostMapping public Result save(Valid RequestBody Customer customer) { return customerService.saveCustomer(customer); } }5. 实战中的典型问题与解决方案5.1 分页查询的权限处理问题场景当使用MyBatis-Plus分页插件时自定义SQL拦截可能导致分页统计不准确。解决方案重写分页拦截器public class PaginationInterceptor extends PaginationInnerInterceptor { Override protected void handlerTotal(boolean needCount, MappedStatement ms, BoundSql boundSql, Page? page, Connection connection) { if (needCount) { String countSql boundSql.getSql(); // 先移除原有ORDER BY等非必要子句 countSql removeUnnecessaryClauses(countSql); // 再拼接数据权限条件 countSql appendDataScope(countSql); // 执行改造后的count查询 executeCountSql(ms, connection, countSql, boundSql, page); } } }5.2 多租户下的唯一索引冲突问题场景用户名等业务字段需要跨租户唯一但单租户内也要唯一。解决方案复合唯一索引ALTER TABLE sys_user ADD UNIQUE INDEX uk_tenant_username (tenant_id, username);5.3 权限变更的实时生效实现方案基于Redis的权限缓存刷新EventListener public void handleRoleChange(RolePermissionUpdateEvent event) { // 获取该角色关联的所有用户 ListLong userIds userRoleMapper.selectUserIdsByRoleId(event.getRoleId()); // 批量清除缓存 redisTemplate.delete( userIds.stream() .map(id - user:perms: id) .collect(Collectors.toList()) ); }6. 前端权限控制协同方案虽然后端是权限控制的最终防线但良好的前后端协同能提升用户体验6.1 按钮级权限控制template el-button v-ifhasPermission(customer:export) clickhandleExport 导出数据 /el-button /template script export default { methods: { hasPermission(code) { return this.$store.state.user.permissions.includes(code); } } } /script6.2 动态路由生成// 过滤异步路由表 function filterAsyncRoutes(routes, permissions) { return routes.filter(route { if (route.meta route.meta.permission) { return permissions.includes(route.meta.permission); } return true; }); }6.3 接口权限提示优化全局拦截403错误axios.interceptors.response.use(response { return response; }, error { if (error.response.status 403) { ElMessage.error(您没有执行此操作的权限); return Promise.reject(error); } });7. 系统扩展与性能优化7.1 权限缓存策略Cacheable(value user_perms, key #userId) public SetString getUserPermissions(Long userId) { return permissionMapper.selectByUserId(userId).stream() .map(Permission::getCode) .collect(Collectors.toSet()); }7.2 权限查询优化使用JOIN查询避免N1问题SELECT r.code FROM sys_role r JOIN sys_user_role ur ON ur.role_id r.id WHERE ur.user_id #{userId}7.3 租户资源隔离通过自定义注解实现租户资源配额控制Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface TenantResourceLimit { String resourceType(); int limit(); }切面实现Around(annotation(limit)) public Object checkLimit(ProceedingJoinPoint joinPoint, TenantResourceLimit limit) throws Throwable { Long tenantId SecurityUtils.getTenantId(); String key tenant:limit: tenantId : limit.resourceType(); // Redis原子操作 Long count redisTemplate.opsForValue().increment(key); if (count limit.limit()) { throw new BusinessException(资源使用已达上限); } try { return joinPoint.proceed(); } finally { redisTemplate.decrement(key); } }

更多文章