简介:这是一份基于Java的宠物管理系统课程设计资源,面向Java Web初学者与课程设计开发者,完整覆盖SSM(Spring、SpringMVC、MyBatis)框架整合、Layui前端页面搭建及MySQL数据库设计等环节,可用于宠物店或动物保护机构的信息化管理场景。压缩包共1442个文件,约136.6MB,包含java源码、jsp页面、class编译文件、jar依赖库、js/css等前端资源,以及png/jpg/gif图片素材、sql数据库脚本和mp4演示视频,结构完整便于对照学习。已有2171人浏览学习。通过该资源可掌握SSM项目从环境配置、表结构设计到前后端联调的全过程,同时提供myeclipse工程配置文件与实验报告文档,适合作为毕业设计或课程设计的参考模板。
1. 基于Java的宠物管理系统:这门课设/项目到底要做什么
每个学期期末,总有人拿着“基于Java的宠物管理系统”这个题目来找我:有人要交课程设计,有人想拿它当面试项目,还有人真的开了宠物店想给自己做个后台。这个题目看着不难,但坑一点都不少——技术路线选错、数据库连不上、中文乱码、寄养订单重复结算,任何一项都能让你在验收前一晚对着控制台发呆。下面按我实际带人做这类系统的顺序来讲:先选技术路线,再建表,再写一个 Spring Boot + JdbcTemplate 能跑起来的完整雏形,最后给出我踩过五次的坑和进阶验证清单。适合刚学完 Java 基础、想把手头的管理系统做成“能演示、能答辩、能讲清为什么”的读者;如果你已经在背 Java 八股文,不如先把这个系统跑通,面试时反而更有话讲。
2. 技术选型:从 Swing 到 Spring Boot,四条路线怎么选
2.1 四条常见路线:控制台、JavaFX、Swing+JDBC、Spring Boot
“基于Java的宠物管理系统”这种题目,能落地的技术组合其实就四类。很多网上的课程设计案例源码还停留在 Swing 时代,不是不能用,而是你要想清楚做完之后它能不能继续生长。
| 路线 | 技术组成 | 适合谁 | 最大的坑 |
|---|---|---|---|
| 控制台程序 | Java SE + ArrayList/HashMap + 文件读写 | 只要交作业、不在意演示效果 | 数据存文件,重启就丢,无说服力 |
| JavaFX + JSON/CSV | Java SE + JavaFX + 本地文件 | 想做个本地单店演示 | 界面现代但打包复杂,多用户并发没法保证 |
| Swing + JDBC + MySQL | Java SE + Swing + JDBC + MySQL | 经典课设组合,能讲清UI和SQL | 布局代码量大,样式老旧,难扩展成Web |
| Spring Boot + Thymeleaf + MySQL | Java + Spring Boot + JdbcTemplate + Thymeleaf | 想真正落地、找工作和谈业务 | 学习跨度稍大,但值得投入 |
我的判断标准很简单:如果只有 48 小时并且明确“只要交作业”,Swing + JDBC 是保底方案,因为它的演示路径最短,老师问起来你也能把 JDBC 的 DriverManager、Connection、Statement 讲明白。但如果你想把“宠物管理系统”做成简历上的项目,甚至以后让宠物店真的用起来,直接走 Spring Boot 路线,别在 JavaFX 上浪费时间——原因很简单:Web 系统不用装客户端,数据进 MySQL,部署到云服务器上手机也能访问,这些是本地桌面程序给不了的。
2.2 为什么我推荐 Spring Boot + JdbcTemplate,而不是一上来就 MyBatis
管理系统本质上是增删改查,SQL 复杂度不高。用 JdbcTemplate 写,你能清楚看到每一条 SQL、每一个参数、每一次类型转换;换成 MyBatis 后,多了一层 XML 映射和 Mapper 接口,新手一旦 SQL 写错,经常分不清是 XML 解析问题还是参数绑定问题,等于多背一个黑匣子。Spring Data JPA 更不适合这个场景,宠物列表往往要联查主人姓名、订单状态,JPA 的懒加载很容易搞出 N+1 条 SQL,页面一开慢得明显。
所以我的选型结论是:Spring Boot + Thymeleaf + JdbcTemplate + MySQL。具体创建项目时,用你 IDE 里的 Spring Initializr 向导,勾选 Web、Thymeleaf、JDBC、MySQL Driver 四个依赖即可,不用手工建目录。运行环境建议按下面这个表对齐,少踩很多版本坑:
| 环境项 | 建议版本 | 说明 |
|---|---|---|
| JDK | 8 / 11 / 17 均可 | pom 里的 java.version 必须和实际安装一致 |
| Maven | 3.6 以上 | 用mvn -v看实际生效的 JDK,别只看 JAVA_HOME |
| MySQL | 5.7 或 8.0,推荐 8.0 | 建库字符集用 utf8mb4 |
| Spring Boot | 2.7.x | 较新的 3.x 也能用,但注意 javax 换成 jakarta |
2.3 这个项目里最容易走偏的两件事:排序和内存存储
很多同学拿到题目第一反应是“我要写个排序”,甚至有人在 Service 里手写冒泡排序来给宠物按名字排序。这里有个原则:管理系统的排序交给数据库,不要在 Java 内存里自己排。一条ORDER BY created_at DESC, id DESC就能稳定解决,你手写冒泡排序不仅性能更差,还要处理中文排序规则,纯属给自己挖坑。如果真遇到内存排序,用Comparator.comparing也就够了,别从排序算法开始造轮子。
另一个更隐蔽的坑是存储方案。我见过有人用 ArrayList + 文件序列化做了一个宠物系统,演示当天没问题,第二天开机数据全没了,最后只能在答辩时硬着头皮说“这是测试版”。真实宠物店的数据是客户和活体宠物,丢一条都是事故。所以从第一章开始就把 MySQL 放进去,这是管理系统的底线,不是可选项。
3. 表结构先行:宠物、主人、寄养订单的建库 SQL 与字段边界
3.1 三张核心表的关系与建库 SQL
宠物管理系统的核心实体就三个:主人(customer)、宠物档案(pet)、寄养订单(boarding_order)。关系是:一个主人可以养多只宠物;一个宠物可以多次寄养。我一般会在订单表里冗余一个 customer_id,不是为了省一次联查,而是因为寄养订单本质是业务快照——宠物主人后来换了手机号,历史订单里应该保留当时的客户指向。
下面是建库建表 SQL,直接复制到 MySQL 里执行即可:
CREATE DATABASE IF NOT EXISTS pet_manager CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE pet_manager; CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '客户ID', name VARCHAR(50) NOT NULL COMMENT '主人姓名', phone VARCHAR(20) NOT NULL COMMENT '手机号', address VARCHAR(255) NOT NULL DEFAULT '' COMMENT '默认地址', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建档时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宠物主人表'; CREATE TABLE pet ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '宠物ID', customer_id BIGINT NOT NULL COMMENT '主人ID', name VARCHAR(50) NOT NULL COMMENT '宠物名', species VARCHAR(30) NOT NULL COMMENT '猫/狗/兔子', breed VARCHAR(50) NOT NULL DEFAULT '' COMMENT '品种', gender TINYINT NOT NULL DEFAULT 0 COMMENT '0未知 1公 2母', birthday DATE DEFAULT NULL COMMENT '出生日期', weight_kg DECIMAL(5,2) DEFAULT NULL COMMENT '体重kg', archived TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1归档', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建档时间', KEY idx_customer_archived (customer_id, archived), KEY idx_species (species), CONSTRAINT fk_pet_customer FOREIGN KEY (customer_id) REFERENCES customer(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='宠物档案表'; CREATE TABLE boarding_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '订单ID', pet_id BIGINT NOT NULL COMMENT '宠物ID', customer_id BIGINT NOT NULL COMMENT '主人ID', check_in_time DATETIME NOT NULL COMMENT '入住时间', check_out_time DATETIME DEFAULT NULL COMMENT '离店时间', fee_per_day DECIMAL(8,2) NOT NULL DEFAULT 0 COMMENT '每日寄养费', total_fee DECIMAL(10,2) DEFAULT NULL COMMENT '离店结算金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0寄养中 1已离店 2已取消', note VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', KEY idx_status_checkin (status, check_in_time), KEY idx_pet_id (pet_id), CONSTRAINT fk_order_pet FOREIGN KEY (pet_id) REFERENCES pet(id), CONSTRAINT fk_order_customer FOREIGN KEY (customer_id) REFERENCES customer(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='寄养订单表';这段 SQL 里最值得注意的不是字段多,而是三个设计细节。第一,外键约束保留下来,虽然生产环境很多人会去掉外键,但课程设计和中小型系统里外键能帮你挡住“删了正在寄养的宠物”这种低级错误。第二,pet 表没有直接删数据,而是加了 archived 字段做软删除,这样历史寄养订单还能关联到宠物名,不会出现“订单里宠物已不存在”的空洞。第三,boarding_order 加了 version 字段,这不是业务字段,而是给后面的并发控制用的,先建好,后面写离店结算时直接受益。
3.2 字段边界:为什么手机号用 VARCHAR、金额用 DECIMAL、日期用 DATETIME
这几张表的字段类型是有讲究的,踩坑往往就踩在这里。
手机号必须用 VARCHAR(20) 而不是 BIGINT。原因有两个:手机号可能有前导 0,也可能带 +86 前缀;如果用数值类型,超过一定位数还会触发精度问题。虽然中国手机号目前都是 1 开头,但你无法保证以后不做海外客户或分机号。
金额字段用 DECIMAL 而不是 FLOAT 或 DOUBLE。这是 Java 面试八股里反复考的精度问题:二进制浮点数无法精确表示 0.1,寄养费按天累加会出现 19.99 变成 20.0000001 这种诡异情况。fee_per_day用DECIMAL(8,2)表示一天最多 99 万,total_fee用DECIMAL(10,2)表示累计金额。体重用DECIMAL(5,2),够记录 999.99 公斤的宠物了。
日期字段要区分场景:生日用 DATE,只存年月日;寄养入住和离店用 DATETIME,不带时区转换。很多人习惯用 TIMESTAMP,这在高版本 MySQL 里会有 UTC 转换问题,应用服务器和数据库时区不一致时,查询结果经常差 8 小时。DATETIME 存的是字面时间,配合 JDBC URL 里的serverTimezone=Asia/Shanghai,能少踩一半时区坑。
性别和状态我用 TINYINT 加注释,不用 ENUM。ENUM 看着直观,但以后要加一个“未知/绝育”状态,得 ALTER TABLE 改枚举定义;TINYINT 只需要在代码里约定数字含义,扩展成本低得多。状态字段有三个值(0 寄养中、1 已离店、2 已取消),如果一开始用 BOOLEAN 来表示,后面扩展就非常痛苦。
3.3 索引怎么加:外键自动索引与三个高频查询
表建好后,索引不是越多越好。这个系统的高频查询其实只有三类:按主人查未归档宠物、按状态查寄养中的订单、按时间范围查订单用于月结对账。
pet表我建了复合索引(customer_id, archived),因为最常见页面是“张三的宠物列表”,查询条件是WHERE customer_id = ? AND archived = 0。单独建 customer_id 索引也能用,但复合索引可以直接过滤 archived,数据量大时性能差别明显。boarding_order表建了(status, check_in_time)复合索引,因为前台要显示“当前寄养中”的列表,后台要按月份统计收入,这两个查询都能命中索引最左前缀。
需要注意,外键约束会自动为外键列创建索引,所以pet.customer_id和boarding_order.pet_id不需要手动重复建单列索引。但复合索引不会自动生成,这也是我特意加idx_customer_archived和idx_status_checkin的原因。索引设计的原则是“先写查询,再建索引”,而不是每个字段都来一个。
4. 可抄作业的最小实现:Spring Boot + JdbcTemplate 跑通宠物管理
4.1 项目结构与配置:pom.xml 和 application.yml 怎么定
这一章给出一个能完整跑起来的最小项目。我选用 Spring Boot 2.7.x,因为很多读者本地还是 JDK 8,切换成本最低。先看 Maven 依赖,核心只需要四个 starter:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>1.8</java.version> </properties> <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-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> </dependencies>spring-boot-starter-parent已经把 MySQL 驱动版本管好了,所以不用写版本号。如果你用 JDK 17 并且想上 Spring Boot 3.x,记得把javax.servlet相关的包全部换成jakarta.servlet,否则启动直接 NoClassDefFoundError。
接着是src/main/resources/application.yml:
spring: datasource: url: jdbc:mysql://localhost:3306/pet_manager?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false server: port: 8080URL 里的参数没有一个是多余的:characterEncoding=utf8保证中文不乱码;serverTimezone=Asia/Shanghai解决 MySQL 8 的时区识别报错;allowPublicKeyRetrieval=true是配合 MySQL 8 默认的caching_sha2_password认证插件,不加它连接时可能报“Public Key Retrieval is not allowed”。cache: false是让 Thymeleaf 模板改完刷新浏览器就能看到效果,不用每次重启。
4.2 实体类与 DAO:RowMapper、插入返回主键、软删除
先写一个最朴素的 Pet 实体,字段和表结构一一对应。这里不用 Lombok,免得新手看不懂;getter/setter 用 IDE 的 Alt+Insert 一键生成。
import java.math.BigDecimal; import java.time.LocalDate; public class Pet { private Long id; private Long customerId; private String name; private String species; private String breed; private Integer gender; // 0未知 1公 2母 private LocalDate birthday; private BigDecimal weightKg; private Integer archived; // 0正常 1归档 // 生成 getter / setter }DAO 层直接用 JdbcTemplate,核心是 RowMapper。这一步体现了“一行数据库记录变成一个 Java 对象”的完整过程:
@Repository public class PetDao { private final JdbcTemplate jdbcTemplate; public PetDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } private final RowMapper<Pet> rowMapper = (rs, rowNum) -> { Pet pet = new Pet(); pet.setId(rs.getLong("id")); pet.setCustomerId(rs.getLong("customer_id")); pet.setName(rs.getString("name")); pet.setSpecies(rs.getString("species")); pet.setBreed(rs.getString("breed")); pet.setGender(rs.getInt("gender")); pet.setArchived(rs.getInt("archived")); java.sql.Date birthday = rs.getDate("birthday"); if (birthday != null) { pet.setBirthday(birthday.toLocalDate()); } pet.setWeightKg(rs.getBigDecimal("weight_kg")); return pet; }; public List<Pet> listByCustomer(Long customerId) { return jdbcTemplate.query( "SELECT * FROM pet WHERE customer_id = ? AND archived = 0 " + "ORDER BY created_at DESC, id DESC", rowMapper, customerId); } public Pet findById(Long id) { return jdbcTemplate.queryForObject( "SELECT * FROM pet WHERE id = ? AND archived = 0", rowMapper, id); } public Long insertAndReturnId(Pet pet) { KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(connection -> { PreparedStatement ps = connection.prepareStatement( "INSERT INTO pet (customer_id, name, species, breed, gender, birthday, weight_kg) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", PreparedStatement.RETURN_GENERATED_KEYS); ps.setLong(1, pet.getCustomerId()); ps.setString(2, pet.getName()); ps.setString(3, pet.getSpecies()); ps.setString(4, pet.getBreed()); ps.setInt(5, pet.getGender()); ps.setDate(6, pet.getBirthday() == null ? null : java.sql.Date.valueOf(pet.getBirthday())); ps.setBigDecimal(7, pet.getWeightKg()); return ps; }, keyHolder); return keyHolder.getKey().longValue(); } public int archive(Long id) { int rows = jdbcTemplate.update( "UPDATE pet SET archived = 1 WHERE id = ? AND archived = 0", id); if (rows == 0) { throw new IllegalArgumentException("宠物不存在或已归档"); } return rows; } }代码里的参数顺序必须和 SQL 占位符一一对应,这是新手最容易翻车的地方,少传一个参数或传错位置,报错信息往往还不好懂。insertAndReturnId用到了GeneratedKeyHolder,因为JdbcTemplate.update返回的是影响行数,不是自增主键;如果不注册RETURN_GENERATED_KEYS,keyHolder.getKey()会抛异常,这一点第 5 章还会展开讲。archive是软删除,故意在 UPDATE 里加了archived = 0条件,这样重复点击删除时会抛异常提示,而不是默默返回成功。
寄养订单的 DAO 只需先实现一个“登记入住”的方法,让主流程能跑通:
@Repository public class BoardingOrderDao { private final JdbcTemplate jdbcTemplate; public BoardingOrderDao(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public void startBoarding(Long petId, Long customerId, LocalDate checkInDate) { jdbcTemplate.update( "INSERT INTO boarding_order (pet_id, customer_id, check_in_time, status, version) " + "VALUES (?, ?, ?, 0, 0)", petId, customerId, checkInDate); } }这个 INSERT 里故意把status和version写成固定值,是因为新订单一定是“寄养中”且版本从 0 开始。check_in_time在 URL 参数里是字符串,Spring MVC 会负责转成 LocalDate,但要在 Controller 里加@DateTimeFormat注解。
4.3 Controller 与 Thymeleaf 页面:先跑通新增宠物和寄养登记
Controller 走最简单的 Spring MVC 写法,一个类负责页面跳转和表单提交:
@Controller @RequestMapping("/pets") public class PetController { private final PetDao petDao; private final BoardingOrderDao boardingOrderDao; public PetController(PetDao petDao, BoardingOrderDao boardingOrderDao) { this.petDao = petDao; this.boardingOrderDao = boardingOrderDao; } @GetMapping public String list(@RequestParam(required = false) Long customerId, Model model) { if (customerId == null) { model.addAttribute("pets", petDao.listAll()); } else { model.addAttribute("pets", petDao.listByCustomer(customerId)); } return "pet/list"; } @GetMapping("/new") public String createForm(Model model) { model.addAttribute("pet", new Pet()); return "pet/form"; } @PostMapping public String create(@ModelAttribute Pet pet) { Long petId = petDao.insertAndReturnId(pet); return "redirect:/pets/" + petId; } @PostMapping("/{id}/boarding") public String startBoarding(@PathVariable Long id, @RequestParam Long customerId, @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate checkInDate) { boardingOrderDao.startBoarding(id, customerId, checkInDate); return "redirect:/pets/" + id; } }@ModelAttribute Pet pet会把表单里 name、species、breed 这些字段自动绑定到 Pet 对象上,前提是表单控件的 name 属性和 Java 字段名一致。新增成功后用redirect:/pets/{id}做重定向,避免用户刷新浏览器时重复提交表单,这是 Post/Redirect/Get 的标准做法。
Thymeleaf 模板放在src/main/resources/templates/pet/list.html,核心片段是这样:
<table> <thead> <tr> <th>宠物名</th> <th>种类</th> <th>主人ID</th> <th>体重(kg)</th> <th>操作</th> </tr> </thead> <tbody> <tr th:each="pet : ${pets}"> <td th:text="${pet.name}">宠物名</td> <td th:text="${pet.species}">种类</td> <td th:text="${pet.customerId}">主人ID</td> <td th:text="${pet.weightKg}">0.00</td> <td> <a th:href="@{/pets/{id}(id=${pet.id})}">查看</a> </td> </tr> </tbody> </table>列表页不用写任何 Java 代码,Thymeleaf 的th:each就是循环输出。需要留意的是${pet.weightKg}如果为 null,页面会显示空白而不是 0.00,所以要么在数据库层给默认值,要么在实体 getter 里做空值兜底。
4.4 启动与验证:从 SQL 到浏览器的一分钟验证
表结构建好后,先插入一条测试主人和一只宠物:
INSERT INTO customer (name, phone) VALUES ('张三', '13800001111'); INSERT INTO pet (customer_id, name, species, breed, gender, birthday, weight_kg) VALUES (1, '煤球', '猫', '英短', 1, '2023-06-01', 4.50);然后在项目根目录执行:
mvn spring-boot:run启动日志出现Tomcat started on port 8080后,浏览器访问http://localhost:8080/pets?customerId=1,应该能看到“煤球”在列表里。如果页面报 500,先看控制台异常栈,八成是数据库连接参数不对或表没建成功;如果页面空白但后台没报错,多半是模板路径写错,Thymeleaf 找不到pet/list.html时会抛模板解析异常。这一套流程能通,说明项目骨架、数据库、模板引擎三个环节已经串起来了。
5. 避坑排查:驱动、时区、乱码与自增主键的五个经典现场
这一章写的都是我做这类系统时真实遇到过的现场。每条按“现象 → 原因 → 解决”展开,你可以直接拿来当排查手册用。
5.1 ClassNotFoundException: com.mysql.jdbc.Driver —— 驱动类名已经变了
现象:Spring Boot 启动时抛ClassNotFoundException: com.mysql.jdbc.Driver,或者Cannot load driver class: com.mysql.jdbc.Driver。
原因:MySQL Connector/J 5.x 的驱动类名是com.mysql.jdbc.Driver,从 Connector/J 8.0 开始改为com.mysql.cj.jdbc.Driver。如果你在网上复制了老项目的配置,或者 pom 里引入的驱动版本停在 5.x,就会遇到这个错误。Spring Boot 2.7 自带的驱动版本是 8.x,所以driver-class-name必须写新类名。
解决:把 application.yml 里的驱动类名改成:
spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver同时用 Maven 确认实际依赖版本:
mvn dependency:tree -Dincludes=mysql:mysql-connector-java如果 pom 里显式写了一个很老的版本号,把这个依赖声明删掉,交给 Spring Boot 的 parent 统一管理。
5.2 时间少 8 小时、时区报错 —— serverTimezone 与 DATETIME 的配合
现象:插入一条寄养记录,入住时间填的是 2024-06-01 10:00:00,查出来变成 2024-06-01 02:00:00;或者启动时报The server time zone value '�й���ʱ��' is unrecognized,后面跟一串乱码。
原因:MySQL 8 的 JDBC 驱动要求客户端显式指定时区,否则它读服务器系统时区,读出来是中文“中国标准时间”但字符集不对,就成了乱码。TIMESTAMP 类型在存储时会做 UTC 转换,应用服务器与数据库时区不一致,时间就会偏移 8 小时。
解决:JDBC URL 加serverTimezone=Asia/Shanghai,同时尽量把业务时间字段用 DATETIME 而不是 TIMESTAMP。DATETIME 不做时区转换,存进去是什么就是什么,对宠物寄养这种纯业务场景更友好。如果项目里已经有 TIMESTAMP 字段,还可以在 MySQL 里执行SET time_zone = '+08:00'临时验证,但长期方案还是统一时区配置。
5.3 新增宠物后详情页 404 —— 自增主键根本没取回来
现象:新增宠物表单提交后,页面跳到/pets/null或者/pets/0,详情页查不到数据。控制台没有异常,数据库里也确实多了一条记录。
原因:最常见的写法是把JdbcTemplate.update()的返回值当成主键。这个方法返回的是受影响行数,新增成功时返回 1,而不是自增 ID。如果用普通PreparedStatement执行 INSERT,也没有注册RETURN_GENERATED_KEYS,数据库生成的主键根本不会传回 Java 端。
解决:用GeneratedKeyHolder接住自增主键,参考第 4 章的insertAndReturnId。这里再强调一个细节:prepareStatement的第二个参数必须是PreparedStatement.RETURN_GENERATED_KEYS,否则keyHolder.getKey()会直接抛异常。
KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(connection -> { PreparedStatement ps = connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS); // 设置参数 return ps; }, keyHolder); Long id = keyHolder.getKey().longValue();5.4 页面中文乱码 —— 数据库、连接、模板三个源头
现象:新增一只叫“煤球”的猫,列表页显示“???”或“ç…”,但数据库命令行里查出来是正常的。有时候数据库里就是乱码,有时候只有页面乱码。
原因:中文乱码在 Java Web 里是老三样:数据库表字符集不是 utf8mb4;JDBC 连接没有指定字符编码;HTML 页面没有声明 UTF-8。三者只要有一个不对,中文就保不住。
解决:第一步,建库时用CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci,并且建表语句也用DEFAULT CHARSET=utf8mb4。第二步,JDBC URL 加useUnicode=true&characterEncoding=utf8。第三步,Thymeleaf 模板的<head>里写<meta charset="UTF-8">。排查时先用 MySQL 命令行SELECT name FROM pet;看数据本身是否正常,如果命令行乱码,说明数据入库时就已经错了,问题在 JDBC 连接;如果命令行正常页面乱码,问题在模板或浏览器编码。
5.5 mvn compile 报“源发行版 17 需要目标发行版 17” —— JAVA_HOME 与 pom 不一致
现象:执行mvn compile成功,但 IDE 里编译报错,红字提示“java: 警告: 源发行版 17 需要目标发行版 17”,或者直接 javac 错误。用命令java -version看是 17,pom 里写的却是 1.8。
原因:IDE 默认用当前 JDK 的版本作为编译器级别,而 pom 里maven.compiler.source/target或<java.version>也是另一个值,两边不一致时 Maven 就会拿 17 的 javac 去编 target 1.8 的代码。更隐蔽的是,JAVA_HOME环境变量指向 JDK 17,但 IDE 内置的 Maven 用的是自己的 JRE 配置。
解决:在 pom 的 properties 里显式声明版本,Spring Boot parent 会自动把 source 和 target 都设成这个值:
<properties> <java.version>1.8</java.version> </properties>然后检查 IDE 的 Project Structure 里 SDK 是否和 pom 一致。不要相信echo $JAVA_HOME,要以mvn -v输出里的 Java home 为准。如果你本地装的是 JDK 17,直接把<java.version>改成 17 更省事,但别同时用 JDK 17 编译 target 1.8 又开着 JDK 8 的依赖,这种错位最容易把人绕晕。
6. 进阶验证:登录拦截、乐观锁结算与交付前的回归检查
6.1 先加登录拦截器,再加乐观锁离店结算
基础功能跑通后,最值得加的两个能力是登录校验和并发控制。先说登录,Spring Boot 里加一个拦截器就能守住所有管理端点:
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getSession().getAttribute("loginUser") == null) { response.sendRedirect("/login"); return false; } return true; } }然后在WebMvcConfigurer里注册,addPathPatterns("/pets/**", "/boardings/**")拦截所有业务页面,excludePathPatterns("/login", "/css/**", "/js/**")放行登录页和静态资源。这段代码能挡住“直接在浏览器输入 URL 访问详情页”的越权路径。
寄养离店结算是另一个经典并发场景。两个前台同时点“结算”,如果代码是“先查订单,判断状态,再 UPDATE”,就会有两个线程都读到 status=0,然后都执行更新,最终重复结算。Java 面试喜欢问的“怎么保证数据一致性”,在这个系统里最务实的答案是乐观锁。利用表里已经建好的 version 字段:
UPDATE boarding_order SET status = 1, total_fee = ?, version = version + 1 WHERE id = ? AND status = 0 AND version = ?;执行后影响行数为 1,说明结算成功;影响行数为 0,说明订单已经被别人结算过或状态不对,此时页面明确提示“请刷新后重试”,而不是静默失败。这里的version = ?必须传之前查询出来的旧值,version = version + 1让每次成功更新都改变版本号,下一次旧版本号再提交就匹配不上。
6.2 交付前只跑这 12 个回归检查点
我现在的习惯是功能写完先不急着拍照演示,而是固定过一遍 12 个检查点:1 未登录访问 /pets 要跳转登录页;2 登录后直接访问别人的宠物详情要被拦截或校验归属;3 中文新增和查询全程不乱码;4 日期显示没有 8 小时偏移;5 新增宠物后跳转的详情页能打开且数据正确;6 金额计算不出现浮点误差,保留两位小数;7 同一订单重复点击离店结算只有第一次成功;8 订单状态只能按 0→1 或 0→2 的方向流转,不能从已取消变回寄养中;9 归档宠物不再出现在列表,但历史订单仍能显示宠物名;10 新客户没有宠物时列表显示空态而不是报 500;11 列表排序稳定,同一秒创建的多条记录按 ID 倒序排列;12 重启应用后数据仍在,确认用的是 MySQL 而不是内存存储。
这套检查点是我踩了多次数据丢失和重复结算后沉淀下来的,比在答辩现场被老师问倒再补救要体面得多。你可以按自己的项目裁剪,但 7、9、12 三条建议永远保留。希望帮到你。
本文还有配套的精品资源,点击获取