【鉴权架构】SpringBoot + Sa-Token + MyBatis + MySQL + Redis 实现用户鉴权、角色管理、权限管理

sa-token 官方文档

基础登录功能

  1. 引入依赖

由于使用SpringBoot3,故使用较新版

plain 复制代码
        <!-- Sa-Token 权限认证,在线文档:https://sa-token.cc -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-spring-boot3-starter</artifactId>
            <version>1.44.0</version>
        </dependency>
  1. yml配置

前端小程序使用,故禁用session,使用token模式

yaml 复制代码
# 用户鉴权
sa-token:
  # token 名称
  # Authorization: Bearer token值
  token-name: Authorization
  token-prefix: Bearer       # 设置Token前缀 【注意有空格】
  is-read-header: true          # 从Header读取Token
  is-read-cookie: false         # 禁用Cookie(纯Token模式)
  # token 有效期(单位:秒) 默认30天,-1 代表永久有效
  timeout: 2592000
  # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
  active-timeout: -1
  # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
  is-concurrent: true
  # 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
  is-share: false
  # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
  token-style: uuid
  # 是否输出操作日志
  is-log: true
  1. 全局拦截器 拦截未登录用户

新建全局拦截器 实现 WebMvcConfigurer 接口

java 复制代码
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册Sa-Token拦截器
        registry.addInterceptor(new SaInterceptor(handler -> {
            SaRouter.match("/**")
            .notMatch(
                // 用户认证
                "/user/login",
                "/user/register",

                // Swagger/Knife4j 接口文档
                "/doc.html",
                "/webjars/**",
                "/swagger-resources/**",
                "/v3/api-docs",
                "/v3/api-docs/**",
                "/favicon.ico",

                // 其他需要放行的路径
                "/error"
            )
            .check(StpUtil::checkLogin); // 校验是否登录 (调试阶段可放开)
        })).addPathPatterns("/**");
    }
}
  1. 在用户登录服务中使用 StpUtil 服务进行登录

satoken 会在内存中保存 id 和 token,标记用户登录

java 复制代码
StpUtil.login(user.getId()); // 生成Token并保存
java 复制代码
    @Override
    public LoginUserVO userLogin(String userAccount, String userPassword) {
        // 1. 校验
        if (StrUtil.hasBlank(userAccount, userPassword)) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "参数为空");
        }
        if (userAccount.length() < 4) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "账号错误");
        }
        if (userPassword.length() < 8) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "密码错误");
        }
        // 2. 加密
        String encryptPassword = getEncryptPassword(userPassword);
        // 查询用户是否存在
        QueryWrapper queryWrapper = new QueryWrapper();
        queryWrapper.eq("userAccount", userAccount);
        queryWrapper.eq("userPassword", encryptPassword);
        User user = this.mapper.selectOneByQuery(queryWrapper);
        // 用户不存在
        if (user == null) {
            throw new BusinessException(ErrorCode.PARAMS_ERROR, "用户不存在或密码错误");
        }

        // 3. SaToken登录
        StpUtil.login(user.getId()); // 生成Token并保存

        // 4. 返回信息并携带 token
        LoginUserVO loginUserVO = this.getLoginUserVO(user);
        loginUserVO.setToken(StpUtil.getTokenValue());

        return loginUserVO;
    }

引入Redis

由于satoken默认将数据存储与内存中,每次重启都需要重新登录,即不方便

故引入redis进行持久化存储

  1. 引入Redis
java 复制代码
        <!-- Redis -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
  1. 引入 Satoken 的redis相关依赖
java 复制代码
        <!-- Sa-Token 整合 Redis -->
        <dependency>
            <groupId>cn.dev33</groupId>
            <artifactId>sa-token-redis-jackson</artifactId>
            <version>1.44.0</version>
        </dependency>
        <!-- 提供Redis连接池 -->
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-pool2</artifactId>
        </dependency>
  1. yml配置redis

注:spring3 的redis配置在 spring/data 下

