【鉴权架构】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;

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

相关推荐
weixin_441455264 小时前
Mysql MVCC
数据库·mysql
wxin_VXbishe4 小时前
基于SpringBoot的天天商城管理系统的设计与现-计算机毕业设计源码79506
java·c++·spring boot·python·spring·django·php
奥尔特星云大使4 小时前
MySQL快速构建主从(基于GTID)
数据库·mysql·主从复制
小园子的小菜4 小时前
MySQL ORDER BY 深度解析:索引排序规则与关键配置参数阈值
数据库·mysql
惊鸿一博5 小时前
mysql_page pagesize 如何实现游标分页?
数据库·mysql
不学习何以强国6 小时前
Cool Unix + OpenAuth.Net 实现一款校园小程序的开发
mysql·前端框架·asp.net
.格子衫.11 小时前
Spring Boot 原理篇
java·spring boot·后端
多云几多12 小时前
Yudao单体项目 springboot Admin安全验证开启
java·spring boot·spring·springbootadmin
摇滚侠13 小时前
Spring Boot 3零基础教程,Spring Intializer,笔记05
spring boot·笔记·spring