搭建一个基于RBAC权限的后台管理系统(服务端)的过程

650 阅读7分钟

作为一个前端,想要系统性的学习搭建一个基于RBAC权限的后台管理系统,于是就有了这个系列的文章,系列文章旨在记录学习一个后台管理系统的过程,非构建一个可直接部署于生产环境的商业级应用,配套前端工程见Github

创建一个SptingBoot项目

首先没有选择单体项目,直接选择多模块项目入手,多模块还不是微服务。

创建项目很简单,参考文章IntelliJ IDEA 构建 Spring Boot 多模块 Maven 项目的方法比较

  1. 目录机构如下
Demo-end/  <!-- 项目根目录 -->
├── .gitignore  <!-- Git忽略文件配置 -->
├── demo-api/  <!-- 业务API接口模块 -->
├── demo-common/  <!-- 通用工具模块 -->
├── demo-framework/  <!-- 框架模块 -->
├── demo-generator/  <!-- 代码生成器模块 -->
├── demo-system/  <!-- 系统管理模块(用户管理,角色管理等) -->
├── doc/  <!-- 文档目录 -->
└── pom.xml  <!-- Maven项目配置文件 -->
  1. demo-api模块新建应用启动类
/**
 * 后台管理系统启动类
 */
@SpringBootApplication
public class RbacAdminApplication {
    public static void main(String[] args) {
        SpringApplication.run(RbacAdminApplication.class, args);
    }
}
  1. demo-api模块新建controller测试类,成功启动项目后浏览器访问hello接口返回数据,项目创建成功
/**
 * 测试控制器
 */
@RestController
public class HelloWorldController {

    /**
     * 测试接口
     * @return 测试数据
     */
    @GetMapping("/hello")
    public Map<String, Object> hello() {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 200);
        result.put("message", "Hello World!");
        result.put("data", "RBAC后台管理系统测试成功");
        return result;
    }
}

进行一些简单初始化

项目已经创建完成,在正式进入开发前先做一些基础工作:logback配置,日志切面配置、Lombok测试、Hibernate Validator测试、SpringDoc配置、添加统一响应和用异常处理机制

配置日志

可参考相关文章:Java应用日志管理:日志库、日志门面及其在SpringBoot中的整合策略

  1. 配置logback日志,并测试配置成功

需求:手动添加的日志和自动记录的接口的日志可以存储到文件中,区分操作系统

  • 添加logbck-spring.yml配置appendar和不同环境下的日志输出规则。

logback配置文件使用条件表达式需要添加 janino依赖

<dependency>
    <groupId>org.codehaus.janino</groupId>
    <artifactId>janino</artifactId>
    <version>${janino.version}</version>