java 复制代码
srping:
  # Redis 配置
  data:
    redis:
      host: localhost
      port: 6379
      password:
  1. 启动redis
java 复制代码
redis-server

无需修改代码,可以看到登录后 satoken会自动将数据存储在redis中

SaToken基础注解鉴权

  1. sa-token 接口设置权限就是使用 注解
java 复制代码
@SaCheckLogin // 登录才能访问
@SaCheckPermission("user:create")  // 权限标识鉴权
@SaCheckRole("super-admin") // 角色标识鉴权

// 注解式鉴权:只要具有其中一个权限即可通过校验
@SaCheckPermission(value = {"user-add", "user-all", "user-delete"}, mode = SaMode.OR) 
java 复制代码
    @ApiOperationSupport(order = 4)
    @SaCheckLogin // 登录才能访问
    @Operation(summary = "获取当前登录用户信息")
    @GetMapping("/get/login")
    public BaseResponse<LoginUserVO> getLoginUser() {
        User loginUser = userService.getLoginUser();
        return ResultUtils.success(userService.getLoginUserVO(loginUser));
    }
  1. 实现 StpInterface 接口,在用户登录时 获取其权限并注入

重写 getPermissionList 和 getRoleList 方法,在用户登录时

sa-token 会调用这两个方法,注入用户角色和权限

我这里读取数据库,可以写死数据进行模拟

java 复制代码
@Component
public class StpInterfaceConfig implements StpInterface {

    @Resource
    private UserService userService;

    @Resource
    private RoleService roleService;

    /**
     *  一个账号拥有的权限码 集合
     *
     * @return
     */
    @Override
    public List<String> getPermissionList(Object loginId, String loginType) {
        Long userId = Long.parseLong(loginId.toString());
        String roleKey = userService.getRoleKeyByUserId(userId);
        return roleService.getPermKeyListByRoleKey(roleKey);
    }

    /**
     *  一个账号拥有的角色
     *
     * @return
     */
    @Override
    public List<String> getRoleList(Object loginId, String loginType) {
        Long userId = Long.parseLong(loginId.toString());
        return List.of(userService.getRoleKeyByUserId(userId));
    }
}
  1. 只要注入的角色/权限与 注解对应,即可访问相应接口

Sa-Token + Mysql 鉴权

这部分是数据库相关内容,之前sa-token相关配置已经完成

这部分主要是sql设计、相关服务、接口

数据库设计

设计 用户表、角色表、权限表、港口表(部门)

java 复制代码
-- 用户表
CREATE TABLE IF NOT EXISTS user (
    id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,
    user_account VARCHAR(256) NOT NULL COMMENT '账号',
    user_password VARCHAR(512) NOT NULL COMMENT '密码',
    user_name VARCHAR(256) NULL COMMENT '用户昵称',
    user_avatar VARCHAR(1024) NULL COMMENT '用户头像',
    user_profile VARCHAR(512) NULL COMMENT '用户简介',
#     department_id BIGINT NULL COMMENT '部门id',
    role_key VARCHAR(256) NOT NULL COMMENT '绑定的角色标识',
    edit_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '编辑时间',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
    update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',
    UNIQUE KEY uk_user_account (user_account),
    INDEX idx_user_name (user_name),
    INDEX idx_role_key (role_key)
) COMMENT '用户表' COLLATE = utf8mb4_unicode_ci;

-- 港口表
CREATE TABLE IF NOT EXISTS port (
   id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,
   port_name VARCHAR(256) NOT NULL COMMENT '港口名称',
   port_type TINYINT NOT NULL COMMENT '港口类型: 0=普通港口 1=平台',
   create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
   update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',
   UNIQUE KEY uk_port_name (port_name)
) COMMENT '港口表' COLLATE = utf8mb4_unicode_ci;

