news 2026/10/18 2:59:45

SpringBoot+Thymeleaf后台管理CRUD实战:从数据流转到表单校验

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Thymeleaf后台管理CRUD实战:从数据流转到表单校验

1. 动手前先想清楚的事:这套方案到底解决什么问题

“增删改查”这四个字,在简历上出现频率很高,但真正能用SpringBoot+Thymeleaf把它做成一个可交付、可扩展、页面好看的后台管理模块,很多人其实是卡住的。问题往往不在“会不会写代码”,而在“不知道整个数据是怎么从浏览器流到数据库,再流回页面的”。我见过太多学习者照着教程把代码抄完,换了一张表、加了一个字段,就不知道怎么改了。

先明确本文要做什么:用SpringBoot构建后端服务,用Thymeleaf渲染服务端页面,实现一个完整且可直接落地的增删改查功能。我会拿一个典型的“员工信息管理”场景为主线,覆盖列表查询、新增、编辑、删除、表单校验、分页、异常处理这些环节。这不是一篇只贴代码的教程,每一步我都会解释“为什么这么设计”,因为只有理解了来龙去脉,换个需求你才接得住。

先说结论性的东西:SpringBoot+Thymeleaf这套组合,最适合的是内部管理系统、运营后台、CMS这类“服务端渲染优先”的场景。为什么不用前后端分离?因为这类页面的核心诉求是快速交付、可直接刷新、便于SEO(如果对外)、开发链路短。你不需要专门维护一套接口文档,不需要处理跨域,不需要前端工程化环境,一个SpringBoot应用全部搞定。把复杂的东西留给后端,把渲染交给模板引擎,这对中小团队来说是性价比最高的方案。

1.1 为什么这个场景不适合一上来就前后端分离

很多同学一听到“管理系统”就默认要上Vue+ElementUI,然后后端写接口,再部署个Nginx。这套东西本身不复杂,但它有个隐性成本:你需要同时维护两套工程,处理跨域、权限、环境变量,联调成本比服务端渲染高一个量级。

而Thymeleaf这套方案,模板页面直接放在SpringBoot的src/main/resources/templates目录里,Controller返回一个视图名,SpringMVC自动帮你去渲染HTML。用户刷新浏览器,他看到的就是最新数据,没有接口联调这回事。你甚至可以理解为:Thymeleaf让“页面”成为Controller的输出结果,而不是一个独立的前端App。

特别是当你的团队成员以Java为主、前端功力一般时,Thymeleaf的学习成本和维护成本远低于维护一个完整前后端分离体系。这也是大量的后台管理系统至今仍在使用服务端渲染的原因——不是技术落后,是成本可控。

1.2 先把数据流转拆成五步,再动手写代码

在写任何一个CRUD功能之前,我建议你先在纸上把这五步写出来:

  1. 浏览器发起HTTP请求(GET或POST)。
  2. Controller接收请求,解析路径参数、表单参数。
  3. Service层处理业务逻辑(比如校验、默认值填充)。
  4. Repository层与数据库交互,执行查询或持久化。
  5. Controller把数据模型放到Model里,Thymeleaf模板渲染,返回HTML。

明确了这一步,后续每一个功能你都能定位到“我到底要改哪一层”。列表页烂了去查Controller和模板,保存数据不对去查Service和实体映射,数据库没建表去查JPA配置——问题边界立刻清晰。这是整个项目最基本的认知框架,后面所有代码都会落在链条的某一段上。

2. 工程初始化与目录规划:三分钟跑起骨架

我的建议是直接用Spring Initializr创建项目,不要手动拼目录。访问start.spring.io或者用IDEA自带的新建SpringBoot项目入口都可以。

版本方面,我用的是Spring Boot 3.3.x,JDK 17。如果你还在用JDK 8和Spring Boot 2.x,代码里的javax.*包要换回javax.persistence而不是jakarta.persistence,这个坑后面讲实体类时再强调。

核心依赖就五个:Web、Thymeleaf、Spring Data JPA、MySQL Driver、Validation。Lombok也加上,能少写很多getter/setter。