</dependency>
<?xml version="1.0" encoding="UTF-8"?>
<!-- Logback配置文件 - 用于控制日志的输出行为、格式和存储方式 -->
<configuration>
    <!-- 从Spring配置文件中读取基础包名配置 -->
    <springProperty scope="context" name="basePackage" source="app.base-package"/>

    <!-- 直接读取应用名称 -->
    <springProperty scope="context" name="appName" source="spring.application.name" defaultValue="rbac-admin"/>
    
    <!-- 根据操作系统类型选择日志路径 -->
    <if condition='property("os.name").toLowerCase().contains("windows")'>
        <then>
            <property name="log.path" value="D:\${appName}\logs"/>
        </then>
        <else>
            <property name="log.path" value="/var/log/${appName}"/>
        </else>
    </if>
    <!-- 日志输出格式 - 定义日志的显示格式,包含以下元素:
         %d{yyyy-MM-dd HH:mm:ss.SSS} - 日期时间,精确到毫秒
         [%thread] - 输出日志的线程名
         %-5level - 日志级别,用5个字符右对齐(如:INFO、ERROR)
         %logger{20} - 输出日志的logger名,最大20个字符
         [%method,%line] - 输出日志的方法名和行号
         %msg%n - 日志消息和换行符
    -->
    <property name="log.pattern" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{20} - [%method,%line] - %msg%n" />
    
    <!-- 控制台输出 - 将日志输出到控制台,主要用于开发调试阶段查看实时日志 -->
    <appender name="console" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${log.pattern}</pattern>
            <charset>UTF-8</charset>
        </encoder>
    </appender>
    
    <!-- 系统日志输出 - 将INFO级别的日志输出到文件,用于记录系统的正常运行信息 -->
    <appender name="file_info" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${log.path}/info.log</file>
        <!-- 循环政策:基于时间创建日志文件 - 每天创建一个新的日志文件,避免单个文件过大 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 日志文件名格式 -->
            <fileNamePattern>${log.path}/info.%d{yyyy-MM-dd}.log</fileNamePattern>
            <!-- 日志最大的历史 60天 -->
            <maxHistory>60</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>${log.pattern}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <!-- 过滤的级别 - 此appender只接收INFO级别的日志 -->
            <level>INFO</level>
            <!-- 匹配时的操作:接收(记录)- 当日志级别等于INFO时,接收并记录日志 -->
            <onMatch>ACCEPT</onMatch>
            <!-- 不匹配时的操作:拒绝(不记录)- 当日志级别不是INFO时(如DEBUG、WARN、ERROR等),拒绝记录 -->
            <onMismatch>DENY</onMismatch>
        </filter>
    </appender>
    
    <!-- 系统错误日志输出 - 将ERROR级别的日志单独输出到错误日志文件,便于快速定位系统错误 -->
    <appender name="file_error" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${log.path}/error.log</file>
        <!-- 循环政策:基于时间创建日志文件 - 每天创建一个新的日志文件,避免单个文件过大 -->
        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
            <!-- 日志文件名格式 -->
            <fileNamePattern>${log.path}/error.%d{yyyy-MM-dd}.log</fileNamePattern>
            <!-- 日志最大的历史 60天 -->
            <maxHistory>60</maxHistory>
        </rollingPolicy>
        <encoder>
            <pattern>${log.pattern}</pattern>
            <charset>UTF-8</charset>
        </encoder>
        <filter class="ch.qos.logback.classic.filter.LevelFilter">
            <!-- 过滤的级别 - 此appender只接收ERROR级别的日志 -->
            <level>ERROR</level>
            <!-- 匹配时的操作:接收(记录)- 当日志级别等于ERROR时,接收并记录日志 -->
            <onMatch>ACCEPT</onMatch>
            <!-- 不匹配时的操作:拒绝(不记录)- 当日志级别不是ERROR时(如DEBUG、INFO、WARN等),拒绝记录 -->
            <onMismatch>DENY</onMismatch>
        </filter>
    </appender>
    
    <!-- 异步输出 - 通过异步方式处理日志,提高系统性能,减少日志记录对主业务的影响 -->
    <appender name="async_info" class="ch.qos.logback.classic.AsyncAppender">
        <!-- 不丢失日志,默认的,如果队列的80%已满,则会丢弃TRACE、DEBUG、INFO级别的日志 -->
        <!-- 设置为0表示不丢弃任何日志,即使队列已满也会阻塞而不是丢弃日志 -->
        <discardingThreshold>0</discardingThreshold>
        <!-- 更改默认的队列深度,该值会影响性能,默认值为256 -->
        <!-- 队列越大,可以缓存的日志越多,但会占用更多内存 -->
        <queueSize>512</queueSize>
        <!-- 添加附加的appender,最多只能添加一个 -->
        <!-- 此处引用file_info appender,表示异步处理INFO级别的日志 -->
        <appender-ref ref="file_info"/>
    </appender>
    
    <!-- 异步错误日志输出 - 单独处理ERROR级别的日志,确保错误日志不会丢失 -->
    <appender name="async_error" class="ch.qos.logback.classic.AsyncAppender">
        <!-- 设置为0表示即使队列满了也不丢弃错误日志,确保所有错误都被记录 -->
        <discardingThreshold>0</discardingThreshold>
        <!-- 队列大小设置为512,提供足够的缓冲空间处理突发的错误日志 -->
        <queueSize>512</queueSize>
        <!-- 引用file_error appender,表示异步处理ERROR级别的日志 -->
        <appender-ref ref="file_error"/>
    </appender>
    
    <!-- 开发环境 - 开发阶段使用的日志配置,提供更详细的日志信息辅助开发调试 -->
    <springProfile name="dev">
        <!-- root是默认的日志配置,level="info"表示默认记录INFO及以上级别(INFO、WARN、ERROR)的日志 -->
        <root level="info">
            <!-- 开发环境下同时输出到控制台和文件,方便开发调试 -->
            <appender-ref ref="console" />
            <appender-ref ref="async_info" />
            <appender-ref ref="async_error" />
        </root>
        <!-- 项目自身的包设置为debug级别,可以看到更详细的调试信息 -->
        <logger name="${basePackage}" level="debug" />
    </springProfile>
    
    <!-- 测试环境 - 用于测试阶段的日志配置,保留必要的信息用于问题分析 -->
    <springProfile name="test">
        <!-- 测试环境下默认记录INFO及以上级别的日志 -->
        <root level="info">
            <!-- 测试环境同时输出到控制台和文件,便于测试人员查看日志 -->
            <appender-ref ref="console" />
            <appender-ref ref="async_info" />
            <appender-ref ref="async_error" />
        </root>
        <!-- 项目自身的包设置为info级别,只记录必要的信息,不记录debug信息 -->
        <logger name="${basePackage}" level="info" />
    </springProfile>
    
    <!-- 生产环境 - 用于线上运行的日志配置,只记录重要信息,减少日志量,提高性能 -->
    <springProfile name="prod">
        <!-- 生产环境下默认记录INFO及以上级别的日志 -->
        <root level="info">
            <!-- 生产环境同样配置了控制台输出,但实际部署时通常只关注文件日志 -->
            <appender-ref ref="console" />
            <appender-ref ref="async_info" />
            <appender-ref ref="async_error" />
        </root>
        <!-- 项目自身的包设置为warn级别,只记录警告和错误,减少日志数量,提高系统性能 -->
        <!-- 这意味着只有WARN和ERROR级别的日志会被记录,而INFO和DEBUG级别的日志会被忽略 -->
        <logger name="${basePackage}" level="warn" />
    </springProfile>