-- 角色表
CREATE TABLE IF NOT EXISTS sa_role (
   id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,
   role_key VARCHAR(256) NOT NULL COMMENT '角色标识(唯一,不可修改)',
   role_name VARCHAR(256) NOT NULL COMMENT '角色名称',
   role_level INT NOT NULL COMMENT '角色等级(越小权限越大)',
   perm_key_list TEXT COMMENT '权限key列表(JSON数组格式)',
   port_id BIGINT NOT NULL COMMENT '绑定的港口id',
   status TINYINT DEFAULT 1 NOT NULL COMMENT '状态:1启用 0停用',
   create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
   update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',
   UNIQUE KEY uk_role_key (role_key),
   INDEX idx_port_id (port_id)
) COMMENT '角色表' COLLATE = utf8mb4_unicode_ci;

-- 权限表 (菜单/接口二合一)
CREATE TABLE IF NOT EXISTS sa_permission (
     id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,
     parent_id BIGINT DEFAULT 0 NOT NULL COMMENT '父ID (0=根节点)',
     perm_key VARCHAR(256) NOT NULL COMMENT '权限标识(唯一,接口权限/菜单路由)',
     perm_name VARCHAR(256) NOT NULL COMMENT '权限名称',
     type TINYINT NOT NULL COMMENT '类型:0目录 1菜单 2接口',
     request_path VARCHAR(512) NULL COMMENT '请求路径(接口用) 或 前端路由',
     icon VARCHAR(128) NULL COMMENT '前端图标',
     sort INT DEFAULT 0 NOT NULL COMMENT '排序字段',
     create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
     update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
     is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',
     UNIQUE KEY uk_perm_key (perm_key),
     INDEX idx_parent_id (parent_id),
     INDEX idx_type (type)
) COMMENT '权限表' COLLATE = utf8mb4_unicode_ci;
  1. 用户表只绑定角色表 (role_key 属性对应 角色表的角色标识)

一个用户只能拥有一个角色,方便管理 n :1

  1. 角色表绑定 港口表 (port_id属性对应港口表id) n :1
  2. 角色表绑定 权限表列表 (perm_key_list 属性存储 perm_key权限标识数组)

不使用 角色-权限表 的原因是存储为json数组,方便存取,不用大规模读取表

java 复制代码
  "permKeyList": [
    "sys",
    "user",
    "user:delete"
  ]
  1. 权限表 存储两种类型:接口 和 菜单

接口权限 用于satoken接口级鉴权

菜单权限 用于前端路由使用,都支持前端管理员分配权限

  1. 港口表(部门表) 分为平台和港区

平台管理员可以管理全平台

港区管理员只能管理港区内相关人员、内容

业务开发

逻辑删除与唯一索引冲突

MyBatis 设置 is_delete 逻辑删除可能与 表的唯一依赖出现冲突

冲突的产生

● 假设你有一个 user 表,其中 username 字段有唯一索引。

● 现有用户 JohnDoe(username = 'johndoe', is_deleted = 0)。

● 当你"删除"这个用户后,这条记录变为 username = 'johndoe', is_deleted = 1。

● 现在,如果你想重新创建一个用户名为 johndoe 的新用户并执行插入操作,数据库会尝试插入 ('johndoe', 0)。

● 然而,唯一索引检查时会发现表中已经存在一条 ('johndoe', 1) 的记录。由于唯一索引并不感知 is_deleted 字段的业务逻辑,它只检查 username 的值,因此 'johndoe' 已经存在,插入操作就会违反唯一索引约束,导致报错。

由于我的代码中每个表设置了唯一索引,故每个表 有数据删除后都会 出现唯一索引冲突

原始代码:

  1. 数据库设计

如下表设计:port_name 字段设置了唯一索引

java 复制代码
-- 港口表
CREATE TABLE IF NOT EXISTS port (
   id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,
   port_name VARCHAR(256) NOT NULL COMMENT '港口名称',
   port_type TINYINT NOT NULL COMMENT '港口类型: 0=普通港口 1=平台',
   create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
   update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',
   UNIQUE KEY uk_port_name (port_name)
) COMMENT '港口表' COLLATE = utf8mb4_unicode_ci;
  1. MyBatis 设置逻辑删除