<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-data-jpa</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </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经常被漏掉。写校验注解的时候你发现@Valid找不到类,就是因为没引这个依赖。Spring Boot 2.3之后它不再默认包含在Web启动器里,必须手动声明。

然后写application.yml。本地开发关键配置如下:

spring: datasource: url: jdbc:mysql://localhost:3306/staff_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true properties: hibernate: format_sql: true thymeleaf: cache: false

ddl-auto: update的意思是实体类改了字段会自动帮你在数据库里同步更新表结构。开发期非常方便,但上线前必须改成validate或none,否则生产环境一旦实体变化,表结构被自动改掉,后果很严重。这一点我真的是踩过坑,在线上的库里跑出一条自动加的字段,半夜被叫起来排查。

thymeleaf.cache: false是本地开发的救命配置。不开缓存的话,你改完HTML需要重启应用才生效;开了缓存false,页面模板每次请求都会重新加载,刷新浏览器就能看到改动。

2.1 目录结构:你可以再简单一点,但不要更简单

标准的包路径结构大概是这样的:

src/main/java/com/example/demo ├── DemoApplication.java ├── controller │ └── StaffController.java ├── service │ └── StaffService.java ├── repository │ └── StaffRepository.java └── entity └── Staff.java src/main/resources ├── static │ ├── css │ └── js └── templates ├── staff │ ├── list.html │ └── form.html └── error.html

很多人为了图省事把Repository省略,直接在Service里用EntityManager,或者把Service省略,Controller里直接注入Repository。在Demo阶段确实能跑,但一旦业务规则复杂,比如“删除前要检查关联数据”“新增时自动设置创建时间”,你就知道Service层夹在中间的价值了。我的原则很简单:Controller只做参数接收和视图跳转,Repository只做持久层操作,业务规则全部放Service层。项目再小,这个分层都不要省。

2.2 数据源配置里最容易忽略的细节

URL里那三个参数,useUnicode=true&characterEncoding=utf8保证中文不乱码,serverTimezone=Asia/Shanghai保证时间类型映射正确。少一个,轻则中文乱码,重则LocalDateTime存入数据库时差8小时。

driver-class-name在Spring Boot 3里是com.mysql.cj.jdbc.Driver,老的com.mysql.jdbc.Driver已经废弃了。很多老教程还在用旧驱动,启动时虽然会告警但能跑,建议还是新写法干净。

3. 实体、仓库与服务层:别把数据库表直接暴露给页面

实体类是一个Java对象和数据库表之间的映射。我们以员工表为例,核心字段设计如下:ID、姓名、邮箱、部门、入职日期、创建时间。

@Entity @Table(name = "staff") @Getter @Setter @NoArgsConstructor public class Staff { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank(message = "姓名不能为空") @Column(nullable = false, length = 50) private String name; @Email(message = "邮箱格式不正确") @NotBlank(message = "邮箱不能为空") @Column(nullable = false, unique = true, length = 100) private String email; @NotBlank(message = "部门不能为空") @Column(length = 50) private String department; @Column(name = "hire_date") private LocalDate hireDate; @Column(name = "create_time", updatable = false) private LocalDateTime createTime; @PrePersist public void prePersist() { this.createTime = LocalDateTime.now(); } }

@PrePersist这个注解很实用:在实体第一次保存前自动执行方法体,给字段填默认值。这样你就不用在Service层手动set创建时间了,代码少一点是一点。updatable = false防止更新时覆盖创建时间,从根源上避开数据错误。

数据库表主键生成策略选IDENTITY,就是数据库自增。MySQL下这是最常规的选择,性能也好。别用序列或者UUID当主键去做业务表,长字符串主键会引起聚簇索引性能下降,这个在数据量大了之后特别明显。

3.1 Repository层的代码可以少到什么程度

Spring Data JPA最爽的一点在于,只要继承接口,你就能零实现地拿到一堆现成方法。