</configuration>
  • 添加测试类。项目启动成功后会初始测试类里的测试日志,同时也会输出日志到文件
/**
 * Logback日志框架测试类
 * 用于验证日志配置是否生效
 * 这个测试类已经配置为Spring组件(@Component)
 * 当SpringBoot应用启动时会自动执行run方法
 * 只需要确保:
 *  1. 项目中包含logback相关依赖(spring-boot-starter-logging)
 *  2. resources目录下配置logback-spring.xml或application.properties中配置日志级别
 *  3. 直接运行SpringBoot主应用类即可看到不同级别的日志输出
 *  4. 默认情况下TRACE和DEBUG级别不会显示,需要在配置文件中调整日志级别
 */
@Component
public class LogbackTest implements CommandLineRunner {

    private static final Logger logger = LoggerFactory.getLogger(LogbackTest.class);

    @Override
    public void run(String... args) {
        // 测试不同级别的日志
        logger.trace("这是 TRACE 级别的日志");
        logger.debug("这是 DEBUG 级别的日志");
        logger.info("这是 INFO 级别的日志");
        logger.warn("这是 WARN 级别的日志");
        logger.error("这是 ERROR 级别的日志");
        
//        // 测试带异常的日志
//        try {
//            // 模拟一个异常
//            int result = 1 / 0;
//        } catch (Exception e) {
//            logger.error("发生异常:", e);
//        }
        
        logger.info("日志框架配置测试完成!");
    }
}
  1. 添加日志切面,记录请求日志