原来:直接使用 removeById() MyBatis会自动调用将逻辑删除字段设置为 1

java 复制代码
    @TableLogic
    private Integer isDelete;


// yml配置
mybatis-plus:
  configuration:
    map-underscore-to-camel-case: false
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      logic-delete-field: isDelete # 全局逻辑删除的实体字段名
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
java 复制代码
// 无需全局配置
// MyBatis Felx 设置逻辑删除  默认 0 1 
    @Column(value = "is_delete", isLogicDelete = true)
    private Integer isDelete;
解决方案

方案一:

若使用MyBatis-plus 可以直接配置避免冲突,比较高效解决方案

  1. 原来的删除值为 1,可以将删除值设置为 null 空值
  2. 修改 is_delete 字段,删去 not null, 允许为空

方案二:

由于我使用的是 MyBatis Flex,参考官方文档,其值的设置不能直接配置

需要通过实现 FlexGlobalConfig globalConfig = FlexGlobalConfig.getDefaultConfig(); 来进行配置

尝试使用该类后发现 不能传入空值,也就是不支持将删除后的值设置为空

MyBatisFlex 不支持配置空值,业务开发大半不可能更换框架,只能另辟蹊径

若MySQL版本在** 8.0.13+ **,可以修改表结构使用函数索引

函数索引 (IF(is_delete = 0, 0, NULL)) 的工作原理:

  1. 当 is_delete = 0(未删除)时,索引值为 0
  2. 当 is_delete = 1(已删除)时,索引值为 NULL
  3. 在 MySQL 中,唯一索引允许有多个 NULL 值,但不允许多个相同的非 NULL 值
  4. 因此,这个索引确保了:
    ○ 未删除的记录中,user_account 必须是唯一的(因为索引值为 (user_account, 0))
    ○ 已删除的记录可以有多个相同的 user_account(因为索引值为 (user_account, NULL),而多个 NULL 值不违反唯一约束)
java 复制代码
-- 港口表
CREATE TABLE IF NOT EXISTS port (
   id BIGINT AUTO_INCREMENT COMMENT 'id' PRIMARY KEY,
   port_name VARCHAR(256) NOT NULL COMMENT '港口名称',
   port_type TINYINT NOT NULL COMMENT '港口类型: 0=普通港口 1=平台',
   create_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
   update_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
   is_delete TINYINT DEFAULT 0 NOT NULL COMMENT '是否删除',
   -- 使用函数索引替代普通唯一索引
   UNIQUE INDEX uk_port_name_is_delete (port_name, (IF(is_delete = 0, 0, NULL)))
) COMMENT '港口表' COLLATE = utf8mb4_unicode_ci;

如此,也能有效解决 唯一索引冲突问题!

相关推荐
L.EscaRC35 分钟前
Redisson在Spring Boot中的高并发应用解析
java·spring boot·后端
Naylor1 小时前
玩转kafka
spring boot·kafka
摇滚侠1 小时前
Spring Boot3零基础教程,StreamAPI 介绍,笔记98
java·spring boot·笔记
摇滚侠1 小时前
Spring Boot3零基础教程,StreamAPI 的基本用法,笔记99
java·spring boot·笔记
codingPower2 小时前
升级mybatis-plus导致项目启动报错: net.sf.jsqlparser.statement.select.SelectBody
java·spring boot·maven·mybatis
刘一说4 小时前
深入理解 Spring Boot Web 开发中的全局异常统一处理机制
前端·spring boot·后端
智_永无止境4 小时前
Spring Boot全局异常处理指南
java·spring boot
屹奕4 小时前
基于EasyExcel实现Excel导出功能
java·开发语言·spring boot·excel
whltaoin5 小时前
【Spring Boot 注解解析】Bean 生命周期注解深度解析:@PostConstruct 与 @PreDestroy 面试高频考点 + 实战案例
java·spring boot·面试·bean生命周期
蒲公英源码5 小时前
教务管理系统源码
java·mysql