
2. 先说清楚这套后台管理系统demo是干嘛的后台管理系统大概是每个做Java后端的人都会触碰的东西无论你是刚毕业找工作还是公司内部要快速搭一套运营后台Spring Boot 3.X 权限框架 数据库这套组合几乎是绕不开的标配。这个demo项目的定位很简单不搞花活不做微服务不含分布式中间件就用最主流、最稳妥的技术栈在Spring Boot 3.X版本下实现一个具备登录认证、用户管理、角色管理、菜单权限的后台核心骨架。说白了它解决的是一个很实在的问题当你想在新项目里用Spring Boot 3.X又不想从零研究Jakarta EE迁移、Spring Security 6的写法变化、MyBatis-Plus的适配问题时有一套直接能跑、能改、能扩展的底子可以抄。适合看这篇文章的人也很明确正在从Spring Boot 2.X往3.X迁移的Java开发需要快速知道版本升级到底改了哪些东西刚学完Spring Boot基础、想做项目练手但不知道从哪下手的初学者公司里需要快速搭建内部运营后台不想每次都在登录认证和权限上反复造轮子的同学这套demo我前前后后重构了三版期间踩了不少Spring Boot 3.X专属的坑这篇文章不写官方文档里已经有的内容重点讲版本差异、设计思路、关键实现和排查过程你可以直接照着搭也可以在此基础上往自己的业务方向扩展。3. 技术选型为什么这套组合在3.X生态里最省心3.1 Spring Boot 3.X带来的真实变化先聊一个核心问题既然Spring Boot 2.X那么成熟稳定为什么非要上3.XSpring Boot 3.0发布时最大的变化不是功能增加了多少而是底层的根基换了JDK基线从8提升到17这意味着如果你还在用JDK 8就不得不先升级基础环境Java EE迁移到Jakarta EE所有javax.*开头的包名都变成了jakarta.*你原来写的import javax.servlet.http.HttpServletRequest直接编译不过Spring Security 6.0全面拥抱Lambda风格的DSL配置原本的antMatchers方法被废弃换成requestMatchers自动配置和配置绑定机制也做了调整个别配置项改名可以理解为2.X像是一栋老楼装修再漂亮也是老地基3.X是在新地基上重新盖的楼短期有点阵痛但长期看更稳。这个demo选择3.X而不是停留在2.X还有一个实际考虑新项目以后要长期维护与其等两年后被依赖的兼容性问题逼着升级不如现在就把地基打好。3.2 核心依赖清单与版本匹配关系我的最终选择如下组件版本说明JDK17Spring Boot 3.X的最低要求Spring Boot3.2.53.X生态中比较稳的一个版本MyBatis-Plus3.5.7已经适配Spring Boot 3.XSa-Token1.38.0轻量级权限认证框架MySQL8.0数据库Redis不需要这个demo不走分布式Session省一个依赖ThymeleafSpring Boot内置版本服务端渲染后台页面这里我做了两个重要决策需要解释一下为什么。第一权限认证选了Sa-Token而不是Spring Security。虽然Spring Security是官方亲儿子但实际开发中我发现一件事后台管理系统的权限需求是非常标准的登录拦截、角色校验、按钮级权限Spring Security在这块配置繁琐不说Spring Boot 3 Spring Security 6的写法变化还特别大对新手极其不友好。Sa-Token的思路就简单直接登录了就给你一个token访问接口时通过拦截器校验token校验通过就放行。整个接入过程比Spring Security少写至少一半配置。这不是说Spring Security不行而是场景不同。如果你做的是对外提供API的服务需要OAuth2、JWT等复杂场景那该用Spring Security还得用。但后台管理系统的demoSa-Token足够且开发效率更高。第二页面渲染选择了Thymeleaf而不是前后端分离。现在vue3后台管理系统模板确实很火网上也有大量基于Vue3 Spring Boot的分离方案。但这次做demo的出发点是让你用最少的代码把后端权限链路跑通。如果引入Vue3 Vite Axios Router Pinia等于一套项目拆成两个工程光前端环境搭建就劝退一堆人。Thymeleaf可以直接在Spring Boot里配好templates目录就开跑通过LinkExpression天然拿到context-path不需要处理CORS、不需要做跨域联调非常适合快速验证逻辑。如果你后续要把前端替换成Vue3那更简单后端只需要把Controller改成返回JSON格式再把Sa-Token的token校验放在拦截器里前端对接登录接口和路由守卫即可。这个demo的后端结构已经为这种改造留好了余地后面我会说。3.3 为什么这个demo不做Redis和微服务很多人一听到后台管理系统习惯性就要上Redis、Nacos、Gateway这一套。但你要想明白demo的意义在于最小化可运行如果你的目标是验证Spring Boot 3.X的适配性和实现核心权限逻辑就完全没必要引入分布式组件。这里的原则是凡是单机就能跑通的功能就不要引入外部依赖。Sa-Token本身支持将Session存储在内存登录信息放在ConcurrentHashMap里对于单机demo完全够用。等你真正做生产项目再加一个Sa-Token集成Redis的配置十分钟搞定。同理不用微服务那套也是刻意为之——一旦拆成多个服务你需要处理服务间鉴权、网关统一认证、配置中心等一系列问题这些跟后台管理系统demo的核心目标毫无关系。把这些复杂度全砍掉专注登录 权限 CRUD是聪明的取舍。4. 环境准备与项目搭建的完整流程4.1 JDK 17的安装与验证因为Spring Boot 3.X强制要求JDK 17所以第一步先把本机JDK升上去。这里要特别注意一个容易被忽略的点升级JDK 17不会影响你原来基于JDK 8的项目。不同项目可以指向不同版本的JDKIDE和构建工具里分别配置就行。安装完成后在命令行执行java -version如果能看到类似这样的输出说明JDK 17就绪openjdk version 17.0.10 2024-01-16 OpenJDK Runtime Environment (build 17.0.102) OpenJDK 64-Bit Server VM (build 17.0.102)如果你的机器同时装了多个JDK版本请注意检查Maven用的是哪个JDK。我遇到过一次很诡异的情况命令行里java -version显示17但Maven编译时却报无法解析javax.servlet排查半天发现是IDEA里Project Structure的SDK还指向旧的JDK 8。所以三个地方一定要保持一致命令行JAVA_HOME、IDEA的Project SDK、Maven的JAVA_HOME配置。4.2 Spring Boot 3.X项目骨架的创建推荐两种方式创建项目骨架方式一Spring Initializr官网在start.spring.io选择Spring Boot 3.2.5Group填com.exampleArtifact填admin-demoJava版本选17依赖里选择Spring Web、Thymeleaf、Validation然后生成项目包导入IDEA。方式二Maven直接创建如果不想去官网也可以用命令行直接生成比较适合熟悉Maven的同学。我建议用方式一因为Initializr生成的pom.xml已经帮你处理好了Spring Boot 3.X的所有依赖版本管理不需要手写版本号去踩兼容坑。4.3 pom.xml的关键依赖配置创建好基础骨架后在pom.xml里添加以下核心依赖dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.7/version /dependency dependency groupIdcn.dev33/groupId artifactIdsa-token-spring-boot3-starter/artifactId version1.38.0/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency这里要特别说明几个坑第一个坑MyBatis-Plus的artifactId变了。如果你在Spring Boot 3.X项目里用旧的mybatis-plus-boot-starter会启动失败。3.5.7版本开始官方专门提供了mybatis-plus-spring-boot3-starter这个新的依赖标识2.X时代的老包在3.X环境下会报ClassNotFoundException。第二个坑Sa-Token也有专门的Boot 3适配包。正常我们熟知的包名是sa-token-spring-boot-starter但Spring Boot 3.X需要引入sa-token-spring-boot3-starter这点很容易忽略一旦用错启动时会出现无法识别Spring Boot版本或自动配置不生效的问题。第三个坑MySQL驱动从mysql-connector-java改成了mysql-connector-j。这是Spring Boot 3.X里驱动依赖名的调整继续用旧名称会爆红。4.4 application.yml的核心配置在src/main/resources/application.yml中配置数据源和MyBatis-Plus相关信息server: port: 8080 servlet: context-path: /admin spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/admin_demo?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 thymeleaf: cache: false mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: id-type: auto注意两个细节context-path设为/admin这样所有接口都会带/admin前缀方便区分后台接口。但这也意味着你在Controller返回的页面路径里Thymeleaf的LinkExpression能正确拼上前缀而后端通过重定向跳转页面时也必须要加这个前缀否则会404。这个后面写登录跳转的时候会重点说到。serverTimezone必须设MySQL 8的连接url如果不指定时区运行时会报Could not create connection to database server的异常很多人卡在这。5. 登录认证模块的实现与安全细节5.1 用户表和角色表设计后台管理系统最核心的就是认证和授权。认证是谁授权是能干什么。这里我不设计复杂的权限模型就用最简单直接的三张表用户表、角色表、用户角色关联表。后续扩展按钮权限再额外加菜单权限表这也是Sa-Token比较擅长处理的。CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(50) NOT NULL, role_name VARCHAR(50) NOT NULL ); CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) );设计上为什么要单独把user_role表拆出来因为用户和角色是多对多关系。这样的话以后加多个角色给同一个用户只需要插入关联记录而不需要改用户表结构。虽然现阶段demo里每个用户只分配一个角色但表结构从一开始就保持规范化省得后面返工。5.2 Sa-Token的集成从配置到第一个登录接口Sa-Token在Spring Boot 3.X下的集成非常简单。先写配置类把token的过期时间、token风格等参数设定好sa-token: token-name: satoken timeout: 2592000 active-timeout: -1 is-concurrent: true token-style: uuid is-share: false解释一下这几个参数的作用。token-name是前端调用接口时请求头里携带的key名称从Sa-Token生成的token值会存在于请求头satoken字段里。timeout是token绝对有效期单位秒2592000秒就是30天。is-share这个参数决定同一账号多处登录时是否共享同一个token设为false表示每处登录都生成新token适合后台系统管理场景方便踢人下线。然后写一个登录接口PostMapping(/login) public String login(String username, String password, Model model) { SysUser user userService.login(username, password); if (user null) { model.addAttribute(error, 用户名或密码错误); return login; } StpUtil.login(user.getId()); StpUtil.getSession().set(userInfo, user); return redirect:/admin/index; }这里我想多聊几句密码校验的问题。很多demo里密码直接明文存储甚至明文传给后端这个习惯千万不要养成。生产环境一定要用BCrypt加密Spring Security里自带的BCryptPasswordEncoder可以单独拿过来用不需要引入整个SecurityComponent public class PasswordEncoder { private final BCryptPasswordEncoder encoder new BCryptPasswordEncoder(); public String encode(String rawPassword) { return encoder.encode(rawPassword); } public boolean matches(String rawPassword, String encodedPassword) { return encoder.matches(rawPassword, encodedPassword); } }然后注册用户、初始化数据时都存加密后的密码。校验时用matches方法判断。数据库里如果被人拖库拿到也是一串不可逆的密文这就是最基本的底线安全措施。5.3 拦截器配置与白名单放行配置好Sa-Token后需要注册拦截器让框架对请求进行token校验Configuration public class SaTokenConfigure implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new SaInterceptor(handle - StpUtil.checkLogin())) .addPathPatterns(/**) .excludePathPatterns(/login, /doLogin, /css/**, /js/**, /error); } }这里最值的注意的就是白名单放行登录页本身、登录接口、静态资源、错误页都要放行。如果你忘了放行静态资源会出现页面样式全部丢失的情况因为CSS文件被拦截器拦截了返回的是401或302而非CSS内容。还有一点如果你配置了context-path拦截器的路径匹配是不会自动带上下文前缀的也就是说上面的/login指的是项目内部路径。请求进来时Spring MVC已经帮你把context-path剥离了所以在这里不需要写成/admin/login这点和Controller里的重定向跳转正好相反很多人搞混导致登录后一直跳转循环。5.4 登录页面与服务端渲染的跳转细节Thymeleaf页面放在src/main/resources/templates/下面登录页login.html的表单提交地址要加context-path前缀form th:action{/doLogin} methodpost input typetext nameusername placeholder用户名 required/ input typepassword namepassword placeholder密码 required/ button typesubmit登录/button /formth:action{/doLogin}会自动在前方拼上应用上下文前缀这是Thymeleaf比较方便的地方。而如果你在Java代码里做重定向就必须手动拼接return redirect:/admin/index;这条是我第一次从2.X迁到3.X时踩过的一个典型问题在2.X时代如果没配置context-path所有重定向直接写/index就行但配了context-path后任何重定向都要手工补上前缀。不补的话浏览器跳转到/index结果404。5.5 为什么不推荐在demo里用JWT现在的后台管理系统一搜全是JWT的方案。但在这个demo里我刻意没用JWT核心原因有三个JWT是无状态的token签发生效后服务端无法在有效期内主动让这个token失效。如果发现用户被盗号想踢人下线都做不到。JWT的payload是Base64编码虽然不能改但任何人解个码就能看到内容敏感信息不小心放进去就是裸奔。后台管理系统本身就是服务端渲染Session天然适合Sa-Token在内存中保存会话状态想做踢人、查看在线用户、同端互斥登录都极其方便。简单说对外提供API的服务适合JWT带登录态的运营后台更适合服务端Session。技术上没有银弹选择适合自己的才是对的。6. 用户与角色管理模块的CRUD实现6.1 基于MyBatis-Plus的Service封装有了权限认证的基础接下来就是后台管理系统的正常业务用户管理。用MyBatis-Plus的核心好处是不用写繁琐的SQLCRUD方法全都内置。public interface SysUserService extends IServiceSysUser { SysUser login(String username, String password); PageSysUser getUserPage(int pageNum, int pageSize, String keyword); } Service public class SysUserServiceImpl extends ServiceImplSysUserMapper, SysUser implements SysUserService { Override public PageSysUser getUserPage(int pageNum, int pageSize, String keyword) { LambdaQueryWrapperSysUser wrapper new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { wrapper.like(SysUser::getUsername, keyword) .or().like(SysUser::getNickname, keyword); } wrapper.orderByDesc(SysUser::getCreateTime); return this.page(new Page(pageNum, pageSize), wrapper); } }这里要注意一个MyBatis-Plus的使用细节LambdaQueryWrapper的or()方法会把前面所有条件括起来容易产生SQL逻辑问题。比如上面这段代码如果keyword不为空生成的SQL是WHERE username LIKE %xx% OR nickname LIKE %xx% ORDER BY create_time DESC看起来没毛病。但如果后面还有其它条件比如status 1用or连接就会变成WHERE username LIKE %xx% OR nickname LIKE %xx% AND status 1这个SQL会因为AND优先级高于OR而出现只有nickname条件关联了status判断的bug。解决方式有两种一种是用and(condition - condition.like(...).or().like(...))把or条件组括起来另一种是分开写多个wrapper查询。详情我后面在常见问题里还会再展开。6.2 分页插件的配置一个很容易踩的坑MyBatis-Plus的分页插件跟其它组件不一样光引入依赖是不够的必须手动在配置类里注册分页拦截器。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(100L)这行是我自己加的目的防止有人传一个巨大的pageSize把数据库打爆比如有人手滑传了pageSize999999如果没限制分页插件生成的SQL会LIMIT 999999虽然MySQL能扛住但非常容易拖垮接口响应。设置上限后超过100会按100处理。这里顺便提醒分页查询返回的数据包含total总记录数、pages总页数等元数据。前端渲染时要用这些数据生成底部分页组件。Thymeleaf配合Page对象做页面循环时访问的是page.records、page.total、page.pages这些属性命名一定要跟Java属性对得上。6.3 用户新增、编辑、删除的处理逻辑新增用户时有几个环节要特别留意第一用户名唯一性校验。数据库里username字段设了UNIQUE约束但仅靠数据库报错还不够友好。插入前先查一遍long count count(new LambdaQueryWrapperSysUser() .eq(SysUser::getUsername, user.getUsername())); if (count 0) { throw new ServiceException(用户名已存在); }第二密码加密存储。前端表单传进来的明文密码要在Service层加密后入库。注意不要在Controller层里做加密业务逻辑应该收拢在Service里。第三删除用户的级联问题。用户删除之后用户角色关联表里的数据如果不删下次查询时就会有一堆孤儿数据影响统计结果。删除时同步清理Transactional(rollbackFor Exception.class) public void deleteUser(Long userId) { removeById(userId); userRoleMapper.delete(new LambdaQueryWrapperSysUserRole() .eq(SysUserRole::getUserId, userId)); }Transactional注解这里一定要加保证用户删除和关联表删除在同一个数据库事务里任何一步失败都整体回滚避免出现用户没了但关联关系还留着这种脏数据。6.4 角色分配功能的一个思路角色分配的常见交互是用户列表页面每行有一个分配角色按钮点击后弹出一个页面/对话框勾选要给这个用户分配哪些角色提交后后端先删掉旧的关联数据再批量插入新的。Transactional(rollbackFor Exception.class) public void assignRoles(Long userId, ListLong roleIds) { userRoleMapper.delete(new LambdaQueryWrapperSysUserRole() .eq(SysUserRole::getUserId, userId)); for (Long roleId : roleIds) { SysUserRole userRole new SysUserRole(); userRole.setUserId(userId); userRole.setRoleId(roleId); userRoleMapper.insert(userRole); } }先删除后插入这套逻辑是因为用户端的角色勾选状态是个集合你怎么知道用户取消了哪些、保留哪些如果不做全量替换而是做差量更新代码就复杂多了还容易出并发问题。全量替换的优点是逻辑简单、不易出错缺点是有轻微的性能浪费但在一个后台管理系统的用户角色分配场景下这点开销完全不是问题。7. 菜单权限与页面级控制7.1 最简单的菜单权限模型严格来说一个完整的权限管理系统应该包含用户-角色-菜单三层关系菜单又分为目录、菜单、按钮三种类型做成RBAC模型。但做demo要从简我采用的是角色-菜单关联表 菜单表的方式只控制页面级的菜单显示权限不做按钮级的细粒度控制。CREATE TABLE sys_menu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT DEFAULT 0, menu_name VARCHAR(50), menu_url VARCHAR(200), menu_icon VARCHAR(50), sort INT DEFAULT 0, visible TINYINT DEFAULT 1 ); CREATE TABLE sys_role_menu ( role_id BIGINT NOT NULL, menu_id BIGINT NOT NULL, PRIMARY KEY (role_id, menu_id) );菜单表通过parent_id实现父子层级sort字段控制同级菜单的排列顺序。这样设计的好处是菜单可以动态维护不需要每次改代码再部署运营人员直接在前端可视化配置菜单项就行。7.2 根据当前用户动态加载菜单在Thymeleaf页面中根据当前登录用户的角色动态渲染菜单是服务端渲染方案里最舒服的一点。不需要前端根据权限路由做动态添加后端渲染时就已经判断好了。Controller里从Sa-Token的Session取用户信息再查出这个用户拥有哪些菜单StpUtil.getSession().get(userInfo);因为用户登录时已经set了userInfo进Session这里可以很方便地拿到当前用户然后通过userId查到角色再通过角色查到菜单列表放到Model里传给页面。Thymeleaf页面里这样渲染ul classnav-menu li th:eachmenu : ${menus} th:if${menu.parentId 0} a th:href{${menu.menuUrl}} span th:text${menu.menuName}/span /a ul th:if${#lists.contains(childMenus, menu.id)} ... /ul /li /ul这里有一个体验细节菜单的url在数据库里存的是不带context-path的相对路径比如/dashboard、/user/list。但Thymeleaf的th:href{${menu.menuUrl}}会自动拼接上下文前缀所以页面中点击跳转的路径是对的。可是如果你在Controller里重定向到这个路径就必须手动加前缀。这类前缀不一致问题贯穿整个开发过程一定要从设计上明确区分模板页面里的链接交给Thymeleaf处理Java代码里的跳转自己拼前缀。7.3 Sa-Token的权限注解使用Sa-Token除了做登录拦截还提供了基于注解的权限校验。在Controller方法上加上SaCheckPermission注解可以指定访问该接口需要什么权限SaCheckPermission(value system:user:add) PostMapping(/add) public String addUser(SysUser user) { userService.addUser(user); return redirect:/admin/user/list; }这个注解生效的前提是需要注册SaInterceptor时开启注解校验registry.addInterceptor(new SaInterceptor(handle - StpUtil.checkLogin())) .addPathPatterns(/**) .excludePathPatterns(/login, /doLogin, /css/**, /js/**, /error);不过要注意注解的权限标识是需要你在用户登录时把这些权限标识放进Session里的。也就是说Sa-Token的SaCheckPermission判断时不是实时去数据库查角色的而是从当前会话的权限集合里查。所以登录时或角色变更时要调用StpUtil.getSession().set(permissionList, permissionCodes);或者使用Sa-Token提供的StpUtil.login(userId)后在需要的地方通过StpUtil.getPermissionList()获取权限列表。简化做法是登录时把权限集合放进去。8. 统一异常处理与数据返回格式8.1 使用RestControllerAdvice做全局异常拦截后台管理系统虽然页面居多但也会有接口返回JSON的情况比如用户列表里点击分配角色时异步加载角色列表。如果此时出现异常直接抛给前端会得到一堆丑陋的错误页或HTTP状态码体验极差。我习惯用RestControllerAdvice加ExceptionHandler统一处理配合一个Result类做统一返回Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(500); result.setMessage(message); return result; } }为什么用一个统一的Result好处有三个前端拦截器可以统一判断code是否等于200来决定是否弹出错误提示日志里能按照统一格式打印错误信息后续如果要接前端框架axios响应拦截器里可以直接用message提示用户。8.2 异常处理里最容易犯的三个错误第一个错误只处理了ServiceException但没处理通用Exception。数据库异常、空指针、参数格式错误统统会漏到最外层直接导致错误页白屏。至少要处理一层ExceptionExceptionHandler(Exception.class) public Result? handleException(Exception e) { log.error(系统异常, e); return Result.error(系统繁忙请稍后重试); }第二个错误全局异常处理里直接返回了渲染页面路径但接口调用方期望的是JSON。这里要区分场景如果Controller是返回页面视图、且发生了异常跳到统一错误页更合适如果Controller是接口返回JSON应该返回Result。两种场景可以在Controller方法上区分也可以在异常类型上进行分流但最省事的做法是接口相关的Controller统一返回Result页面相关的Controller抛异常时指定错误页面跳转。第三个错误忽略了特定异常的处理顺序。Spring MVC处理异常时会找最精确的异常类型处理器比如同时定义了ServiceException和Exception的处理器抛ServiceException时会优先走ServiceException的。所以子类异常一定要单独定义父类Exception做兜底两者缺一不可。9. 常见问题与排查技巧实录9.1 启动报错ClassNotFoundException: javax.servlet.Filter这是从Spring Boot 2.X升级到3.X时最经典的问题。报错信息会告诉你找不到javax.servlet.Filter这个类或者类似jakarta.servlet.*根本原因就是前面说的Spring Boot 3.X把Java EE换成了Jakarta EE包名从javax变成了jakarta。所以项目里所有import javax.servlet的地方都必须改成import jakarta.servlet。还需要排查是不是依赖里引入了旧版第三方包。我有一次是引了一个旧版本的第三方工具库它内部硬编码了javax.servlet导致编译期不报错但在运行时类加载器找不到这个类。解决办法是升级那个依赖到适配Jakarta的版本或者排除掉它内部关联的旧servlet-api依赖。9.2 页面样式全丢静态资源被拦截器拦截症状是登录页能打开但完全没样式Chrome开发者工具里全是401或302到login的CSS请求。这就是前面说过的拦截器没有放行静态资源。检查两个地方Sa-Token拦截器的excludePathPatterns是否包含/css/、/js/、/images/**这些路径如果你的页面引用了webjars资源是否放行了/webjars/**有人可能会问为什么登录之前也需要放行静态资源因为登录页本身也就是一个HTML页面浏览器会继续发请求去拿这个页面里引用的CSS和JS文件这些请求同样会被拦截器拦住。拦住了又没登录自然就被重定向到登录页结果登录页加载不出来样式。9.3 登录成功后一直重定向循环登录成功后跳转index结果浏览器地址栏一直出现login - index - login的循环页面刷不停。这个坑十有八九是重定向的路径没加context-path。检查Controller里的所有redirect确保它们都带上了/admin前缀// 错误 return redirect:/index; // 正确 return redirect:/admin/index;这里有个比较隐蔽的细节Thymeleaf模板里的th:action和th:href不需要拼前缀但Java代码里字符串的redirect必须拼因为Thymeleaf会通过LinkExpression自动处理上下文而Java字符串不会。9.4 Sa-Token注解不生效SaCheckLogin、SaCheckPermission这些注解写在Controller方法上调试时发现完全没有拦截效果匿名访问仍然可以进到方法体里。原因基本可以锁定为没有注册SaInterceptor并开启注解识别。Sa-Token的注解校验并非Spring Boot自动扫描就生效的必须要通过拦截器触发它本质上是拦截器内部去检查当前请求对应的HandlerMethod上有没有相关注解有就执行校验逻辑。配置里漏掉这个注解自然不会生效。9.5 LambdaQueryWrapper的or条件把status过滤带偏前面在用户管理里埋了一个坑这里详细说一下。需求是搜索关键字匹配用户名或昵称同时还要只查status为1的有效用户。如果直接这么写wrapper.like(SysUser::getUsername, keyword) .or() .like(SysUser::getNickname, keyword) .eq(SysUser::getStatus, 1);生成的SQL是WHERE (username LIKE %xx% OR nickname LIKE %xx% AND status 1)结果就是只要用户名匹配的用户即使status是0被禁用也会被查出来因为OR把AND隔开了。正确写法是用and嵌套条件wrapper.and(w - w.like(SysUser::getUsername, keyword) .or() .like(SysUser::getNickname, keyword)) .eq(SysUser::getStatus, 1);这样生成的SQL是WHERE (username LIKE %xx% OR nickname LIKE %xx%) AND status 1这种细节写一次记一辈子也建议你在team的代码规范里加一条or()必须配合and()嵌套使用禁止裸写。9.6 MyBatis-Plus分页查询一直返回全量数据分页插件配了Page对象也new了但查出来的数据还是全量total返回0。这个问题的根源通常是分页拦截器没有生效。检查是否已经注册了MybatisPlusInterceptor并且加入的是PaginationInnerInterceptor是否是旧版依赖导致分页插件包名变更3.5.7版本的com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor是否Mapper方法里的分页参数Page对象没有作为第一个参数传入另外注意一点如果项目里同时用了PageHelper和MyBatis-Plus两者会有冲突分页逻辑会各种诡异要么只留一个要么确保它们作用的Mapper范围完全隔离。10. 这个demo后续还可以怎么扩展如果你基于这套骨架做生产级的系统有几个值得投入的扩展方向。这里我按个人建议的优先级列出来你可以按实际需要选择。第一个建议菜单管理做成可视化维护。现在菜单数据是SQL脚本预置的。真实的运营后台一般会提供一个菜单管理的页面支持动态新增、排序、修改url。这不难本质就是对sys_menu表的CRUD但加上之后整个后台的可用性会高一个档次。第二个建议接入操作日志。后台管理系统的审计需求往往是硬性的谁在什么时间删了哪个用户、改了哪个角色的权限。你可以用一个AOP切面拦截所有Controller请求记录请求路径、参数、操作人、耗时存到一张sys_log表里。这件事在demo里可以先不做但生产系统基本绕不开。第三个建议改造为前后端分离。后端把所有的Controller改成返回Result页面交由Vue3管理。这个改造本身并不难难的是权限数据的管理。Vue Router的beforeEach钩子里你需要根据后端返回的菜单数据动态添加路由这与Thymeleaf服务端渲染时直接输出菜单的原理不同但后端查询菜单、角色关联的那部分代码可以直接复用。第四个建议把Sa-Token会话存储切换到Redis。demo里保存在内存重启即失效。如果部署到多实例环境下会出现用户在A实例登录、请求跑到B实例却不认识的情况。Sa-Token官方提供了sa-token-redis集成配置文件里换一下再把sa-token的dao实现替换成RedisDao几行配置就能完成。我在实际开发中收获最大的一条经验是做demo不是把功能做全而是把所有核心链路用最简单的方式跑通。链路通了后面加缓存、加中间件、加分布式组件都只是锦上添花链路没通时去堆技术栈结果只能是问题套问题排查起来非常痛苦。另外再分享一个实际过程中很实用的小技巧在全局异常处理类里加一行日志输出把每个请求的耗时也一起打出来。这样你在测试接口性能时不需要额外打点直接看日志平均耗时就能判断是不是某个页面接口响应太慢。打开慢查询日志配合MySQL的EXPLAIN分析SQL执行计划能帮你快速定位分页查询到底是不是全表扫描。这套demo代码量不大但该有的核心模块都有了。从创建骨架、配置数据源、实现登录、接入权限、编写CRUD、统一异常处理到排查各种3.X版本专属问题全部过一遍之后你基本就能摸清Spring Boot 3.X开发后台管理系统的主要脉络了。后续无论是往生产项目方向做深还是把前端切成Vue3都不会再觉得是一头雾水。