这一步要用到spring-boot-starter-aop关于aop的知识点就先不介绍了。重新启动项目,访问测试接口查看控制台和文件日志

/**
 * 操作日志记录处理切面
 * 用于记录每个接口的请求参数、响应结果、执行时间等信息
 */
@Aspect
@Component
@Order(1)
@Slf4j
public class LogAspect {

    /**
     * 配置切入点 - 所有controller包下的所有方法
     */
    @Pointcut("execution(* me.parade.controller..*.*(..))")
    public void logPointCut() {
    }

    /**
     * 环绕通知,记录请求的各项信息
     *
     * @param joinPoint 切点
     * @return 原方法返回值
     * @throws Throwable 执行原方法时可能抛出的异常
     */
    @Around("logPointCut()")
    public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("========== 日志切面开始 ==========");
        long startTime = System.currentTimeMillis();
        
        // 获取请求相关信息
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes != null ? attributes.getRequest() : null;
        
        // 获取方法签名
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        
        // 记录请求开始日志
        if (request != null) {
            log.info("========== 请求开始 ==========");
            log.info("请求URL: {} {}", request.getMethod(), request.getRequestURI());
            log.info("请求方法: {}.{}", signature.getDeclaringTypeName(), method.getName());
            log.info("请求IP: {}", getIpAddress(request));
            log.info("请求参数: {}", getRequestParams(joinPoint));
        }
        
        // 执行原方法
        Object result;
        try {
            result = joinPoint.proceed();
            // 记录正常响应日志
            long executionTime = System.currentTimeMillis() - startTime;
            log.info("响应结果: {}", result);
            log.info("执行时间: {}ms", executionTime);
            log.info("========== 请求结束 ==========\n");
        } catch (Throwable e) {
            // 记录异常日志
            long executionTime = System.currentTimeMillis() - startTime;
            log.error("请求异常: {}", e.getMessage());
            log.error("执行时间: {}ms", executionTime);
            log.error("========== 请求异常结束 ==========\n");
            throw e;
        }
        
        return result;
    }
    
    /**
     * 获取请求参数字符串
     *
     * @param joinPoint 切点
     * @return 请求参数字符串
     */
    private String getRequestParams(JoinPoint joinPoint) {
        Object[] args = joinPoint.getArgs();
        if (args == null || args.length == 0) {
            return "无参数";
        }
        
        // 过滤掉HttpServletRequest、HttpServletResponse、MultipartFile等特殊参数
        List<Object> filteredArgs = Arrays.stream(args)
                .filter(arg -> !(arg instanceof HttpServletRequest || 
                                arg instanceof HttpServletResponse ||
                                arg instanceof MultipartFile))
                .collect(Collectors.toList());
        
        return filteredArgs.toString();
    }
    
    /**
     * 获取请求IP地址
     *
     * @param request HTTP请求
     * @return IP地址
     */
    private String getIpAddress(HttpServletRequest request) {
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_CLIENT_IP");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("HTTP_X_FORWARDED_FOR");
        }
        if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }
}

Lombok测试

  1. 添加依赖

<!-- lombok 依赖,仅编译期生效,生产包不包含 -->
<dependency>
  <groupId>org.projectlombok</groupId>
  <artifactId>lombok</artifactId>
  <scope>provided</scope>
</dependency>
  1. 添加实体
@Data
public class LombokDemo {
    private Long id;
    private String name;
}
  1. 添加测试类
public class LombokDemoTest {
    @Test
    void testLombokGetterSetter(){
        LombokDemo demo = new LombokDemo();
        demo.setId(1L);
        demo.setName("测试");
        assertEquals(1L, demo.getId());
        assertEquals("测试", demo.getName());
    }
}

