
1. 为什么这套老三样组合至今仍是后台管理系统的首选后台管理系统这个东西做过的人都知道需求翻来覆去就那些登录鉴权、菜单权限、增删改查、分页列表、表单校验、文件上传。听起来简单但真要从零搭一个能用的架子坑一点都不少。我前后用不同技术栈搭过七八个后台系统绕了一大圈最后还是回到了SpringBoot MyBatis Thymeleaf这套组合上。原因很直接这套组合的心智负担最低。SpringBoot 把配置和依赖管理全包了MyBatis 让你对 SQL 有完全的掌控权Thymeleaf 是服务端渲染页面直接由后端吐出来不需要前后端分离那一套接口联调、跨域处理、Token 传递的繁琐流程。对于一个中小型后台、内部工具、或者个人项目来说这套方案的开发效率高得离谱。这篇文章我打算把整个搭建过程完整拆一遍从项目结构设计、依赖选型、数据库设计到核心的 CRUD 实现、分页、权限拦截、模板布局再到实际开发中踩过的那些坑。适合有一定 Java 基础、想快速上手一个完整后台项目的朋友也适合那些做惯了前后端分离、想回头看看服务端渲染方案到底香在哪的老手。文章里所有代码和配置都是可以直接抄作业的我会把每个选择背后的理由讲清楚而不是甩一堆代码让你自己猜。先说清楚这套方案的定位它不适合高并发、大流量的 C 端产品也不适合需要复杂前端交互比如拖拽看板、实时协作的场景。但如果你要做的是一个几十上百人用的内部管理系统或者一个需要快速交付的 MVP那这套组合的性价比几乎没有对手。Thymeleaf 的模板复用能力配合 SpringBoot 的自动配置一个中等复杂度的后台两三天出原型完全不是问题。2. 项目整体架构设计与技术选型拆解2.1 分层结构怎么划分才不混乱很多人搭后台系统一开始图省事Controller 里直接写 SQL、拼 HTML写着写着代码就烂成一锅粥。我的习惯是从第一天就严格分层哪怕项目再小。标准的分层是这样的Controller 层只负责接收请求参数、调用 Service、返回视图或重定向不写任何业务逻辑。Service 层业务逻辑的核心事务控制放在这一层调用 Mapper 完成数据操作。Mapper 层只做数据库交互一个方法对应一条 SQL保持纯粹。Entity / DTOEntity 对应数据库表结构DTO 用于接收前端参数和返回给页面两者不要混用。为什么要 Entity 和 DTO 分开因为数据库字段和页面需要的字段往往不一致。比如用户表里有password字段但返回给页面时绝对不能带上再比如列表页需要显示角色名称但用户表里只存了role_id这时候就需要一个 DTO 来承载联表查询的结果。混用 Entity 会导致两个问题一是敏感字段容易泄露到页面二是表结构一变页面全崩。包结构我一般这样组织com.example.admin ├── controller // 控制器 ├── service // 业务接口 │ └── impl // 业务实现 ├── mapper // MyBatis 接口 ├── entity // 数据库实体 ├── dto // 数据传输对象 ├── config // 配置类 ├── interceptor // 拦截器 ├── common // 通用工具、常量、统一返回 └── AdminApplication // 启动类这个结构看起来平平无奇但它的好处是职责边界清晰。新人接手时看到mapper就知道是数据库操作看到interceptor就知道是拦截逻辑不用满项目找。2.2 为什么选 Thymeleaf 而不是 JSP 或前后端分离这是最多人纠结的点。我逐个说下我的判断。JSP 的问题JSP 本质上是 Servlet 容器的一部分打包成可执行 JAR 后运行会有各种路径问题而且 JSP 的语法和 HTML 混在一起可读性差现代 SpringBoot 项目基本已经抛弃它了。Thymeleaf 是纯 HTML 模板浏览器直接打开也能看到静态效果前后端协作时前端可以先做好静态页面后端再往里填数据。前后端分离的问题分离方案Vue/React REST API当然好但它引入了额外的复杂度——跨域配置、Token 管理、接口文档维护、两套代码仓库。对于一个后台管理系统来说这些成本很多时候是浪费的。Thymeleaf 服务端渲染页面直接由后端生成登录状态用 Session 管理简单直接。Thymeleaf 的核心优势在于它的模板片段复用。后台系统的页面结构高度相似——顶部导航、左侧菜单、内容区这些用 Thymeleaf 的th:fragment和th:replace抽成公共片段改一处全站生效。这个能力在 JSP 里也有jsp:include但 Thymeleaf 的语法更自然而且支持在浏览器里直接预览。提示Thymeleaf 默认会缓存模板开发阶段一定要在配置文件里关掉缓存否则改完 HTML 刷新页面看不到变化会浪费大量时间排查。2.3 依赖清单与版本选择依赖这块我不建议盲目追新后台系统稳定压倒一切。下面是我实测比较稳的一套组合依赖版本建议作用spring-boot-starter-web跟随 SpringBoot 主版本Web 支持spring-boot-starter-thymeleaf跟随主版本模板引擎mybatis-spring-boot-starter2.3.xMyBatis 集成mysql-connector-j8.x数据库驱动pagehelper-spring-boot-starter1.4.x分页插件spring-boot-starter-validation跟随主版本参数校验lombok最新简化实体类SpringBoot 主版本我建议用2.7.x或者3.x。2.7.x 的优势是生态成熟、资料多遇到问题一搜就有答案3.x 用了 Jakarta EE 命名空间javax.*全部变成jakarta.*如果你参考的教程比较老直接上 3.x 会有一堆包名对不上的问题。新手我建议从 2.7.x 起步把整个流程跑通了再考虑升级。PageHelper 这个分页插件值得单独说一句。MyBatis 本身不带分页功能你得自己写LIMIT语句还要单独写一个COUNT查询。PageHelper 的做法是在你执行查询前调用PageHelper.startPage(pageNum, pageSize)它会自动拦截下一条 SQL帮你拼上分页和总数查询。用起来非常省事但有个坑后面会讲。3. 数据库设计与核心表结构落地3.1 后台系统最少需要哪几张表一个能跑起来的最小后台我建议至少设计这几张表用户表、角色表、菜单表以及用户角色关联表、角色菜单关联表。有人会问就做个简单后台搞这么复杂干嘛我的经验是权限这块一开始不设计好后面加功能会非常痛苦。哪怕你现在只需要一个管理员账号也建议把用户和角色分开。因为业务一旦发展运营只能看订单、财务只能看账单这种需求一定会来。到时候你再去改表结构、改代码成本比一开始就设计好高得多。核心表结构大致如下-- 用户表 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 COMMENT 1启用 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 角色表 CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_name VARCHAR(50) NOT NULL, role_code VARCHAR(50) NOT NULL UNIQUE ); -- 用户角色关联 CREATE TABLE sys_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ); -- 菜单表 CREATE TABLE sys_menu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, parent_id BIGINT DEFAULT 0, menu_name VARCHAR(50) NOT NULL, url VARCHAR(200), icon VARCHAR(50), sort_order INT DEFAULT 0 ); -- 角色菜单关联 CREATE TABLE sys_role_menu ( role_id BIGINT NOT NULL, menu_id BIGINT NOT NULL, PRIMARY KEY (role_id, menu_id) );3.2 密码存储与字段设计的几个关键决策密码绝对不能明文存。这是底线没有商量余地。我用的是 BCryptSpring Security 里有现成的BCryptPasswordEncoder但如果你不想引入整个 Spring Security它比较重可以单独引入spring-security-crypto这个轻量包只为了用它的加密工具。BCrypt 的好处是它自带盐值每次加密同一个密码得到的结果都不一样但校验时能正确匹配。这比 MD5 加固定盐安全得多。字段长度给 100 是因为 BCrypt 生成的哈希串固定是 60 位留点余量。菜单表的parent_id设计成自关联是为了支持多级菜单。后台系统的菜单通常是树形结构——系统管理下面挂用户管理角色管理菜单管理。用parent_id 0表示顶级菜单查询时先查顶级再递归查子级或者一次性全查出来在内存里组装成树。注意菜单的url字段要和 Controller 的映射路径对应上这是后面做权限拦截的基础。如果 url 写错了拦截器就拦不住会出现菜单看不到但直接输网址能访问的漏洞。3.3 初始化数据的准备表建好后得先塞一条管理员数据进去否则你连登录都进不去。密码字段要填 BCrypt 加密后的值不能直接写明文。你可以写一个临时的测试方法用BCryptPasswordEncoder().encode(你的密码)生成哈希串然后手动插进数据库。初始化菜单数据时我建议先把所有菜单的parent_id都设成 0等系统跑起来、菜单能正常显示了再慢慢调整层级关系。一上来就搞复杂的树形结构调试起来很费劲。4. 核心功能实现从登录到增删改查4.1 登录鉴权与 Session 管理登录逻辑本身不复杂接收用户名密码查库比对成功就把用户信息塞进 Session失败就返回错误提示。但有几个细节必须处理好。第一密码比对要用加密工具的 matches 方法不能自己写字符串比较。BCrypt 每次加密结果不同直接比较哈希串永远不相等。第二登录失败不要提示用户名不存在或密码错误统一提示用户名或密码错误。这是为了防止有人通过错误提示枚举出系统里存在哪些用户名。第三Session 里存什么。我一般存一个精简的用户对象id、username、nickname、角色列表、权限菜单列表。不要把整个 User 实体塞进去尤其是密码字段万一哪天不小心在页面里输出了 Session 内容密码就泄露了。登录成功后跳转到后台首页首页需要展示当前用户的菜单。菜单的获取逻辑是根据用户 id 查角色根据角色查菜单去重后按sort_order排序再组装成树形结构返回给页面。4.2 拦截器实现权限控制拦截器是这套方案里权限控制的核心。思路很简单写一个HandlerInterceptor在preHandle方法里判断当前请求的路径是否在用户的权限菜单里不在就拦截。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); Object user session.getAttribute(loginUser); if (user null) { response.sendRedirect(/login); return false; } return true; } }上面这个是最基础的登录拦截只判断有没有登录。如果要做到菜单级权限还需要在 Session 里存一份当前用户可访问的 url 集合preHandle里比对请求路径。比对时要注意排除登录页、静态资源、错误页这些不需要权限的路径否则会陷入死循环——拦截器拦截了登录页用户永远登不进去。拦截器注册时用addPathPatterns(/**)拦截所有请求再用excludePathPatterns排除掉白名单。白名单至少包括/login、/logout、/css/**、/js/**、/images/**、/error。实操心得静态资源路径一定要排除干净。我踩过一次坑CSS 文件被拦截器拦下来重定向到登录页结果登录页样式全丢排查了半天才发现是拦截器的问题。4.3 通用 CRUD 的模板化写法后台系统里 80% 的代码都是增删改查如果每个模块都从头写一遍效率太低。我的做法是抽象出一套通用的写法新模块直接复制改改就行。以用户管理为例一个完整的 CRUD 包含这几个部分列表查询带分页和条件搜索Controller 接收页码、每页条数、搜索关键词调用 ServiceService 里用 PageHelper 分页返回一个PageInfo对象给页面。页面用 Thymeleaf 遍历渲染表格底部渲染分页条。新增和编辑这两个功能通常共用一个表单页面。新增时表单是空的编辑时表单回填数据。判断依据是请求里有没有 id 参数。Controller 里用GetMapping(/form)处理页面跳转用PostMapping(/save)处理提交。删除一般用逻辑删除而不是物理删除也就是给表加一个deleted字段删除时把它置为 1查询时过滤掉。物理删除一旦误操作数据就找不回来了。逻辑删除的代价是每条查询都要带WHERE deleted 0稍微麻烦点但安全。分页这块用 PageHelper 有个细节要注意PageHelper.startPage()必须紧挨着查询语句调用中间不能插入其他数据库操作否则分页会作用到错误的 SQL 上。我见过有人把startPage写在方法开头中间查了别的表结果分页参数跑偏了。4.4 Thymeleaf 模板布局与片段复用Thymeleaf 最实用的功能就是片段复用。后台系统的页面结构高度一致把公共部分抽出来能省大量重复代码。我一般会建一个layout.html里面定义好头部、侧边栏、内容区的骨架内容区用一个th:fragment占位。其他页面通过th:replace引入这个布局把自己的内容填进去。!-- layout.html -- !DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head meta charsetUTF-8 title th:text${pageTitle}后台管理/title link relstylesheet th:href{/css/main.css} /head body div classheader th:replace~{fragments/header :: header}/div div classsidebar th:replace~{fragments/sidebar :: sidebar}/div div classcontent div th:replace${content}/div /div /body /html这种布局方式叫装饰器模式Thymeleaf 官方推荐配合thymeleaf-layout-dialect使用但纯用th:replace也能实现类似效果。新手我建议先用简单的th:replace把公共片段抽出来等熟悉了再上布局方言。菜单渲染是个重点。侧边栏菜单是树形结构Thymeleaf 里用嵌套的th:each遍历。一级菜单遍历一次每个一级菜单下面再遍历它的子菜单。判断当前访问的 url 是否等于菜单 url相等就加个高亮样式让用户知道自己在哪个页面。5. 开发过程中最容易踩的坑与排查实录5.1 常见问题速查表我把这些年踩过的坑整理成一张表遇到问题可以先对照排查问题现象可能原因解决方向页面 404模板路径不对 / Controller 映射不匹配检查spring.thymeleaf.prefix配置和RequestMapping模板改了不生效模板缓存没关配置spring.thymeleaf.cachefalse静态资源 404拦截器拦截了静态资源拦截器白名单加上/css/**等分页总数不对PageHelper 调用位置错误startPage紧贴查询语句中文乱码数据库连接没指定编码JDBC url 加characterEncodingutf8登录后立刻掉线Session 超时太短配置server.servlet.session.timeout表单提交 400参数类型不匹配检查 DTO 字段类型和表单 nameMyBatis 找不到 Mapper没加MapperScan启动类加注解指定包路径5.2 几个典型问题的深度排查问题一Thymeleaf 页面报错但看不出具体原因。Thymeleaf 的报错信息有时候很含糊只说模板解析错误不告诉你是哪一行。这时候可以打开 DEBUG 日志在配置文件里把logging.level.org.thymeleaf设成 DEBUG它会打印出详细的解析过程。另一个技巧是先把模板里的动态表达式一个个注释掉定位到具体是哪段出的问题。问题二MyBatis 的驼峰映射失效。数据库字段是create_time实体类属性是createTime正常情况下 MyBatis 会自动映射但有时候查出来是 null。原因是驼峰映射没开启。在配置文件里加上mybatis.configuration.map-underscore-to-camel-casetrue就能解决。这个配置我建议一开始就加上能省很多事。问题三事务不生效。Service 方法上加了Transactional但数据没回滚。最常见的原因是方法不是 public 的或者同类内部方法调用A 方法调 B 方法B 上有事务注解。Spring 的事务是基于代理的内部调用不走代理事务自然不生效。解决办法是把 B 方法抽到另一个 Service 里或者注入自己的代理对象。问题四分页查询后总数不对。PageHelper 返回的PageInfo里有个total字段正常情况下是总记录数。但如果你的查询语句里有GROUP BY或者复杂的子查询PageHelper 自动生成的 count 语句可能算错。这时候需要手动写 count 查询用PageInfo的构造方法传入自定义的总数。5.3 独家避坑经验经验一开发阶段一定要开 SQL 日志。在配置文件里把 MyBatis 的日志实现设成org.apache.ibatis.logging.stdout.StdOutImpl这样每条执行的 SQL 和参数都会打印到控制台。排查问题时一眼就能看出 SQL 拼得对不对、参数传得对不对。上线前记得关掉否则日志量太大。经验二表单提交前先做前端校验后端也要再校验一遍。前端校验是为了用户体验后端校验是为了安全。永远不要相信前端传来的数据因为请求可以被伪造。用spring-boot-starter-validation的注解NotBlank、Size等在 DTO 上做校验Controller 参数加Valid校验失败会自动返回错误信息。经验三删除操作一定要加二次确认。不管是前端弹窗确认还是后端加逻辑删除目的都是防止误删。我见过有人手滑点错把整个用户表清空了只能从备份恢复。逻辑删除是最后的保险。经验四菜单权限和接口权限要分开控制。菜单权限控制的是用户能看到哪些菜单接口权限控制的是用户能调用哪些接口。只做菜单权限是不够的因为用户可以直接输 url 访问。两者都要做才能形成完整的权限闭环。6. 项目扩展方向与个人实操体会这套基础架子搭好之后能扩展的方向其实很多。比如加一个操作日志模块用 AOP 拦截所有增删改操作记录操作人、操作时间、操作内容出问题时能追溯。再比如加一个定时任务模块用 Spring 的Scheduled处理一些周期性的数据统计。还有文件上传、数据导出 Excel、邮件通知这些都是后台系统的常见需求按需往上加就行。我个人在实际操作中的体会是后台系统的难点从来不在技术本身而在需求的边界。一开始想清楚哪些功能要做、哪些不做比闷头写代码重要得多。我见过太多项目做着做着需求不断膨胀最后变成一个四不像。所以我的建议是先用这套方案搭一个最小可用的版本把核心流程跑通然后再根据实际使用中的反馈逐步迭代。最后再分享一个小技巧Thymeleaf 的模板可以配置成从文件系统加载而不是 classpath这样改完 HTML 不用重启应用刷新页面就能看到效果。配置方式是设置spring.thymeleaf.prefixfile:./src/main/resources/templates/开发阶段非常提效。上线时再改回 classpath 模式保证打包后的资源路径正确。