public interface StaffRepository extends JpaRepository<Staff, Long> { // 按姓名模糊查询 List<Staff> findByNameContaining(String name); // 邮箱唯一性检查 boolean existsByEmail(String email); // 按部门查询 List<Staff> findByDepartment(String department); }

方法名按照“字段名+关键词”的命名规则写,框架会自动生成对应的SQL。这种机制叫方法名派生查询。你不需要写任何实现代码,findByNameContaining会被自动翻译成WHERE name LIKE %?%。我个人建议命名规则不超过两个字段组合,再多就老实写@Query注解吧,可读性反而更好。

3.2 Service层:事务与业务规则的落脚点

Service层的存在不是为了“多写一层”,而是为了守住业务规则。举个最常见的例子:新增员工前要检查邮箱不能重复,这个检查放Controller里就是污染控制层,放Repository层又写不出来,就只能放Service层。

@Service @RequiredArgsConstructor public class StaffService { private final StaffRepository staffRepository; @Transactional(readOnly = true) public Page<Staff> pageStaff(Pageable pageable) { return staffRepository.findAll(pageable); } @Transactional(readOnly = true) public Staff getStaff(Long id) { return staffRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("员工数据不存在: " + id)); } @Transactional public void saveStaff(Staff staff) { // 新增时检查邮箱唯一性(编辑时排除自身) Staff existing = staffRepository.findByEmail(staff.getEmail()); if (existing != null && !existing.getId().equals(staff.getId())) { throw new IllegalArgumentException("邮箱已被使用: " + staff.getEmail()); } staffRepository.save(staff); } @Transactional public void deleteStaff(Long id) { staffRepository.deleteById(id); } }

@Transactional(readOnly = true)放在查询方法上,告诉数据库“我这个方法只需要读,不需要改”。MySQL的InnoDB在这种模式下可以走快照读,减少锁竞争。写操作的方法上用@Transactional保证原子性。这里的@Transactional一定要导入org.springframework.transaction.annotation.Transactional,别导成javax.transaction。

更新操作有个经典错误:直接创建一个新对象,塞上id,然后save()。这样做的后果是——没被set的字段全部变成null,然后把数据库里原来的值覆盖掉。正确做法是先从数据库里查出原实体,再逐个更新需要修改的字段,最后调用save()。由于目标对象是持久态对象,JPA自己会做脏检查,事务提交时自动更新。你甚至可以不写save(),但为了代码意图明确,我还是建议显式调用。

4. Controller与数据校验:把入口管住

Controller是HTTP请求的入口,也是整个数据流转的枢纽。我的习惯是每个资源一个Controller,页面级路由统一收归一处。

先说路由设计。列表页挂在GET /staffs,新增表单页在GET /staffs/new,提交新增用POST /staffs,编辑表单页在GET /staffs/{id}/edit,提交更新用POST /staffs/{id},删除用POST /staffs/{id}/delete。这里几个细节很多人看不出来:

  • 编辑我用POST /staffs/{id}而不是PUT /staffs/{id}。HTML表单只支持GET和POST两种方法,虽然可以伪造_method参数配合HiddenHttpMethodFilter模拟PUT,但纯Thymeleaf场景没必要,POST本身就是通用的。
  • 删除必须用POST,不能用GET。如果你用GET /staffs/{id}/delete,搜索引擎爬虫和浏览器预加载可能提前触发这个操作,数据被删了你都不知道。用POST可以规避这个风险。

完整Controller如下:

@Controller @RequestMapping("/staffs") @RequiredArgsConstructor public class StaffController { private final StaffService staffService; @GetMapping public String list(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, Model model) { Pageable pageable = PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "createTime")); Page<Staff> staffPage = staffService.pageStaff(pageable); model.addAttribute("staffPage", staffPage); model.addAttribute("currentPage", page); return "staff/list"; } @GetMapping("/new") public String createForm(Model model) { model.addAttribute("staff", new Staff()); return "staff/form"; } @PostMapping public String create(@Valid @ModelAttribute("staff") Staff staff, BindingResult bindingResult, Model model) { if (bindingResult.hasErrors()) { return "staff/form"; } try { staffService.saveStaff(staff); } catch (IllegalArgumentException e) { model.addAttribute("errorMsg", e.getMessage()); return "staff/form"; } return "redirect:/staffs"; } @GetMapping("/{id}/edit") public String editForm(@PathVariable Long id, Model model) { model.addAttribute("staff", staffService.getStaff(id)); return "staff/form"; } @PostMapping("/{id}") public String update(@PathVariable Long id, @Valid @ModelAttribute("staff") Staff staff, BindingResult bindingResult, Model model) { if (bindingResult.hasErrors()) { return "staff/form"; } staff.setId(id); try { staffService.saveStaff(staff); } catch (IllegalArgumentException e) { model.addAttribute("errorMsg", e.getMessage()); return "staff/form"; } return "redirect:/staffs"; } @PostMapping("/{id}/delete") public String delete(@PathVariable Long id) { staffService.deleteStaff(id); return "redirect:/staffs"; } }

