资讯详情

资讯详情

SpringBoot + Thymeleaf服务端渲染实战:伪分离架构的工程化落地

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/listThymeleaf就自动去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而不是转向Vue2.1 真实项目中的技术选型决策树很多初学者看到“伪分离”这个词第一反应是“赶紧换Vue”。但我在某公司支撑过三个不同生命周期的系统结论很反直觉不是所有项目都需要前端工程化也不是所有团队都适合承担工程化带来的协作成本。我们当时面临的选择是项目A某地市级政务内网的公文流转系统用户数约800人年迭代需求10个功能点要求国产化适配麒麟OS达梦数据库上线周期≤3周项目B某制造企业设备点检APP的配套PC管理后台需对接IoT平台MQTT数据前端需实时刷新设备状态卡片预计未来6个月接入20新厂区项目C某在线教育平台的教师端作业批改系统含富文本编辑、图片拖拽上传、多人协同批注日活教师超5000人最终决策是✅ 项目A → SpringBoot Thymeleaf4天完成首版国产化适配零修改⚠️ 项目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}不会执行JSXSS防护由框架兜底而Vue中v-html绑定若未严格校验来源一个富文本字段就能打穿整站。提示别迷信“技术先进性”。我见过用SpringCloud Vue做的系统因前端团队误删了axios拦截器里的token续签逻辑导致所有用户每2小时强制登出——这种问题在Thymeleaf里根本不存在因为登录态全由Spring Security Session管理模板里只写一句span sec:authenticationname。2.2 Thymeleaf的不可替代优势服务端渲染的“确定性红利”Thymeleaf最被低估的能力是它把“页面状态”彻底交还给服务端控制。举个典型场景权限菜单动态渲染。在Vue方案中你需要① 前端请求/user/menu获取JSON菜单树 → ② 前端解析JSON生成router.addRoutes() → ③ 处理异步路由懒加载 → ④ 权限指令v-permission做按钮级校验 → ⑤ 所有菜单图标、名称、排序还得单独维护i18n文件而在Thymeleaf中只需三步Controller中查库生成MenuVo列表放入model模板里用li th:eachmenu : ${menus}循环渲染按钮级权限用button th:if${#authorization.authorize(ROLE_ADMIN)}删除/button整个过程没有网络延迟、没有竞态条件、没有缓存不一致——菜单数据和服务端Session状态永远100%同步。某次我们做等保测评第三方扫描工具发现Vue后台存在“未授权访问菜单接口”的风险因为菜单接口未加鉴权仅靠前端隐藏而Thymeleaf版本直接通过了——因为菜单数据根本不出现在任何API里它只存在于服务端内存。另一个常被忽视的优势是SEO友好性。虽然管理后台通常不需SEO但如果你做的是面向公众的活动页如“618大促专题页”Thymeleaf生成的HTML源码自带完整标题、描述、结构化数据百度爬虫能直接抓取。而Vue SPA首页是空的除非上SSRNuxt.js否则SEO成本翻倍。2.3 “伪分离”的本质分层清晰 ≠ 物理隔离很多人混淆了“分层”和“分离”。MVC本身就是分层架构Model数据、View展示、Controller调度。Thymeleaf完美实现了这三层的职责隔离ModelController中new UserVo()并set属性或调用Service返回DTOViewHTML模板只负责“怎么展示”不处理“从哪来”不调API和“往哪去”不发POSTController只做三件事——接收参数、调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:fragmentcontent预留内容区!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org head title th:text${title} ?: 系统后台默认标题/title link relstylesheet th:href{/css/bootstrap.min.css} /head body nav classnavbar.../nav div classcontainer-fluid main th:fragmentcontent页面内容将插入此处/main /div script th:src{/js/jquery.min.js}/script /body /html二级功能模块模板fragments/user-fragments.html封装可复用组件如用户表格、搜索表单!-- 用户列表表格 -- th:block th:fragmentuser-table(users) table classtable theadtrth姓名/thth邮箱/thth操作/th/tr/thead tbody tr th:eachuser : ${users} td th:text${user.name}张三/td td th:text${user.email}zhangexample.com/td td a th:href{/user/edit(id${user.id})} classbtn btn-sm btn-primary编辑/a button typebutton classbtn btn-sm btn-danger th:onclickdeleteUser(${user.id})删除/button /td /tr /tbody /table /th:block !-- 搜索表单 -- th:block th:fragmentsearch-form(action) form th:action${action} methodget div classinput-group input typetext namekeyword classform-control th:value${keyword} ?: placeholder输入姓名或邮箱 button classbtn btn-outline-secondary typesubmit搜索/button /div /form /th:block三级具体页面user/list.html通过th:replace和th:include组合使用!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org xmlns:layouthttp://www.ultraq.net.nz/thymeleaf/layout head title用户管理/title /head body !-- 继承基础布局 -- div layout:decorate~{layout} !-- 替换content片段 -- div layout:fragmentcontent !-- 引入搜索表单 -- 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 relstylesheet script src/js/main.js/script a href/user/list用户列表/a正确写法必须用{}!-- ✅ 自动适配context-path -- link th:href{/css/app.css} relstylesheet script th:src{/js/main.js}/script a th:href{/user/list}用户列表/a !-- 带参数的URL -- a th:href{/user/edit(id${user.id},tabprofile)}编辑资料/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放入modelGetMapping(/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转义输出scriptalert(1)/script会显示为纯文本th:utext${user.content}→ 不转义直接渲染HTML需确保content来自可信源如管理员后台富文本编辑器。但真正的安全防线在后端。Spring Security与Thymeleaf深度集成通过thymeleaf-extras-springsecurity依赖提供专属方言dependency groupIdorg.thymeleaf.extras/groupId artifactIdthymeleaf-extras-springsecurity5/artifactId !-- 注意版本需匹配Spring Boot版本 -- /dependency模板中即可使用安全专用属性!-- 只有ADMIN角色可见 -- div sec:authorizehasRole(ADMIN) button classbtn btn-danger批量删除/button /div !-- 有指定权限才显示 -- span sec:authorizehasAuthority(user:delete)删除/span !-- 权限表达式需开启EnableGlobalMethodSecurity -- div sec:authorizehasPermission(#user, WRITE) a th:href{/user/edit(id${user.id})}编辑/a /div !-- 显示当前用户名 -- span sec:authenticationname当前用户/span !-- 显示用户自定义属性 -- span sec:authenticationprincipal.username用户名/span注意sec:authentication获取的是SecurityContext中的Authentication对象其principal可能是UserDetails含username、authorities或原始用户名字符串取决于你的UserDetailsService实现。我建议统一返回UserDetails这样模板里能直接取principal.email、principal.phone等扩展字段。CSRF防护更简单Thymeleaf会自动在表单中注入隐藏域。只要Controller返回的视图名对应HTML中有form标签且未显式关闭CSRF就会生成form methodpost action/user/save input typehidden name_csrf valueabc123.../ !-- 其他字段 -- /formSpring Security默认校验此token无需额外代码。唯一要注意的是AJAX请求需手动携带。在Thymeleaf模板中可通过_csrf.token获取script th:inlinejavascript var csrfToken [[${_csrf.token}]]; var csrfHeader [[${_csrf.headerName}]]; $(document).ajaxSend(function(e, xhr, options) { xhr.setRequestHeader(csrfHeader, csrfToken); }); /script3.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.ymlspring: thymeleaf: cache: false check-template: true check-template-location: true生产时开启application-prod.ymlspring: thymeleaf: cache: true # 模板修改后最多10分钟生效避免频繁IO template-resolver-order: 1 # 开启HTML5解析器更严格减少容错 mode: HTML实测数据某后台系统首页HTML体积从287KB未压缩降至42KBGzip后首屏时间从1.8s降至320ms。对比同功能Vue SPA含vendor.js 1.2MBThymeleaf在弱网环境下优势明显——它不需要下载几百KB的JS再解析执行而是直接渲染服务端已准备好的HTML。4. 实战全流程从零搭建一个可商用的Thymeleaf后台4.1 初始化项目与依赖配置使用Spring Initializr创建项目勾选Spring WebSpring Data JPAThymeleafSpring SecurityLombok简化POJODevTools开发时热更新pom.xml关键依赖dependencies !-- Web核心 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- Thymeleaf模板引擎 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-thymeleaf/artifactId /dependency !-- 安全增强 -- dependency groupIdorg.thymeleaf.extras/groupId artifactIdthymeleaf-extras-springsecurity5/artifactId /dependency !-- 数据持久化 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId scoperuntime/scope /dependency !-- 工具类 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies注意thymeleaf-extras-springsecurity5的版本必须与Spring Boot主版本匹配。Spring Boot 2.7.x用thymeleaf-extras-springsecurity53.x用thymeleaf-extras-springsecurity6。版本错配会导致sec:标签无法解析页面报错却无明确提示——这是新手最常卡住的点务必检查Maven Dependency Hierarchy。4.2 用户管理模块Controller、Service、Repository三层实现实体类User.javaEntity 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; }RepositoryUserRepository.javaRepository public interface UserRepository extends JpaRepositoryUser, Long { // Spring Data JPA自动生成实现 OptionalUser findByUsername(String username); OptionalUser findByEmail(String email); ListUser findByEnabledTrue(); // 查询启用用户 }ServiceUserService.javaService Transactional Slf4j public class UserService { Autowired private UserRepository userRepository; public PageUser 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); } }ControllerUserController.javaController 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()); PageUser 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:thhttp://www.thymeleaf.org xmlns:sechttp://www.thymeleaf.org/extras/spring-security head meta charsetUTF-8 title th:text${title} ?: 后台管理系统系统标题/title !-- Bootstrap 5 CSS -- link hrefhttps://cdn.jsdelivr.net/npm/bootstrap5.3.0/dist/css/bootstrap.min.css relstylesheet !-- 自定义CSS -- link th:href{/css/admin.css} relstylesheet /head body !-- 顶部导航 -- nav classnavbar navbar-expand-lg navbar-dark bg-dark div classcontainer-fluid a classnavbar-brand href# th:text${title} ?: 系统系统/a div classnavbar-nav ms-auto span classnavbar-text me-3 sec:authenticationname欢迎[[${#authentication.name}]]/span a classnav-link href# onclicklogout()退出/a /div /div /nav !-- 主体内容区 -- div classcontainer-fluid mt-3 main th:fragmentcontent !-- 页面内容将插入此处 -- /main /div !-- 底部 -- footer classbg-light py-3 mt-5 div classcontainer text-center spancopy; 2023 系统后台/span /div /footer !-- Bootstrap JS -- script srchttps://cdn.jsdelivr.net/npm/bootstrap5.3.0/dist/js/bootstrap.bundle.min.js/script !-- jQueryBootstrap 5已不依赖但部分插件需要 -- script th:src{/js/jquery.min.js}/script !-- 自定义JS -- script th:src{/js/admin.js}/script !-- 退出登录JS -- script th:inlinejavascript /*![CDATA[*/ function logout() { if (confirm(确定要退出登录吗)) { window.location.href /*[[{/logout}]]*/ /logout; } } /*]]*/ /script /body /html用户列表页templates/user/list.html!DOCTYPE html html xmlns:thhttp://www.thymeleaf.org xmlns:layouthttp://www.ultraq.net.nz/thymeleaf/layout layout:decorate~{layout} head title用户管理/title /head body div layout:fragmentcontent !-- 操作栏 -- div classd-flex justify-content-between align-items-center mb-3 h2用户管理/h2 a href/user/add classbtn btn-primary 新增用户/a /div !-- 搜索表单 -- div classcard mb-3 div classcard-body form th:action{/user/list} methodget div classinput-group input typetext namekeyword classform-control th:value${keyword} ?: placeholder按姓名或邮箱搜索 button classbtn btn-outline-secondary typesubmit搜索/button a th:href{/user/list} classbtn btn-outline-secondary重置/a /div /form /div /div !-- 用户表格 -- div classcard div classcard-header h5 classmb-0用户列表共span th:text${pageInfo.totalElements}0/span条/h5 /div div classcard-body p-0 table classtable table-hover mb-0 thead classtable-light tr thID/th th用户名/th th邮箱/th th状态/th th创建时间/th th操作/th /tr /thead tbody tr th:eachuser : ${users} td th:text${user.id}1/td td th:text${user.username}admin/td td th:text${user.email}adminexample.com/td td span classbadge 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})} classbtn btn-sm btn-outline-primary编辑/a button typebutton classbtn btn-sm btn-outline-danger th:onclickdeleteUser(${user.id})删除/button /td /tr tr th:if${users.empty} td colspan6 classtext-center py-4暂无用户数据/td /tr /tbody /table /div /div !-- 分页 -- nav aria-label用户列表分页 th:if${pageInfo.totalPages 1} ul classpagination justify-content-center li classpage-item th:classappend${pageInfo.number 0} ? disabled a classpage-link th:href{/user/list(page0,size${pageInfo.size},keyword${keyword})}首页/a /li li classpage-item th:classappend${pageInfo.number 0} ? disabled a classpage-link th:href{/user/list(page${pageInfo.number - 1},size${pageInfo.size},keyword${keyword})}上一页/a /li !-- 页码循环最多显示5个 -- li classpage-item th:eachi : ${#numbers.sequence(0, Math.min(4, pageInfo.totalPages - 1))} th:classappend${pageInfo.number i} ? active a classpage-link th:href{/user/list(page${i},size${pageInfo.size},keyword${keyword})} th:text${i 1}1/a /li li classpage-item th:classappend${pageInfo.number pageInfo.totalPages - 1} ? disabled a classpage-link th:href{/user/list(page${pageInfo.number 1},size${pageInfo.size},keyword${keyword})}下一页/a /li li classpage-item th:classappend${pageInfo.number pageInfo.totalPages - 1} ? disabled a classpage-link th:href{/user/list(page${pageInfo.totalPages - 1},size${pageInfo.size},keyword${keyword})}末页/a /li /ul /nav /div !-- 删除确认JS -- script th:inlinejavascript /*![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 /html4.4 安全配置与生产部署要点Spring Security配置SecurityConfig.javaConfiguration 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?errortrue) // 登录失败返回 .usernameParameter(username) .passwordParameter(password) ) .logout(logout - logout .logoutUrl(/logout) .logoutSuccessUrl(/login?logouttrue) .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:thhttp://www.thymeleaf.org head title用户登录/title link hrefhttps://cdn.jsdelivr.net/npm/bootstrap5.3.0/dist/css/bootstrap.min.css relstylesheet /head body classbg-light div classcontainer d-flex align-items-center justify-content-center vh-100 div classcard w-100 stylemax-width
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →