1. 项目概述:为什么说“SpringBoot + Thymeleaf”是前后端分离的“伪分离”?
“SpringBoot + Thymeleaf”组合在2020年前后曾是Java Web开发的主流轻量级方案,尤其适合中小型企业内部系统、管理后台、教育平台原型、高校课程设计等场景。它不是真正意义上的前后端分离——前端不独立部署、不通过REST API与后端通信、不使用Vue/React构建单页应用(SPA),而是由SpringBoot统一处理HTTP请求,Thymeleaf在服务端完成HTML模板渲染后,将完整页面返回浏览器。这种模式常被开发者戏称为“伪分离”:它保留了传统MVC的清晰分层(Controller负责跳转、Model封装数据、View负责展示),又借用了现代框架的自动配置与开发效率,但本质上仍是服务端渲染(SSR)架构。
我带过几届某高校软件工程方向的实训项目,学生第一次接触Web开发时,90%以上都会从这个组合起步。原因很实在:不用配Webpack、不用学路由守卫、不用处理CORS、不用调试跨域请求头、连Ajax错误都少一半。一个Controller方法return "user/list",Thymeleaf就自动去templates/user/list.html里找模板,把model里的List 塞进去,生成带真实数据的HTML发给浏览器——整个链路透明、可控、可断点、可单步调试。对刚学完Servlet/JSP的学生来说,这就像从手摇电话升级到按键式座机,体验提升巨大。
但“伪分离”的代价也真实存在:前端逻辑被硬编码进HTML模板(比如用th:if嵌套五层判断)、CSS和JS资源无法按需加载、SEO优化能力弱于纯静态站点、移动端适配成本高、团队协作中前端同学难以介入模板开发(Thymeleaf语法虽简洁,但本质仍是Java逻辑混入HTML)。更关键的是,当业务增长到需要App、小程序、H5多端共用同一套数据接口时,这套架构会立刻暴露短板——你得把所有Controller里的ModelAndView拆成@RestController + @ResponseBody,再补一层DTO转换,而原有Thymeleaf模板几乎全部作废。
所以,“伪分离”不是技术倒退,而是一种有明确适用边界的务实选择:它适合MVP验证、交付周期紧、团队前端能力薄弱、或系统本身无复杂交互需求(如审批流、报表查看、设备监控看板)的场景。本文要讲的,就是如何把这套看似“过时”的组合,用得比纯前后端分离还稳、还快、还易维护——不是教你怎么淘汰它,而是教你如何让它在该发光的地方,真正扛住生产环境的连续30天高负载压测。
2. 架构设计与选型逻辑:为什么坚持用Thymeleaf而不是转向Vue?
2.1 真实项目中的技术选型决策树
很多初学者看到“伪分离”这个词,第一反应是“赶紧换Vue”。但我在某公司支撑过三个不同生命周期的系统,结论很反直觉:不是所有项目都需要前端工程化,也不是所有团队都适合承担工程化带来的协作成本。我们当时面临的选择是:
- 项目A:某地市级政务内网的公文流转系统,用户数约800人,年迭代需求<10个功能点,要求国产化适配(麒麟OS+达梦数据库),上线周期≤3周
- 项目B:某制造企业设备点检APP的配套PC管理后台,需对接IoT平台MQTT数据,前端需实时刷新设备状态卡片,预计未来6个月接入20+新厂区
- 项目C:某在线教育平台的教师端作业批改系统,含富文本编辑、图片拖拽上传、多人协同批注,日活教师超5000人
最终决策是:
✅ 项目A → SpringBoot + Thymeleaf(4天完成首版,国产化适配零修改)
⚠️ 项目B → SpringBoot + Thymeleaf(初期)→ 后期增量替换为Vue(仅新增模块)
❌ 项目C → 直接采用SpringBoot + Vue CLI(前后端完全解耦)
这个决策背后有三根硬性标尺:
- 交付确定性:Thymeleaf模板编译失败会在启动时报错,而Vue的webpack打包错误可能只在用户点击某个按钮时才暴露;
- 运维一致性:单jar包部署,运维只需关注一个JVM进程、一套日志、一种监控指标(GC、线程池、HTTP QPS),而前后端分离需同时盯Nginx、Node服务、Vue静态资源CDN、API网关多个环节;
- 安全收敛面:Thymeleaf默认开启HTML转义(th:text="${user.name}"不会执行JS),XSS防护由框架兜底;而Vue中v-html绑定若未严格校验来源,一个富文本字段就能打穿整站。
提示:别迷信“技术先进性”。我见过用SpringCloud + Vue做的系统,因前端团队误删了axios拦截器里的token续签逻辑,导致所有用户每2小时强制登出——这种问题在Thymeleaf里根本不存在,因为登录态全由Spring Security Session管理,模板里只写一句
<span sec:authentication="name">。
2.2 Thymeleaf的不可替代优势:服务端渲染的“确定性红利”
Thymeleaf最被低估的能力,是它把“页面状态”彻底交还给服务端控制。举个典型场景:权限菜单动态渲染。
在Vue方案中,你需要:
① 前端请求/user/menu获取JSON菜单树 → ② 前端解析JSON生成router.addRoutes() → ③ 处理异步路由懒加载 → ④ 权限指令v-permission做按钮级校验 → ⑤ 所有菜单图标、名称、排序还得单独维护i18n文件
而在Thymeleaf中,只需三步:
- Controller中查库生成MenuVo列表,放入model;
- 模板里用
<li th:each="menu : ${menus}">循环渲染; - 按钮级权限用
<button th:if="${#authorization.authorize('ROLE_ADMIN')}">删除</button>
整个过程没有网络延迟、没有竞态条件、没有缓存不一致——菜单数据和服务端Session状态永远100%同步。某次我们做等保测评,第三方扫描工具发现Vue后台存在“未授权访问菜单接口”的风险(因为菜单接口未加鉴权,仅靠前端隐藏),而Thymeleaf版本直接通过了——因为菜单数据根本不出现在任何API里,它只存在于服务端内存。
另一个常被忽视的优势是SEO友好性。虽然管理后台通常不需SEO,但如果你做的是面向公众的活动页(如“618大促专题页”),Thymeleaf生成的HTML源码自带完整标题、描述、结构化数据,百度爬虫能直接抓取。而Vue SPA首页是空的
,除非上SSR(Nuxt.js),否则SEO成本翻倍。2.3 “伪分离”的本质:分层清晰 ≠ 物理隔离
很多人混淆了“分层”和“分离”。MVC本身就是分层架构:Model(数据)、View(展示)、Controller(调度)。Thymeleaf完美实现了这三层的职责隔离:
- Model:Controller中new UserVo()并set属性,或调用Service返回DTO;
- View:HTML模板只负责“怎么展示”,不处理“从哪来”(不调API)和“往哪去”(不发POST);
- Controller:只做三件事——接收参数、调Service、返回视图名或ModelAndView。
这种分层带来的好处是:
🔹测试友好:Controller单元测试只需mock Service,验证返回的视图名和model数据是否正确,无需启动浏览器;
🔹热更新快:修改HTML模板后,devtools能秒级刷新,而Vue需重新build;
🔹调试直观:在Controller打断点,能看到model里每个字段的真实值;在模板里加<div th:text="${#ctx.beanNames}">,能直接打印Spring容器所有Bean——这种调试体验,前端工程师看了会沉默。
所以,“伪分离”的准确表述应是:前后端逻辑分离、职责分离、开发节奏分离,但部署形态与通信机制保持服务端渲染的一致性。它不是妥协,而是对特定场景的精准匹配。
3. 核心实现细节:让Thymeleaf不止于“模板渲染”
3.1 模板工程化:从手写HTML到模块化组件体系
新手常把Thymeleaf当成“高级JSP”,一个页面一个HTML文件,结果三个月后出现大量重复代码:每个页面都有相同的头部导航、侧边栏、页脚、弹窗模板。正确的做法是建立三级模板体系:
一级:基础布局模板(layout.html)
定义全局骨架,使用<th:block th:fragment="content">预留内容区:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title th:text="${title} ?: '系统后台'">默认标题</title> <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}"> </head> <body> <nav class="navbar">...</nav> <div class="container-fluid"> <main th:fragment="content">页面内容将插入此处</main> </div> <script th:src="@{/js/jquery.min.js}"></script> </body> </html>二级:功能模块模板(fragments/user-fragments.html)
封装可复用组件,如用户表格、搜索表单:
<!-- 用户列表表格 --> <th:block th:fragment="user-table(users)"> <table class="table"> <thead><tr><th>姓名</th><th>邮箱</th><th>操作</th></tr></thead> <tbody> <tr th:each="user : ${users}"> <td th:text="${user.name}">张三</td> <td th:text="${user.email}">zhang@example.com</td> <td> <a th:href="@{/user/edit(id=${user.id})}" class="btn btn-sm btn-primary">编辑</a> <button type="button" class="btn btn-sm btn-danger" th:onclick="'deleteUser('+${user.id}+')'">删除</button> </td> </tr> </tbody> </table> </th:block> <!-- 搜索表单 --> <th:block th:fragment="search-form(action)"> <form th:action="${action}" method="get"> <div class="input-group"> <input type="text" name="keyword" class="form-control" th:value="${keyword} ?: ''" placeholder="输入姓名或邮箱"> <button class="btn btn-outline-secondary" type="submit">搜索</button> </div> </form> </th:block>三级:具体页面(user/list.html)
通过th:replace和th:include组合使用:
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"> <head> <title>用户管理</title> </head> <body> <!-- 继承基础布局 --> <div layout:decorate="~{layout}"> <!-- 替换content片段 --> <div layout:fragment="content"> <!-- 引入搜索表单 --> <div th:include="~{fragments/user-fragments :: search-form('/user/list')}"></div> <!-- 引入用户表格 --> <div th:replace="~{fragments/user-fragments :: user-table(${users})}"></div> <!-- 分页组件(独立fragment) --> <div th:replace="~{fragments/pagination :: pagination(${pageInfo})}"></div> </div> </div> </body> </html>实操心得:
th:replace会完全替换标签自身,th:include只替换标签内容。比如<div th:include="...">,最终HTML里只剩include的内容,原div消失;而<div th:replace="...">,原div会被fragment里的根元素替代。这个细节决定DOM结构是否符合预期,我踩过两次坑——一次是引入的fragment里没写根div,导致页面错位;另一次是误用include导致CSS类名丢失。
3.2 动态URL与资源路径:告别硬编码的/static/js/
Thymeleaf的@{}表达式是解决路径问题的终极方案。新手常犯的错误是写死路径:
<!-- ❌ 危险!部署到子路径会404 --> <link href="/css/app.css" rel="stylesheet"> <script src="/js/main.js"></script> <a href="/user/list">用户列表</a>正确写法必须用@{}:
<!-- ✅ 自动适配context-path --> <link th:href="@{/css/app.css}" rel="stylesheet"> <script th:src="@{/js/main.js}"></script> <a th:href="@{/user/list}">用户列表</a> <!-- 带参数的URL --> <a th:href="@{/user/edit(id=${user.id},tab='profile')}">编辑资料</a> <!-- 绝对URL(用于邮件模板) --> <a th:href="@{https://example.com/user/{id}(id=${user.id})}">查看详情</a>原理很简单:@{}表达式在运行时由Thymeleaf的LinkBuilder解析,自动拼接server.servlet.context-path(如配置了/admin,则@{/user/list}生成/admin/user/list)。更重要的是,它支持Spring MVC的@RequestMapping别名:
@Controller @RequestMapping("/api/v1") public class UserController { @GetMapping("/users") public String listUsers(Model model) { model.addAttribute("users", userService.list()); return "user/list"; // 返回模板名,非URL } }此时@{/api/v1/users}会生成对应路径,但注意:Thymeleaf不解析Controller的@RequestMapping,它只解析URL路径字符串。所以更推荐在Controller中把完整URL放入model:
@GetMapping("/list") public String listUsers(Model model) { model.addAttribute("apiUrl", "/api/v1/users"); // 或用UriComponentsBuilder构建 return "user/list"; }模板中:<div th:data-api-url="${apiUrl}">,前端JS即可读取。
3.3 安全增强:不只是XSS防护,还有CSRF与权限细粒度控制
Thymeleaf的安全能力远超“自动转义”。默认情况下,th:text和th:utext的区别是核心安全边界:
th:text="${user.name}"→ 自动HTML转义,输出<script>alert(1)</script>会显示为纯文本;th:utext="${user.content}"→ 不转义,直接渲染HTML(需确保content来自可信源,如管理员后台富文本编辑器)。
但真正的安全防线在后端。Spring Security与Thymeleaf深度集成,通过thymeleaf-extras-springsecurity依赖提供专属方言:
<dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity5</artifactId> <!-- 注意版本需匹配Spring Boot版本 --> </dependency>模板中即可使用安全专用属性:
<!-- 只有ADMIN角色可见 --> <div sec:authorize="hasRole('ADMIN')"> <button class="btn btn-danger">批量删除</button> </div> <!-- 有指定权限才显示 --> <span sec:authorize="hasAuthority('user:delete')">删除</span> <!-- 权限表达式(需开启@EnableGlobalMethodSecurity) --> <div sec:authorize="hasPermission(#user, 'WRITE')"> <a th:href="@{/user/edit(id=${user.id})}">编辑</a> </div> <!-- 显示当前用户名 --> <span sec:authentication="name">当前用户</span> <!-- 显示用户自定义属性 --> <span sec:authentication="principal.username">用户名</span>注意:
sec:authentication获取的是SecurityContext中的Authentication对象,其principal可能是UserDetails(含username、authorities)或原始用户名字符串,取决于你的UserDetailsService实现。我建议统一返回UserDetails,这样模板里能直接取principal.email、principal.phone等扩展字段。
CSRF防护更简单:Thymeleaf会自动在表单中注入隐藏域。只要Controller返回的视图名对应HTML中有<form>标签,且未显式关闭CSRF,就会生成:
<form method="post" action="/user/save"> <input type="hidden" name="_csrf" value="abc123..."/> <!-- 其他字段 --> </form>Spring Security默认校验此token,无需额外代码。唯一要注意的是:AJAX请求需手动携带。在Thymeleaf模板中,可通过_csrf.token获取:
<script th:inline="javascript"> var csrfToken = [[${_csrf.token}]]; var csrfHeader = [[${_csrf.headerName}]]; $(document).ajaxSend(function(e, xhr, options) { xhr.setRequestHeader(csrfHeader, csrfToken); }); </script>3.4 静态资源优化:让Thymeleaf页面加载速度媲美Vue
很多人抱怨Thymeleaf“慢”,其实是没做资源优化。Spring Boot内置的静态资源处理能力极强,关键在配置:
第一步:启用资源链(Resource Chain)
在application.yml中:
spring: web: resources: chain: cache: true # 生产环境必须开启 strategy: content: # 基于内容哈希的版本控制 enabled: true效果:/js/app.js→/js/app-abc123.js,浏览器永久缓存,内容变更自动更新URL。
第二步:Gzip压缩
server: compression: enabled: true mime-types: text/html,text/css,application/javascript,application/json min-response-size: 1024第三步:模板缓存与热更新平衡
开发时关闭缓存(application-dev.yml):
spring: thymeleaf: cache: false check-template: true check-template-location: true生产时开启(application-prod.yml):
spring: thymeleaf: cache: true # 模板修改后最多10分钟生效,避免频繁IO template-resolver-order: 1 # 开启HTML5解析器(更严格,减少容错) mode: HTML实测数据:某后台系统首页HTML体积从287KB(未压缩)降至42KB(Gzip后),首屏时间从1.8s降至320ms。对比同功能Vue SPA(含vendor.js 1.2MB),Thymeleaf在弱网环境下优势明显——它不需要下载几百KB的JS再解析执行,而是直接渲染服务端已准备好的HTML。
4. 实战全流程:从零搭建一个可商用的Thymeleaf后台
4.1 初始化项目与依赖配置
使用Spring Initializr创建项目,勾选:
- Spring Web
- Spring Data JPA
- Thymeleaf
- Spring Security
- Lombok(简化POJO)
- DevTools(开发时热更新)
pom.xml关键依赖:
<dependencies> <!-- Web核心 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf模板引擎 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- 安全增强 --> <dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity5</artifactId> </dependency> <!-- 数据持久化 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <!-- 工具类 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>注意:
thymeleaf-extras-springsecurity5的版本必须与Spring Boot主版本匹配。Spring Boot 2.7.x用thymeleaf-extras-springsecurity5,3.x用thymeleaf-extras-springsecurity6。版本错配会导致sec:标签无法解析,页面报错却无明确提示——这是新手最常卡住的点,务必检查Maven Dependency Hierarchy。
4.2 用户管理模块:Controller、Service、Repository三层实现
实体类(User.java)
@Entity @Table(name = "t_user") @Data @NoArgsConstructor @AllArgsConstructor public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String username; @Column(nullable = false) private String password; // 实际项目应加密存储 @Column(unique = true, nullable = false) private String email; @Column(name = "is_enabled") private Boolean enabled = true; @Column(name = "created_time") @CreationTimestamp private LocalDateTime createdTime; }Repository(UserRepository.java)
@Repository public interface UserRepository extends JpaRepository<User, Long> { // Spring Data JPA自动生成实现 Optional<User> findByUsername(String username); Optional<User> findByEmail(String email); List<User> findByEnabledTrue(); // 查询启用用户 }Service(UserService.java)
@Service @Transactional @Slf4j public class UserService { @Autowired private UserRepository userRepository; public Page<User> listUsers(Pageable pageable, String keyword) { if (keyword == null || keyword.trim().isEmpty()) { return userRepository.findAll(pageable); } return userRepository.findAll( Example.of(new User().setUsername(keyword).setEmail(keyword)), pageable ); } public User saveUser(User user) { // 密码加密(实际用BCryptPasswordEncoder) user.setPassword("{noop}" + user.getPassword()); // 开发时临时方案 return userRepository.save(user); } public void deleteUser(Long id) { userRepository.deleteById(id); } }Controller(UserController.java)
@Controller @RequestMapping("/user") @Slf4j public class UserController { @Autowired private UserService userService; // GET /user/list -> 显示用户列表页 @GetMapping("/list") public String listUsers( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) String keyword, Model model) { Pageable pageable = PageRequest.of(page, size, Sort.by("createdTime").descending()); Page<User> userPage = userService.listUsers(pageable, keyword); // 封装分页信息供模板使用 model.addAttribute("users", userPage.getContent()); model.addAttribute("pageInfo", new PageInfo<>(userPage)); model.addAttribute("keyword", keyword); model.addAttribute("title", "用户管理"); return "user/list"; // 对应 templates/user/list.html } // POST /user/save -> 保存用户 @PostMapping("/save") public String saveUser(@Valid User user, BindingResult result, RedirectAttributes redirectAttrs) { if (result.hasErrors()) { // 校验失败,重定向回表单页并携带错误信息 redirectAttrs.addFlashAttribute("org.springframework.validation.BindingResult.user", result); redirectAttrs.addFlashAttribute("user", user); return "redirect:/user/add"; } userService.saveUser(user); redirectAttrs.addFlashAttribute("message", "用户添加成功!"); return "redirect:/user/list"; } }4.3 模板开发:从布局到交互的完整链路
基础布局(templates/layout.html)
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"> <head> <meta charset="UTF-8"> <title th:text="${title} ?: '后台管理系统'">系统标题</title> <!-- Bootstrap 5 CSS --> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <!-- 自定义CSS --> <link th:href="@{/css/admin.css}" rel="stylesheet"> </head> <body> <!-- 顶部导航 --> <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> <div class="container-fluid"> <a class="navbar-brand" href="#" th:text="${title} ?: '系统'">系统</a> <div class="navbar-nav ms-auto"> <span class="navbar-text me-3" sec:authentication="name">欢迎,[[${#authentication.name}]]</span> <a class="nav-link" href="#" onclick="logout()">退出</a> </div> </div> </nav> <!-- 主体内容区 --> <div class="container-fluid mt-3"> <main th:fragment="content"> <!-- 页面内容将插入此处 --> </main> </div> <!-- 底部 --> <footer class="bg-light py-3 mt-5"> <div class="container text-center"> <span>© 2023 系统后台</span> </div> </footer> <!-- Bootstrap JS --> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <!-- jQuery(Bootstrap 5已不依赖,但部分插件需要) --> <script th:src="@{/js/jquery.min.js}"></script> <!-- 自定义JS --> <script th:src="@{/js/admin.js}"></script> <!-- 退出登录JS --> <script th:inline="javascript"> /*<![CDATA[*/ function logout() { if (confirm('确定要退出登录吗?')) { window.location.href = /*[[@{/logout}]]*/ '/logout'; } } /*]]>*/ </script> </body> </html>用户列表页(templates/user/list.html)
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout}"> <head> <title>用户管理</title> </head> <body> <div layout:fragment="content"> <!-- 操作栏 --> <div class="d-flex justify-content-between align-items-center mb-3"> <h2>用户管理</h2> <a href="/user/add" class="btn btn-primary">+ 新增用户</a> </div> <!-- 搜索表单 --> <div class="card mb-3"> <div class="card-body"> <form th:action="@{/user/list}" method="get"> <div class="input-group"> <input type="text" name="keyword" class="form-control" th:value="${keyword} ?: ''" placeholder="按姓名或邮箱搜索"> <button class="btn btn-outline-secondary" type="submit">搜索</button> <a th:href="@{/user/list}" class="btn btn-outline-secondary">重置</a> </div> </form> </div> </div> <!-- 用户表格 --> <div class="card"> <div class="card-header"> <h5 class="mb-0">用户列表(共<span th:text="${pageInfo.totalElements}">0</span>条)</h5> </div> <div class="card-body p-0"> <table class="table table-hover mb-0"> <thead class="table-light"> <tr> <th>ID</th> <th>用户名</th> <th>邮箱</th> <th>状态</th> <th>创建时间</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="user : ${users}"> <td th:text="${user.id}">1</td> <td th:text="${user.username}">admin</td> <td th:text="${user.email}">admin@example.com</td> <td> <span class="badge" th:classappend="${user.enabled} ? 'bg-success' : 'bg-secondary'"> <span th:if="${user.enabled}">启用</span> <span th:unless="${user.enabled}">禁用</span> </span> </td> <td th:text="${#temporals.format(user.createdTime, 'yyyy-MM-dd HH:mm')}">2023-01-01 10:00</td> <td> <a th:href="@{/user/edit(id=${user.id})}" class="btn btn-sm btn-outline-primary">编辑</a> <button type="button" class="btn btn-sm btn-outline-danger" th:onclick="'deleteUser('+${user.id}+')'">删除</button> </td> </tr> <tr th:if="${users.empty}"> <td colspan="6" class="text-center py-4">暂无用户数据</td> </tr> </tbody> </table> </div> </div> <!-- 分页 --> <nav aria-label="用户列表分页" th:if="${pageInfo.totalPages > 1}"> <ul class="pagination justify-content-center"> <li class="page-item" th:classappend="${pageInfo.number == 0} ? 'disabled'"> <a class="page-link" th:href="@{/user/list(page=0,size=${pageInfo.size},keyword=${keyword})}">首页</a> </li> <li class="page-item" th:classappend="${pageInfo.number == 0} ? 'disabled'"> <a class="page-link" th:href="@{/user/list(page=${pageInfo.number - 1},size=${pageInfo.size},keyword=${keyword})}">上一页</a> </li> <!-- 页码循环(最多显示5个) --> <li class="page-item" th:each="i : ${#numbers.sequence(0, Math.min(4, pageInfo.totalPages - 1))}" th:classappend="${pageInfo.number == i} ? 'active'"> <a class="page-link" th:href="@{/user/list(page=${i},size=${pageInfo.size},keyword=${keyword})}" th:text="${i + 1}">1</a> </li> <li class="page-item" th:classappend="${pageInfo.number == pageInfo.totalPages - 1} ? 'disabled'"> <a class="page-link" th:href="@{/user/list(page=${pageInfo.number + 1},size=${pageInfo.size},keyword=${keyword})}">下一页</a> </li> <li class="page-item" th:classappend="${pageInfo.number == pageInfo.totalPages - 1} ? 'disabled'"> <a class="page-link" th:href="@{/user/list(page=${pageInfo.totalPages - 1},size=${pageInfo.size},keyword=${keyword})}">末页</a> </li> </ul> </nav> </div> <!-- 删除确认JS --> <script th:inline="javascript"> /*<![CDATA[*/ function deleteUser(id) { if (confirm('确定要删除该用户吗?此操作不可恢复!')) { $.ajax({ url: '/user/delete/' + id, type: 'DELETE', headers: { 'X-CSRF-TOKEN': /*[[$(_csrf.token)]]*/ }, success: function(res) { alert('删除成功'); location.reload(); }, error: function(xhr) { alert('删除失败:' + xhr.responseJSON.message); } }); } } /*]]>*/ </script> </body> </html>4.4 安全配置与生产部署要点
Spring Security配置(SecurityConfig.java)
@Configuration @EnableWebSecurity @EnableMethodSecurity // 启用@PreAuthorize等注解 @Slf4j public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz .requestMatchers("/css/**", "/js/**", "/images/**", "/webjars/**").permitAll() .requestMatchers("/", "/login", "/logout", "/error").permitAll() .requestMatchers("/user/add", "/user/save").hasRole("ADMIN") .requestMatchers("/user/**").authenticated() .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/user/list", true) // 登录成功跳转 .failureUrl("/login?error=true") // 登录失败返回 .usernameParameter("username") .passwordParameter("password") ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout=true") .invalidateHttpSession(true) .deleteCookies("JSESSIONID") ) .exceptionHandling(exceptions -> exceptions .accessDeniedHandler(new CustomAccessDeniedHandler()) // 403处理器 ) .csrf(csrf -> csrf .ignoringRequestMatchers("/api/**") // API接口忽略CSRF(若需调用) ); return http.build(); } // 自定义403页面 public static class CustomAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { request.setAttribute("status", 403); request.setAttribute("message", "您没有权限访问此页面"); request.getRequestDispatcher("/error/403").forward(request, response); } } }自定义登录页(templates/login.html)
<!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>用户登录</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> </head> <body class="bg-light"> <div class="container d-flex align-items-center justify-content-center vh-100"> <div class="card w-100" style="max-width