4.1 校验失败时,怎么把错误信息带回表单

@Valid和BindingResult必须成对出现,且BindingResult必须紧跟在被校验对象后面,否则Spring会直接抛BindException,表单错误信息根本拿不到。

校验失败时,我直接返回"staff/form"字符串,此时model里的staff对象是用户填过的原值,Thymeleaf用th:field可以自动回显。这就是表单校验里最核心的一句话:校验失败不是跳转,而是返回当前视图重新渲染。

关于业务异常的提示,我在Service层用了IllegalArgumentException。Controller捕获后放到model属性里,页面上根据errorMsg是否存在来判断是否显示。这个方案简单直接,是中小项目里比较实用的模式。

4.2 PRG模式:成功提交后为什么要重定向

看代码你会发现,新建和更新成功后都是返回"redirect:/staffs"。

这用的是Post/Redirect/Get模式。原因很简单:如果成功提交后直接返回模板页面,用户按F5刷新,浏览器的当前动作是重复提交上次的表单请求,也就是会再插入一条记录。改成重定向,浏览器会GET一下列表页,刷新也只是重新加载列表,不会重复写库。

这个设计本身就是对不对的问题,不是可不可选的问题。凡是表单提交成功的页面,一律重定向跳转。

5. Thymeleaf模板实战:表单回显、分页与代码碎片

Thymeleaf的语法核心就八个属性:th:text、th:if、th:each、th:field、th:href、th:action、th:object、th:replace。掌握了这套,基本可以覆盖日常所有页面的编写。

先说列表页list.html的结构。表格里每一行是数据,操作列放编辑和删除按钮,底部放分页导航。

<table class="table"> <thead> <tr> <th>ID</th> <th>姓名</th> <th>邮箱</th> <th>部门</th> <th>入职日期</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="staff : ${staffPage.content}"> <td th:text="${staff.id}">1</td> <td th:text="${staff.name}">张三</td> <td th:text="${staff.email}">zhangsan@example.com</td> <td th:text="${staff.department}">研发部</td> <td th:text="${#temporals.format(staff.hireDate, 'yyyy-MM-dd')}">2024-01-01</td> <td> <a th:href="@{/staffs/{id}/edit(id=${staff.id})}">编辑</a> <form th:action="@{/staffs/{id}/delete(id=${staff.id})}" method="post" onsubmit="return confirm('确认删除该员工吗?');" style="display:inline;"> <button type="submit">删除</button> </form> </td> </tr> </tbody> </table>

th:each="staff : ${staffPage.content}"用来遍历分页结果集。分页对象里真正放数据的是content属性,这块容易写错,写成staffPage是拿不到数据的。

@{/staffs/{id}/edit(id=${staff.id})}是Thymeleaf的URL表达式,作用就是拼接URL并做参数编码。你千万别用字符串拼接去写链接,特别是带中文参数的时候,编码问题会让你抓狂。

删除操作我直接在表格里放了一个内联表单,好处是不用为每个删除按钮配备JavaScript,只要一个confirm就完成了交互。表单的action表达式和POST方法配合,安全又简单。

5.1 时间格式化的坑

Thymeleaf对LocalDate和LocalDateTime不能直接调用toString,会输出2024-01-01T00:00:00这种带字母T的格式。正确写法是依赖模板内置的#temporals工具类:

th:text="${#temporals.format(staff.hireDate, 'yyyy-MM-dd')}"

