做过后台管理系统的人大概都遇到过一个岔路口:一个只有十来个人用的内部工具,需求三天两头改,前端要做的其实就是列表、表单、弹窗、删除确认这几件事,这时候如果上来就搞前后端分离,光是接口联调、跨域、权限透传、部署两套产物就能耗掉大半工期。我前阵子接手的一个运维台账系统就是这么个场景,十来个页面,二十多张表,交付周期四周,最后定下来的技术组合就是标题里这四样:SpringBoot 打底、MyBatis-Plus 干数据层、Thymeleaf 渲染页面、AdminLTE 套壳。整套东西跑起来,增删改查这类活儿的开发节奏会非常快,一个标准模块从建表到页面能点,熟练的话两三个小时就能出一版。
这篇内容想聊的就是这套组合在真实项目里的落地细节,不是官方文档的复述。适合的人大概有三类:刚接触 SpringBoot 想做点完整东西练手的;手上有个内部系统要快速交付、不想被前端工程化拖住的;以及已经会用这套栈但总在分页、表单回显、静态资源这些地方反复卡壳的。我会把依赖版本的坑、MyBatis-Plus 分页插件的正确姿势、AdminLTE 接 Thymeleaf 的布局复用方式、还有增删改查四个动作里那些文档不会写的细节,一条一条摊开讲。中间穿插的配置和代码都是可以直接抄去改的,遇到需要取舍的地方我会说清楚为什么这么选。
1. 先想清楚这套四件套各自的边界在哪
1.1 服务端渲染在内部系统里的性价比账
选型这件事,最怕的是拿互联网 C 端的标准去套内部管理系统。C 端产品用户量大、交互复杂、首屏性能敏感,前后端分离带来的收益能覆盖掉工程复杂度。但内部台账、审批、配置管理这类系统,页面数量有限、交互模式高度重复、用户就那几十个,首屏慢个三百毫秒没人在意,反而更在意"这个字段能不能加一列""这个查询条件能不能多一个"这种需求变更的速度。
服务端渲染在这个场景下的优势很直接:一个 Controller 方法配一个模板,数据从数据库到页面是一条直线,没有中间层。改一个字段,动的是实体类、Mapper 不用动、模板加一行、Controller 加一个取值,五分钟的事。换成前后端分离,同样的改动要动 DTO、接口、前端 model、前端表单、可能还有类型定义,五个文件起步,还得等前端同学排期。
代价也要说清楚。Thymeleaf 的模板语法是在 HTML 属性上做文章,逻辑复杂了会很难看,超过三层的条件嵌套基本就没法维护了;局部刷新能力弱,想要个下拉联动、表格无刷新编辑,就得自己写零散的 JS;SEO 完全谈不上,不过内部系统也不需要。我的判断标准很简单:如果这个系统的主要使用者是公司内部员工、页面总数在五十个以内、没有复杂的前端状态管理需求,这套组合就是划算的;如果要做的是面向外部用户的产品,或者有大量实时交互,那就老老实实上分离架构。
1.2 四个组件各管一段,别让职责串味
实际写代码时最常见的混乱,是把本该在 Service 层的逻辑塞进 Controller,或者把查询条件拼装散落在模板里。这套栈里四样东西的职责其实很清晰:
| 组件 | 负责的事 | 不该干的事 |
|---|---|---|
| SpringBoot | 容器、依赖注入、配置、事务边界 | 不写业务规则 |
| MyBatis-Plus | 单表 CRUD、条件构造、分页、逻辑删除 | 不处理展示格式、不做权限判断 |
| Thymeleaf | 数据渲染、条件展示、循环、格式化 | 不拼查询条件、不做业务计算 |
| AdminLTE | 页面骨架、样式、常用交互组件 | 不承载业务逻辑 |
一个典型的分层写法是:Controller 只做参数接收、调用 Service、往 Model 里塞数据、返回视图名;Service 管事务和业务规则,比如"用户名不能重复""删除前要检查有没有关联数据";Mapper 只管数据访问。Thymeleaf 模板里只做展示判断,比如状态字段是 0 还是 1 决定显示什么颜色的标签。这个边界守住了,后面加需求才不会牵一发动全身。
1.3 什么时候该果断放弃这套方案
我不太喜欢"某某技术已死"这种论调,但确实有几类需求不适合硬套。一是需要大量图表实时刷新的看板,Thymeleaf 渲染完就定死了,后续全靠手写 JS 轮询,不如直接上图表库配接口;二是移动端适配要求高的场景,AdminLTE 是桌面优先的布局,虽然响应式做了,但在手机上体验一般;三是需要多人协同编辑同一份数据的场景,服务端渲染那套表单提交模型处理不了冲突合并。碰到这三类,趁早换方案,别等到写了一半再返工。
2. 骨架搭建:依赖顺序和版本对齐比想象中重要
2.1 pom 里的依赖清单,版本别随手写
新建工程的时候,最容易出事的就是版本。SpringBoot 版本、MyBatis-Plus 版本、Thymeleaf 版本三者之间有隐性约束,尤其是 MyBatis-Plus,它跟 SpringBoot 的适配是按大版本对应的,3.5.x 系列对 SpringBoot 2.x 和 3.x 的支持情况不一样。我的做法是先用 Spring Initializr 生成一个干净的骨架,选定 SpringBoot 版本,然后把 MyBatis-Plus 的版本号显式写上,不依赖父 POM 的默认管理。
<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这里有两个细节值得单独拎出来。第一,spring-boot-starter-validation必须单独引入,SpringBoot 2.3 之后校验相关的包已经从 web starter 里剥离了,不引这个,@Valid注解会静默失效——注意是静默,不报错,表单提交什么校验都不触发,很容易查半天。第二,MySQL 驱动在较新的 SpringBoot 里坐标改成了com.mysql:mysql-connector-j,老项目里常见的mysql:mysql-connector-java虽然还能用,但会有告警,新项目直接写新的。
如果你用的是 SpringBoot 3.x,MyBatis-Plus 的 starter 也要换成mybatis-plus-spring-boot3-starter,这个坐标差异在官方文档里不算显眼,但包名不一样,直接复制 2.x 的配置会启动失败,报找不到自动配置类。
2.2 application.yml 里必须动的那几项
配置文件这块,默认值大多能用,但有四项我每次都会改:
server: port: 8080 servlet: encoding: charset: UTF-8 force: true spring: thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html mode: HTML datasource: url: jdbc:mysql://127.0.0.1:3306/demo_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: demo password: demo123 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:/mapper/**/*.xmlthymeleaf.cache在开发期一定要关掉,这个后面还会展开讲,它是最容易让人怀疑人生的一个配置。map-underscore-to-camel-case默认是 true,但显式写出来能避免接手别人项目时的困惑。log-impl打开 SQL 打印,调试期非常有用,上线前记得关掉,不然日志量会很吓人。serverTimezone这个参数不能省,省了在写入LocalDateTime的时候可能差八小时,而且是那种"数据看着对、时间就是不对"的诡异问题。
数据源连接串里的allowPublicKeyRetrieval=true是配合较新版本 MySQL 的认证插件用的,不加的话在某些环境下会报公钥检索失败。这个参数涉及安全边界,只在本地和内网开发环境用,生产环境按各自的数据库规范走。
3. MyBatis-Plus 数据层:从实体映射到分页查询的完整链路
3.1 实体类和表结构的映射约定
MyBatis-Plus 默认有一套约定:类名转下划线对应表名,字段名转下划线对应列名。遵守约定能省掉大量注解。我在项目里通常就三条规则:表名统一加业务前缀(比如sys_user、biz_order),主键统一叫id且自增,每张表都带create_time、update_time、deleted三个公共字段。
@Data @TableName("sys_user") public class SysUser { @TableId(type = IdType.AUTO) private Long id; private String username; private String realName; private String phone; private Integer status; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; @TableLogic private Integer deleted; }@TableLogic这个注解是逻辑删除的核心。加上它之后,所有通过 MyBatis-Plus 生成的删除语句都会自动变成UPDATE ... SET deleted = 1,查询语句会自动追加deleted = 0。这个机制的好处是业务代码里完全感知不到删除的实现方式,坏处是有几个场景会失效:手写 XML 里的 SQL 不会自动加条件,需要自己补;update语句也不会自动过滤已删除数据;还有就是唯一索引要重新设计——如果username建了唯一索引,删掉一个用户之后想再建同名用户就会冲突,因为那条软删除的记录还在表里。
软删除的唯一索引问题,常见的解法是把deleted字段设计成"删除时写入主键 ID"而不是固定写 1,这样联合唯一索引(username, deleted)就能保证只有一条未删除记录。不过这样配置就复杂一些,小项目里我一般直接放弃用户名唯一索引,改在 Service 层做查重判断,简单够用。
自动填充需要配一个处理器:
@Component public class MetaHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }注意strictUpdateFill只对@TableField(fill = FieldFill.INSERT_UPDATE)标注的字段生效,如果实体类漏了这个注解,填充不会执行,而且不会报错。
3.2 分页插件必须先注册,否则分页永远返回全量
这是我见过最多人踩的坑。MyBatis-Plus 从 3.4 开始,分页功能必须手动注册拦截器,不注册的话Page对象传进去也不报错,但total永远是 0、records永远是全表数据。很多人第一次遇到会以为是Page参数位置传错了,查半天。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(500L)是个保护措施,防止前端传个size=999999把数据库拖死。setOverflow(false)的意思是页码超出范围时返回空列表而不是回到第一页,这个选择看业务习惯,我倾向于 false,因为回到第一页会让用户以为自己点错了。
分页查询配合条件构造器的典型写法:
public IPage<SysUser> pageQuery(UserQuery query, long current, long size) { Page<SysUser> page = new Page<>(current, size); LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>(); wrapper.like(StringUtils.hasText(query.getUsername()), SysUser::getUsername, query.getUsername()) .eq(query.getStatus() != null, SysUser::getStatus, query.getStatus()) .ge(query.getBeginTime() != null, SysUser::getCreateTime, query.getBeginTime()) .le(query.getEndTime() != null, SysUser::getCreateTime, query.getEndTime()) .orderByDesc(SysUser::getId); return this.page(page, wrapper); }LambdaQueryWrapper比QueryWrapper好在字段名是方法引用,改字段名的时候编译器会报错,字符串写法的QueryWrapper只能等到运行时才发现列名写错了。条件构造器里每个方法都有个重载版本,第一个参数是布尔值,用来控制这个条件是否生效,这个特性在写动态查询时极其好用,能避免大量 if 判断。
3.3 多条件搜索的拼接逻辑应该放在哪
条件拼接的位置有两派做法:放 Controller 里用参数逐个判断,或者放 Service 里封装成一个方法。我倾向后者,Controller 就负责把请求参数打包成一个查询对象传下去。这样做的另一个好处是查询参数可以做成一个独立的类,字段上直接挂校验注解和日期格式注解。
@Data public class UserQuery { private String username; private Integer status; @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate beginTime; @DateTimeFormat(pattern = "yyyy-MM-dd") private LocalDate endTime; }@DateTimeFormat这个注解是给表单绑定用的,处理的是从页面提交上来的字符串到日期对象的转换。如果是 JSON 接口,用的是@JsonFormat,两个注解长得像但作用域完全不同,混用会导致日期绑不上、抛 400 错误。这个区别我在带新人的时候解释过很多遍,本质上就是数据来源不同:表单走的是 Spring 的ConversionService,JSON 走的是 Jackson。
4. AdminLTE 接 Thymeleaf:静态资源、布局复用和脚本顺序
4.1 静态资源目录怎么摆,路径怎么写才不打架
AdminLTE 是一套基于 Bootstrap 的后台模板,下载下来是个完整的前端工程,里面有dist、plugins两个主要目录。放进 SpringBoot 项目的正确位置是src/main/resources/static/,放进去之后通过/dist/css/adminlte.min.css这样的路径就能访问。
目录结构我一般是这么组织的:
resources/ static/ dist/ AdminLTE 的 css、js plugins/ 各种插件,fontawesome、jquery、bootstrap uploads/ 用户上传的图片,运行时生成 templates/ layout/ 公共片段 user/ 业务页面 login.html有个细节很容易被忽略:SpringBoot 的静态资源默认映射到/**,如果模板里写的是<link th:href="@{/dist/css/adminlte.min.css}">,加了@{}语法之后,SpringBoot 会自动处理上下文路径。如果你的应用部署在非根路径下(比如server.servlet.context-path=/admin),不用@{}写死的相对路径全部会 404。这个坑在本地开发时完全发现不了,因为本地默认是根路径。
上传目录的问题要单独说一下。static/uploads这个位置在打成 jar 包之后是只读的,往里写文件会失败。生产环境应该把上传路径配成一个外部目录,通过WebMvcConfigurer加资源映射:
@Configuration public class WebConfig implements WebMvcConfigurer { @Value("${app.upload-path}") private String uploadPath; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:" + uploadPath); } }这样页面里引用/uploads/xxx.png就能拿到外部目录里的文件,开发和生产的路径差异用配置文件隔离。
4.2 用 fragment 做布局复用,别引额外的方言包
Thymeleaf 复用页面的方式有好几种,官方推荐的thymeleaf-layout-dialect用起来确实优雅,但它多一个依赖,语法也自成一套,团队里如果新人多,学习成本不太划算。我一般用原生的 fragment 组合来实现。
先定义公共片段:
<!-- templates/layout/common.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head th:fragment="head(title)"> <meta charset="utf-8"> <title th:text="${title}">页面标题</title> <link th:href="@{/plugins/fontawesome-free/css/all.min.css}" rel="stylesheet"> <link th:href="@{/dist/css/adminlte.min.css}" rel="stylesheet"> </head> <body> <nav th:fragment="navbar" class="main-header navbar navbar-expand navbar-white navbar-light"> <ul class="navbar-nav"> <li class="nav-item"> <a class="nav-link"><!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head th:replace="~{layout/common :: head('用户管理')}"></head> <body class="hold-transition sidebar-mini layout-fixed"> <div class="wrapper"> <nav th:replace="~{layout/common :: navbar}"></nav> <aside th:replace="~{layout/common :: sidebar}"></aside> <div class="content-wrapper"> <section class="content-header"> <div class="container-fluid"> <h1>用户管理</h1> </div> </section> <section class="content"> <div class="container-fluid"> <!-- 业务内容 --> </div> </section> </div> <footer th:replace="~{layout/common :: footer}"></footer> </div> </body> </html>th:replace和th:insert的区别要说一下:replace是用片段整体替换掉当前标签,insert是把片段塞进当前标签内部。用replace的时候当前标签本身会被丢掉,所以写法上要保证被替换的标签类型跟片段的根标签一致。
侧边栏的菜单高亮,靠当前路径判断:
<a th:href="@{/user/list}" class="nav-link" th:classappend="${#httpServletRequest.requestURI.startsWith('/user')} ? 'active'">#httpServletRequest这个对象在 Thymeleaf 3 里默认是可用的,不过在严格的安全配置下可能取不到,取不到就退回用手动传一个activeMenu变量的方式,稳一些。
4.3 脚本加载顺序不对,页面会静默失灵
AdminLTE 依赖 jQuery、Bootstrap、popper,这几个的顺序不能乱。正确的写法是 jQuery 在最前,然后 bootstrap 的 bundle 版本(自带 popper),最后才是 adminlte 的主脚本:
<script th:src="@{/plugins/jquery/jquery.min.js}"></script> <script th:src="@{/plugins/bootstrap/js/bootstrap.bundle.min.js}"></script> <script th:src="@{/dist/js/adminlte.min.js}"></script>顺序错了的典型现象是:折叠菜单点不开、下拉框点一下闪一下就关、弹窗出不来。控制台可能只有一条$ is not defined,或者干脆什么都没有。我遇到过最隐蔽的一次是有人把 adminlte.min.js 放到了<head>里,页面骨架还没渲染完脚本就执行了,界面看着正常,但所有交互事件都没绑上。所以脚本统一放</body>前,这是硬规矩。
还有个版本匹配的坑:AdminLTE 3 基于 Bootstrap 4,AdminLTE 4 基于 Bootstrap 5。如果从网上复制了个 AdminLTE 3 的页面模板,又去引了 Bootstrap 5 的 CSS,会出现间距全乱、圆角对不上、按钮颜色偏色这类"看着像但又不太像"的问题。这种情况我的建议是,一旦确定用哪个版本,把整套静态资源一次性拷全,不要东拼西凑。
5. 增删改查四个动作,真正花时间的都在细节里
5.1 列表页:分页、搜索条件回填与参数保持
列表页看着最简单,实际上坑最多。分页组件要跟搜索条件共存,翻到第二页的时候搜索条件不能丢,这个是最基本的要求。我的做法是 Controller 接收查询对象和分页参数,把所有条件重新塞回 Model:
@GetMapping("/list") public String list(UserQuery query, @RequestParam(defaultValue = "1") long current, @RequestParam(defaultValue = "10") long size, Model model) { IPage<SysUser> page = userService.pageQuery(query, current, size); model.addAttribute("page", page); model.addAttribute("query", query); return "user/list"; }模板里的分页链接必须带上搜索条件:
<nav> <ul class="pagination pagination-sm"> <li class="page-item" th:classappend="${page.current <= 1} ? 'disabled'"> <a class="page-link" th:href="@{/user/list(current=${page.current - 1}, size=${page.size}, username=${query.username}, status=${query.status})}">上一页</a> </li> <li class="page-item" th:each="i : ${#numbers.sequence(1, page.pages)}" th:classappend="${i == page.current} ? 'active'"> <a class="page-link" th:href="@{/user/list(current=${i}, size=${page.size}, username=${query.username}, status=${query.status})}" th:text="${i}">1</a> </li> <li class="page-item" th:classappend="${page.current >= page.pages} ? 'disabled'"> <a class="page-link" th:href="@{/user/list(current=${page.current + 1}, size=${page.size}, username=${query.username}, status=${query.status})}">下一页</a> </li> </ul> </nav>@{...}语法里括号内的参数会自动做 URL 编码,不用手动处理中文。这是 Thymeleaf 一个很实用的特性,用字符串拼接的方式拼 URL 反而容易出问题。
页码直接用#numbers.sequence全展开,在数据量小的内部系统里够用。如果分页超过二十页,这个写法会把页码撑爆,需要做滑动窗口,那就得写个小工具方法或者在 Controller 里算好beginPage和endPage传给模板。内部系统我一般不管,页面撑开了用户自己会缩小范围。
搜索表单的回填也要注意,th:value里如果原值是 null,渲染出来是空字符串,不会报错,但下拉框要注意选中状态:
<select name="status" class="form-control"> <option value="">全部状态</option> <option value="1" th:selected="${query.status == 1}">启用</option> <option value="0" th:selected="${query.status == 0}">停用</option> </select>这里有个类型陷阱:query.status是Integer,如果表单里value="1",Thymeleaf 在比较时做的是对象比较,Integer(1) == 1在 Thymeleaf 的表达式引擎里通常能自动拆箱成立,但如果 status 是String类型就会失败。我在项目里统一用Integer接状态值,避免这个歧义。
5.2 新增和编辑:表单复用、校验失败回显与查重时机
新增和编辑的页面高度相似,字段完全一样,区别只是有没有初始值。复用同一个模板是标准做法,用一个id是否为空来判断:
@GetMapping("/form") public String form(@RequestParam(required = false) Long id, Model model) { SysUser user = id == null ? new SysUser() : userService.getById(id); model.addAttribute("user", user); return "user/form"; }模板里所有th:value都绑到user对象的字段上,新增时是空对象,自然是空值。
保存用两个方法分别处理新增和修改,不要试图用一个方法通用,因为校验规则不一样(新增要查重,修改要排除自己)。校验这块:
@PostMapping("/save") public String save(@Valid @ModelAttribute("user") SysUser user, BindingResult bindingResult, RedirectAttributes ra) { if (userService.existsUsername(user.getUsername(), user.getId())) { bindingResult.rejectValue("username", "duplicate", "该登录名已被占用"); } if (bindingResult.hasErrors()) { return "user/form"; } userService.saveOrUpdate(user); ra.addFlashAttribute("msg", "保存成功"); return "redirect:/user/list"; }@ModelAttribute("user")里的名字很关键,它决定了模板里用${user}能不能取到值。如果校验失败返回表单页,Model 里必须有这个对象,@ModelAttribute会自动把它塞进去。这一点如果漏了,表现就是校验失败跳回表单页,结果所有已填内容全空,用户得重新填一遍,体验极差。
RedirectAttributes的addFlashAttribute是重定向后传递提示消息的标准做法,数据存在 session 里,跳转后读一次就没了。如果用addAttribute,参数会拼到 URL 上,中文还得编码,很难看。
表单模板里的错误提示:
<div class="form-group"> <label>登录名 <span class="text-danger">*</span></label> <input type="text" class="form-control" th:classappend="${#fields.hasErrors('user.username')} ? 'is-invalid'" th:field="*{username}"> <div class="invalid-feedback" th:if="${#fields.hasErrors('user.username')}" th:errors="*{username}">错误信息</div> </div>th:field="*{username}"会自动生成id、name、value三个属性,比手写th:value更省事,而且跟#fields的错误机制是配套的。用th:field的时候注意必须在<form>上挂th:object,否则*{}语法找不到上下文。
查重的时机也有讲究。放在 Service 里做也行,但那样校验失败的信息拿不到BindingResult,只能抛异常或者返回错误码,页面回显就得另想办法。放在 Controller 里配合bindingResult.rejectValue是这套栈下最顺手的做法,错误信息能直接出现在对应字段下面。
5.3 删除:单条、批量与逻辑删除的取舍
删除功能最容易被轻视。一个@GetMapping("/delete/{id}")写完就完事了,看着能跑,但这是典型的错误做法——GET 请求被浏览器预加载、被爬虫抓取、被用户误刷新都能触发删除。删必须用 POST。
@PostMapping("/delete") public String delete(@RequestParam Long id, RedirectAttributes ra) { userService.removeById(id); ra.addFlashAttribute("msg", "删除成功"); return "redirect:/user/list"; } @PostMapping("/batchDelete") public String batchDelete(@RequestParam("ids") List<Long> ids, RedirectAttributes ra) { userService.removeByIds(ids); ra.addFlashAttribute("msg", "已删除 " + ids.size() + " 条"); return "redirect:/user/list"; }前端确认用 AdminLTE 自带的模态框或者confirm,批量删除要注意一个细节:如果用户一条都没勾选就点删除,ids参数是空的,Spring 绑定List<Long>会直接抛 400。稳妥的写法是@RequestParam(value = "ids", required = false),进来先判空:
if (CollectionUtils.isEmpty(ids)) { ra.addFlashAttribute("msg", "请先选择要删除的记录"); return "redirect:/user/list"; }逻辑删除的取舍前面提过一部分,这里补充一个实际场景:如果业务上需要"回收站"功能,逻辑删除是必须的;如果数据完全不敏感,物理删除反而干净,因为软删除的记录日积月累会让表越来越大,查询还得带条件。我的经验是,涉及人员、订单、财务这类数据的表用逻辑删除,配置项、日志这类表直接物理删。混着用没问题,加不加@TableLogic是按实体类控制的。
6. 联调阶段最容易卡住的几个现象与排查顺序
6.1 模板改了页面不变,先看缓存开关
这个现象我第一次遇到时怀疑过 IDE 没保存、怀疑过浏览器缓存、怀疑过 SpringBoot 没热加载,折腾了快一个小时,最后发现就是spring.thymeleaf.cache=true。开发环境必须设成 false,这个配置默认值在 SpringBoot 里是 true。
即使关了缓存,如果你改了模板但没重新编译,而项目又是从target/classes里读模板的,那还是看不到变化。IDEA 里的做法是 Build 一次,或者用spring-boot-devtools配合自动编译。我用 devtools 的时候会把spring.thymeleaf.prefix指向file:src/main/resources/templates/,直接从源码目录读,改完刷新就生效,省掉编译这一步:
spring: thymeleaf: prefix: file:src/main/resources/templates/ cache: false这个配置只能用在开发环境,生产环境必须走 classpath。
另外一个容易混淆的点:改了 Java 代码之后,模板的变动可能被编译覆盖。排查顺序我一般是"配置文件有没有生效 → 模板路径对不对 → 编译产物是不是最新的",从外往里查,比一上来就打断点快。
6.2 日期和数字的格式化,Thymeleaf 里要靠工具类
LocalDateTime直接输出到页面会是2024-03-15T10:23:45,中间带个 T,很难看。格式化的写法:
<td th:text="${#temporals.format(user.createTime, 'yyyy-MM-dd HH:mm')}">时间</td>#temporals是 Thymeleaf 3 内置的处理 Java 8 时间类型的工具,需要thymeleaf-extras-java8time依赖,不过 SpringBoot 的 starter 里已经包含了,不用单独引。如果是Date类型,用的是#dates.format,两个工具类不能混用,混了会报找不到方法。
空值处理是另一个高频问题。${user.realName}如果是 null,页面直接不渲染内容,不会报错,但#temporals.format(null, ...)会抛异常。稳妥的写法是:
<td th:text="${user.createTime != null} ? ${#temporals.format(user.createTime, 'yyyy-MM-dd HH:mm')} : '-'">-</td>金额类的字段用#numbers.formatDecimal(amount, 1, 2)保留两位小数,注意第一个参数是整数最少位数,不是总位数,这个参数含义经常被搞反。
6.3 静态资源 404 与中文乱码的分层排查
静态资源 404 的排查我有一套固定顺序。先看 URL 对不对,浏览器 F12 的网络面板里能看到实际请求的路径,如果路径里带了../或者重复的/static/,说明模板里的路径写错了——@{}语法不需要你手写/static,它对应的就是static目录的根。再看 Spring Security 有没有拦截,如果项目集成了安全框架,静态资源默认是要放行的,配置里漏了requestMatchers("/dist/**", "/plugins/**").permitAll()就会全 404。最后看打包方式,jar 包里的static目录是通过 classpath 访问的,如果资源放的目录名不是static(比如放成了public或者resources),那 SpringBoot 也能识别,但目录名拼错就完了。
中文乱码分两种。一种是请求参数乱码,表单提交的中文到后端变成问号,这是编码配置问题,server.servlet.encoding.force=true加上页面的charset=utf-8基本能解决。另一种是响应内容乱码,页面上中文变成方块或者乱码,通常是模板文件本身的编码不对,IDEA 里在文件右下角能看到当前编码,确认是 UTF-8 就行。还有一种最隐蔽的,数据库连接的characterEncoding写成了utf8但表是utf8mb4,存表情符号的时候报错或者截断,这个只能从建表语句开始统一。
6.4 表单提交后提示信息不显示
addFlashAttribute存进去的消息,在页面里要用${msg}取。取不到的情况一般是重定向的地址跟存放的会话不一致,或者页面模板里漏了判断:
<div class="alert alert-success alert-dismissible" th:if="${msg != null}"> <button type="button" class="close">