Hibernate Validator测试

  1. 添加测试Bean,加上验证注解
@Data
public class UserCreateParam {
    @NotBlank(message = "用户名不能为空")
    private String username;

    @NotBlank(message = "邮箱不能为空")
    @Email(message = "邮箱格式不正确")
    private String email;

    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 20, message = "密码长度必须在6-20位之间")
    private String password;
}
  1. 添加单测类验证Hibernate Validator校验效果

访问接口参数为空会报400错误,但是没有具体的报错信息。这个时候就

需要使用使用全局异常处理机制

/**
     * 创建用户接口
     * @param param 用户创建参数
     * @return 创建结果
     */
    @Operation(summary = "创建用户", description = "创建一个新用户,需要提供用户名等基本信息")
    @PostMapping("/user/create")
    public String createUser(@Parameter(description = "用户创建参数") @RequestBody @Valid UserCreateParam param) {
        logger.info("用户创建请求: {}", param.getUsername());

        // 正常情况返回成功
        Map<String, Object> result = new HashMap<>();
        result.put("code", 200);
        result.put("message", "成功");
        result.put("data", "添加成功");
        return result;
    }

添加统一响应和用异常处理机制

有统一的响应格式后,前端处理起来会很方便,大部分的格式都是code,msg,data,此外为了避免使用magic number,把结果状态码定义常量

public interface ResultCode {
    Integer SUCCESS = 200;
    Integer ERROR = 500;
    Integer VALIDATE_FAILED = 400;
    Integer UNAUTHORIZED = 401;
    Integer FORBIDDEN = 403;
    Integer NOT_FOUND = 404;
}
/**
 * 统一响应结果类
 */
@Data
public class Result<T> {
    private Integer code;

    private String message;

    private T data;

    public static <T> Result<T> success() {
        return success(null);
    }
    
    public static <T> Result<T> success(T data) {
        return success("操作成功", data);
    }

    public static <T> Result<T> success(String message, T data) {
        Result<T> result = new Result<>();
        result.setCode(ResultCode.SUCCESS);
        result.setMessage(message);
        result.setData(data);
        return result;
    }

    public static <T> Result<T> error() {
        return error(ResultCode.ERROR, "操作失败");
    }

    public static <T> Result<T> error(String message) {
        return error(500, message);
    }

    public static <T> Result<T> error(Integer code, String message) {
        Result<T> result = new Result<>();
        result.setCode(code);
        result.setMessage(message);
        return result;
    }
}

统一异常需要@RestControllerAdvice结合@ExceptionHandler轻松搞定


@RestControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    /**
     * 处理参数校验异常(@RequestBody参数校验)
     * 这里使用ResponseEntity二次封装是为了规范Http的状态码,毕竟Result的code只是自己定义的code,不是HttpStatus
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Result<Void>> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        BindingResult bindingResult = e.getBindingResult();
        String message = getBindingResultErrorMessage(bindingResult);
        logger.warn("参数校验失败: {}", message);
        Result<Void> response = Result.error(ResultCode.VALIDATE_FAILED, message);
        return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST);
    }
    //....省略其他异常处理
}

优化controler响应结果:每个controller里的每个请求都用Result二次封装返回结果,有时候也挺麻烦的,可以考虑使用@RestControllerAdvice自动对结果处理

/**
 * 统一响应注解
 * <p>
 * 标记需要被统一响应拦截器处理的控制器方法
 * 可以应用在类或方法上,应用在类上表示该类的所有方法都需要统一响应处理
 * </p>
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ResponseResult {
}
/**
 * 统一响应拦截器
 * <p>
 * 用于自动将控制器返回值包装成统一的响应格式,避免每个控制器方法都手动包装
 * </p>
 */
@RestControllerAdvice(basePackages = {"me.parade"})
public class ResponseAdvice implements ResponseBodyAdvice<Object> {
    
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        // 如果返回值已经是Result类型,则不需要处理
        if (returnType.getParameterType().equals(Result.class)) {
            return false;
        }
        