如果你嫌哪都写日期格式化太啰嗦,可以给实体类加一个getHireDateText()方法,返回格式化后的字符串。模板里直接${staff.hireDateText}。这个做法有点违背“实体不要放展示逻辑”的原则,但在模板引擎场景下,我个人认为是可接受的取舍,省得满屏都是格式化表达式。

5.2 新增和编辑共用一个form.html的思路

表单页是CRUD里最能体现Thymeleaf功底的地方。新增和编辑其实共用同一个页面,th:object指向的staff对象不同而已。编辑时staff来自数据库,新增时staff是空的。

<form th:action="${staff.id == null} ? @{/staffs} : @{/staffs/{id}(id=${staff.id})}" th:object="${staff}" method="post"> <input type="hidden" th:field="*{id}" /> <div> <label>姓名</label> <input type="text" th:field="*{name}" /> <p class="error" th:if="${#fields.hasErrors('name')}" th:errors="*{name}">姓名错误</p> </div> <div> <label>邮箱</label> <input type="email" th:field="*{email}" /> <p class="error" th:if="${#fields.hasErrors('email')}" th:errors="*{email}">邮箱错误</p> </div> <div> <label>部门</label> <input type="text" th:field="*{department}" /> <p class="error" th:if="${#fields.hasErrors('department')}" th:errors="*{department}">部门错误</p> </div> <div> <label>入职日期</label> <input type="date" th:field="*{hireDate}" /> </div> <p class="error" th:if="${errorMsg}" th:text="${errorMsg}">邮箱占用</p> <button type="submit">保存</button> <a th:href="@{/staffs}">返回列表</a> </form>

th:action根据staff.id是否为null自动切换提交路径,th:object="${staff}"帮后面的th:field="*{name}"自动补齐了字段前缀。也就是说*{name}自动等价于${staff.name},这就是表单回显能生效的核心机制。

错误展示有两类:一类是BindingResult的字段校验错误,用#fields.hasErrors('name')判断;另一类是Service层抛出的业务异常,放在errorMsg属性里。两种都是“渲染当前页面并显示错误”,不会丢失用户输入。

5.3 把分页做成分段代码

分页逻辑我也直接写在列表页底部,没有抽公共组件,因为业务简单,但写法有个小技巧:页码范围用#numbers.sequence生成。

<nav> <ul> <li th:if="${staffPage.hasPrevious()}"> <a th:href="@{/staffs(page=${currentPage - 1})}">上一页</a> </li> <li th:each="i : ${#numbers.sequence(1, staffPage.totalPages)}" th:classappend="${i == currentPage} ? 'active'"> <a th:href="@{/staffs(page=${i})}" th:text="${i}">1</a> </li> <li th:if="${staffPage.hasNext()}"> <a th:href="@{/staffs(page=${currentPage + 1})}">下一页</a> </li> </ul> </nav>

staffPage.hasPrevious()和hasNext()是Spring Data Page对象自带的方法,currentPage是Controller传进来的页码。注意,数据库分页的页码是0开始,页面展示的页码是1开始,Controller里PageRequest.of(page - 1, size)已经做了转换,模板里直接拿页面页码做加减就行。

静态资源这块,CSS和JS统一放static/css、static/js,HTML里用th:href="@{/css/style.css}"引用。注意static目录下的文件不能直接访问templates目录下的页面,templates下的HTML只能由Controller视图渲染后返回,这个界限新手经常搞混。

6. 完整跑通后的避坑复盘:四个高频问题

代码写完了,跑起来总会碰到几个拦路虎。我把实际教学和开发过程中最常遇到的几个问题梳理成排查链路,每一个都给出根因和验证思路,做项目时对照着看能省大把时间。

6.1 列表页可以访问,但提交表单后一直报405或404

这个现象的根子通常在表单的th:action写错了路径。Controller里定义的是@PostMapping("/staffs"),表单却提交到了/staffs/或者/staffs/xxx,匹配不上自然报错。排查思路很简单:打开浏览器开发者工具,在Network面板看提交的Request URL和Method,再对照Controller的RequestMapping。别盯着代码看半天,看请求一眼就能定位。

另外一个容易忽略的场景:Thymeleaf模板里的@{...}表达式如果写错了,比如漏了/,生成的URL会带上当前路径前缀,变成类似/staffs/staffs的鬼路径。这个只能用浏览器Network里的实际URL判断,肉眼很难看出来。

6.2 表单校验不生效,空值照样保存成功

这个坑我几乎每周都能看到。如果你确认写了@Valid和@NotBlank,但提交空值照样进数据库,大概率是这么几种情况:

  1. 校验注解对应的JSR 380实现没引入,也就是少了spring-boot-starter-validation。
  2. BindingResult没有紧跟在校验对象后面,Spring直接抛异常,看到500而不是友好的错误提示页。
  3. 表单里的字段名与实体属性名不一致,比如页面写th:field="*{names}",实体叫name,绑定直接失败。

第三点排查起来比较隐蔽,建议在Controller参数上加一个@ModelAttribute Staff staff,在进入方法体后打印一下这个对象,看字段有没有被填充,三两分钟就能确定。

6.3 编辑保存之后,某几个字段变成null

这是一个原理性的坑。前面逻辑里写得很清楚:更新时要先查出原实体再修改字段。很多人图省事,直接staffRepository.save(new Staff(...fields..., id)),然后发现没set的字段全被清空。根因就是JPA帮你把传入的对象当作全新实体处理,null字段直接覆盖原值。

这里可以给一个经验性判断:如果表单里只有一个隐藏的id字段和几个输入框,那么新增和更新走同一个Service方法没问题;但只要你的实体里有“后端默认值”字段,比如创建时间、状态、版本号,那就必须在Service里区分新增和更新,或者用DTO组装配后再落到实体上。早期项目先老老实实分两个方法。

6.4 中文乱码和日期回显格式不一致

中文乱码优先检查两点:数据库连接的characterEncoding=utf8、页面本身编码UTF-8。Spring Boot默认的CharacterEncodingFilter已经帮你设置了UTF-8,所以通常问题出在数据库连接串或MySQL表本身的CHARSET。

日期回显问题也常见。实体里定义的是LocalDate,页面用input type="date"绑定,理论上格式一致不会出问题。但如果浏览器显示成2024-01-01T00:00:00这种值,说明实体字段用了LocalDateTime。如果你只是想存日期,就用LocalDate;想存时间,就用LocalDateTime,不要含糊混用,否则模板里格式化逻辑会变成一团乱麻。

我把这几个高频问题汇总成一张表,方便你排查时快速对照。

现象根因排查方向
列表页能访问,表单提交405/404路由路径或方法不匹配Network面板核对URL与Method
空值照样保存成功校验依赖缺失或参数顺序错误检查依赖与BindingResult位置
编辑后部分字段变NULL更新时未先查原实体Service层更新逻辑拆开处理
中文乱码连接串或表字符集不对检查URL参数和数据库CHARSET
日期出现T时间戳格式实体字段类型与输入框不一致统一LocalDate或LocalDateTime

6.5 ddl-auto: update带来的连带问题

开发期用update省事,但它的副作用也大。比如你修改了字段长度定义,Hibernate会尝试改表;数据库表里如果已经有数据,字段类型转换失败,启动直接报错。更麻烦的是,当你改了字段名,老字段不会自动删除,表结构变得越来越脏。

我的习惯是:本地开发阶段,表结构不重要的直接用create-drop,每次重启干净重建;一旦本地已经有测试数据了,再切回update;部署到测试环境时使用validate,保证实体和表结构一致,不一致就启动失败。生产环境直接none或validate,任何自动改表的操作都是灾难。

7. 这一版做完之后,项目还能往哪走

基本的增删改查跑通之后,很多人会觉得“项目做完了”,但实际上这才刚跨过门槛。同一套骨架,你可以顺手做三件事,让项目直接上一个台阶。

第一,给列表页加搜索和过滤。Repository里多写一个方法,比如findByDepartmentAndNameContaining,Controller接收查询参数拼装Pageable,模板里加一个查询表单。这不需要改任何其他层,十几分钟就能完成。