        // 检查类或方法是否标记了@ResponseResult注解
        boolean hasClassAnnotation = returnType.getContainingClass().isAnnotationPresent(ResponseResult.class);
        boolean hasMethodAnnotation = returnType.getMethod() != null && 
                                     returnType.getMethod().isAnnotationPresent(ResponseResult.class);
        
        return hasClassAnnotation || hasMethodAnnotation;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                  Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                  ServerHttpRequest request, ServerHttpResponse response) {
        // 如果返回值为null,则返回成功响应
        if (body == null) {
            return Result.success();
        }
        
        // 如果返回值已经是Result类型,则直接返回
        if (body instanceof Result) {
            return body;
        }
        
        // 特殊处理String类型返回值
        // 因为StringHttpMessageConverter会直接将返回值转换为字符串,而不是JSON
        if (returnType.getParameterType().equals(String.class)) {
            try {
                // 设置Content-Type为application/json
                response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
                // 将Result对象转换为JSON字符串
                return objectMapper.writeValueAsString(Result.success(body));
            } catch (JsonProcessingException e) {
                throw new RuntimeException("将对象转换为JSON字符串时发生错误", e);
            }
        }
        
        // 将返回值包装成统一的Result格式
        return Result.success(body);
    }
}

测试封装效果,完成每一小步之后,都要测试

集成Mybatis-plus

创建表和实体

参照ruoyi等开源框架先创建本地数据库,主要表有user,role,menu,dept,user_role,role_menu

创建实体类(可使用BaseEntity)来管理一些共性字段create_user,update_time

添加依赖和配置文件

 <!-- 数据库相关依赖版本 -->
 <mysql.version>8.0.28</mysql.version>
 <druid.version>1.2.16</druid.version>
 <mybatis-plus.version>3.5.11</mybatis-plus.version>
 <mybatis-plus-generator.version>3.5.11</mybatis-plus-generator.version>

<!-- MySQL驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>${mysql.version}</version>
</dependency>
<!-- Druid连接池 -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid-spring-boot-starter</artifactId>
    <version>${druid.version}</version>
</dependency>
<!-- MyBatis-Plus 
<type>pom</type>表示这是一个 POM 类型的依赖(不是 jar 包),只用于依赖管理。
<scope>import</scope>只有在 <dependencyManagement> 里才允许使用 import,表示“导入”另一个 POM 的依赖管理配置。
这样配置后,mybatis-plus-bom 中声明的所有依赖(如 mybatis-plus-core、mybatis-plus-boot-starter 等)都可以在你的子模块中直接引用,无需再单独指定版本号,统一由 BOM 管理。
-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-bom</artifactId>
    <version>${mybatis-plus.version}</version>
    <type>pom</type>
    <scope>import</scope>
</dependency>

数据库连接配置文件参考ruoyi等开源库😀,考虑区分开发环境和生产环境。重新启动项目,

MybatisPlus配置文件

@Configuration
@MapperScan("me.parade.**.mapper")
public class MyBatisPlusConfig {

    private final Environment environment;
    //构造函数注入,取代@Autowired
    public MyBatisPlusConfig(Environment environment) {
        this.environment = environment;
    }

    /**
     * 配置MybatisPlus拦截器
     */
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }

    /**
     * 自动填充处理器
     */
    @Bean
    public MetaObjectHandler metaObjectHandler() {
        return new MetaObjectHandler() {
            @Override
            public void insertFill(MetaObject metaObject) {
                this.strictInsertFill(metaObject, "createTime", LocalDateTime::now, LocalDateTime.class);
                this.strictInsertFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
                // TODO: 当实现了用户认证后,替换为实际的用户ID
//                this.strictInsertFill(metaObject, "createBy", () -> 1L, Long.class);
//                this.strictInsertFill(metaObject, "updateBy", () -> 1L, Long.class);
            }

            @Override
            public void updateFill(MetaObject metaObject) {
                this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class);
                // TODO: 当实现了用户认证后,替换为实际的用户ID
//                this.strictUpdateFill(metaObject, "updateBy", () -> 1L, Long.class);
            }
        };
    }
}

测试集成效果,如果没报错证明数据库连接成功

集成SpringSecurity和jjwt

首先什么事jwt和jjwt,参考文章jwt和jjwt解读以及应用

添加依赖

<!-- Spring Security依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- JWT相关依赖 -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <scope>runtime</scope>
</dependency>

准备相关工具和服务

要实现完整的认证和授权流程,在正式进入开发之前,要准备一些工具,

  1. jwt工具类(主要负责生成token和解析token),
  2. Secuity配置
  • 配置放行请求(无需登录即可访问的公开接口)
  • 未认证异常处理器
  • 无权限访问异常处理器
  • csrf
  • session
  • 跨域
  • jwt验证和解析过滤器
  • 认证管理器

至于jwt工具类和SecurityConfig和jwt过滤器的实现可以参考ruioyi或者项目源码,我也是让AI生成的,关在在于过程

有了基础设置后,就可以开始开发认证接口了

开发认证和授权

认证

public LoginResponse login(String username, String password) {
   try {
       // 进行身份验证
       //1. new UsernamePasswordAuthenticationToken(username, password)创建一个“未认证”的 Authentication 对象包含principal:用户名,credentials:密码,authorities:null示未认证
       //
       Authentication authentication = authenticationManager.authenticate(
               new UsernamePasswordAuthenticationToken(username, password)
       );
       // 认证成功后,将认证信息存入SecurityContext
       //设置 SecurityContext,是为了让本次请求线程能获取到认证用户信息,便于后续业务处理。
       //典型场景:登录后立即返回用户详情、记录登录日志、触发审计等。
       SecurityContextHolder.getContext().setAuthentication(authentication);
       // 生成JWT令牌
       UserDetails userDetails = (UserDetails) authentication.getPrincipal();
       String accessToken = jwtTokenUtil.generateAccessToken(userDetails);
       String refreshToken = jwtTokenUtil.generateRefreshToken(userDetails);
       // 构建并返回登录响应
       return LoginResponse.builder()
               .token(accessToken)
               .refreshToken(refreshToken)
               .tokenType(jwtConfig.getTokenPrefix().trim())
               .expiresIn(jwtConfig.getAccessTokenExpiration())
               .build();
   }catch (Exception e){
       throw new BusinessException(e.getMessage());
   }
}

授权

首先要保证接口没有被放行,接口会经过jwt过滤器,在过滤器里会根据jwt信息解析token的有效性和合法性,没有问题的话会在把用户信息(包括权限信息)一起存到上下文中

在接口使用@PreAuthorize或者自定义注解进行权限校验即可

写在最后

关于登录

可以先写一个测试类,产生一个加密密码,然后复制到数据库,前端就可以使用用户名和密码进行登录了

@SpringBootTest
public class PasswordEncoderTest {

    @Autowired
    private BCryptPasswordEncoder passwordEncoder;
    @Test
    public void generatePassword() {
        String rawPassword = "123456";
        String encodedPassword = passwordEncoder.encode(rawPassword);
        System.out.println("Encoded password: " + encodedPassword);

        // 验证密码是否匹配
        boolean matches = passwordEncoder.matches(rawPassword, encodedPassword);
        System.out.println("Password matches: " + matches);
    }
}

至于业务接口的编写(菜单管理,角色分配,菜单分配等)就不涉及到大量的框架学习,基本是MybatisPlus和java的语法的熟练运用

虽然文档中没有涉及到大量代码,但是源码中有大量的文档和详细的注释,按照这个过程,经历过古法手打自己完成的项目,相信才会记忆深刻,与君共勉