第二,处理关联表数据。比如员工属于某个部门,部门本身是另一张表。这时候实体关系注解@ManyToOne、表单下拉选「部门」,查询时用fetch避免N+1问题。这是CRUD到真实系统之间最重要的一步。

第三,做权限控制。引入Spring Security后,要求未登录用户不能访问/staffs,只有管理员能删除,普通用户只能查看和编辑。这个需求本质是给现有代码加过滤器,CRUD的核心逻辑不需要改。

我自己在实际项目里常把这些功能留作下一步的迭代项,而不是在第一版就全部做进去。原因很简单:第一版的目标是“跑得动、看得懂、可扩展”,结构清晰比功能多更重要。你把CRUD的骨架打扎实,后续加搜索、加权限、加报表,都是在稳定地基上添砖加瓦,不太会出现推倒重来的情况。

如果你后续确实要做搜索分页这类查询增强,建议先去掌握Spring Data JPA的Specification或QueryByExample。这两个机制能帮你用动态条件组合查询,而不是在每个方法名里堆字段条件。但前面那个前提依然成立——先把基础CRUD吃透,再去碰动态查询,不然你会被一堆抽象概念绕晕。

顺便给一个我个人的使用习惯:做完这个项目后,我一般会把它保留为一个模板工程,后续新项目需要后台管理模块就直接从它复制改。因为它的分层清晰、表单复用逻辑完整、分页和校验都已跑通,改表名、改字段、调整页面文案,半小时就能交付一个新的管理模块。这种“拿来即用”的积累价值,比再写十个Demo都有用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 2:58:31

多模态集成实战:从视觉编码器到指令微调

多模态集成这件事&#xff0c;我在实际项目里折腾了小半年&#xff0c;踩过不少坑&#xff0c;也总结出一些能直接落地的经验。所谓"将多模态数据集成到大型语言模型中"&#xff0c;说白了就是让一个原本只会读文字的模型&#xff0c;能同时看懂图片、听清音频、理解…

作者头像 李华
网站建设 2026/10/18 2:57:09

基于SSM和Vue的抗疫物资管理系统毕设完整指南

“毕设题目到底选什么”这事&#xff0c;每年都能逼疯一批人。选个纯管理系统&#xff0c;怕太简单过不了&#xff1b;选个算法项目&#xff0c;又担心写不完代码。如果你现在手里拿到的题目是“基于SSM和Vue的抗疫物资管理系统”&#xff0c;我倒是觉得这是个挺稳的选择——技…

作者头像 李华
网站建设 2026/10/18 2:55:17

为什么选Ming-Image?3大核心能力解锁高质量AI绘图

【免费下载链接】Ming-Image 项目地址&#xff1a; https://ai.gitcode.com/hf_mirrors/Comfy-Org/Ming-Image 点击查看 免费下载 Ming-Image 是面向 ComfyUI 的 AI 绘图模型包&#xff0c;重新打包了 inclusionAI 的 Ming-Image-0.1-Design 系列模型&#xff0c;开箱即用地提…

作者头像 李华
网站建设 2026/10/18 2:54:52

多传感器融合层级解析:前融合、特征级融合、决策级融合,如何突破自动驾驶感知性能天花板

1. 融合层级背后的核心逻辑&#xff1a;不是在哪个阶段合并数据&#xff0c;而是在哪个阶段放弃信息多传感器融合&#xff0c;说白了就是让摄像头、激光雷达、毫米波雷达这些传感器“把话说齐”&#xff0c;但“对齐”这件事发生在哪个环节&#xff0c;直接决定了系统的性能上限…

作者头像 李华
网站建设 2026/10/18 2:53:56

用Python从零搭建有限元分析系统:核心流程与工程实践

我自己写过不止一套有限元分析程序。最早一次是在处理某支架的强度校核&#xff0c;商业软件模型传到同事手里&#xff0c;改一版边界条件结果就变一个样&#xff0c;最后谁也说不清那个约束到底是固接还是铰接。从那以后&#xff0c;我决定自己做一套能看清每一步的Python有限…

作者头像 李华