第一次被宠物店老板娘问到能不能做线上预约时我下意识觉得这就是个普通的 CRUD存用户、存宠物、存订单顶多再报个今日预约数量。真正动手之后才发现美容和疫苗这两种预约完全不是一回事光是一个时间冲突检测就够改三版 SQL。这个网上宠物店美容疫苗预约系统代码习惯叫 springboot_ssm863也是门店线上化需求里最常见的案例之一技术栈就是 Spring Boot SSM。文章会沿着业务模块拆解、数据库设计、核心接口实现、管理端落地、部署踩坑这条线完整过一遍适合做毕设、课程设计或者想真正搞懂 Spring Boot 整合 SSM 的人。1. 业务拆解与技术选型先别急着敲代码1.1 一个预约系统到底包含哪些模块我先说结论这种项目的代码量其实不大最花时间的是把业务约束想清楚。以这个网上宠物店美容疫苗预约系统为例我用一张模块清单把需求定死后面所有表结构和接口都从这张清单推导出来。端模块具体功能用户端注册登录手机号/用户名注册、登录、退出用户端宠物档案添加/编辑/删除宠物维护宠物照片和基础信息用户端美容预约浏览美容项目、选择美容师、选择日期时段、提交预约用户端疫苗预约查看疫苗批次库存、选择针次、预约并生成接种提醒用户端订单中心查看预约状态、取消未开始的预约管理端预约审核确认/取消预约、标记完成管理端美容师管理美容师信息维护、排班管理管理端疫苗管理疫苗品种、批次、库存、效期管理管理端数据统计预约量、服务类型占比、疫苗库存预警写代码前先把这张表过一遍就能发现美容预约和疫苗预约的根本差异。美容预约的冲突点在于资源美容师的时间安排同一个美容师同一个时间段只能有一个预约疫苗预约的冲突点在于库存疫苗批次和宠物接种计划同一只宠物同一种疫苗的第二针不能在第一针状态没完成时就预约批次没货也不能预约。一个偏资源调度一个偏库存约束后面建表时会分化出不同的字段。1.2 为什么用 Spring Boot SSM而不是直接上全家桶这里得替很多同学问一句都这个年头了为什么不用 Spring Cloud、不用 JPA甚至不用 MyBatis-Plus原因很现实。第一这是典型的单体应用。一个小型宠物店日预约量几十单把单体拆成微服务纯属增加成本。Spring Boot 本身已经帮我们省掉了大量 XML 配置Spring MVC 负责接口MyBatis 负责把 SQL 握在自己手里对应热词里几乎所有 Spring Boot 基础知识点都能在这个项目里落地。第二SSM 是教学主流。无论课程设计还是毕设很多学校的评分标准就是 Spring Boot 整合 SSM 的过程。如果你直接上 MyBatis-Plus确实快但面试官问“MyBatis 的动态 SQL 你写过没有”就会卡壳。用原生 MyBatis 写几条if、choose、where比背一百个注解都有说服力。第三事务和并发控制更适合手写 SQL。预约系统最怕超卖和重复预约MyBatis 允许我用UPDATE ... WHERE status #{expected}这种带条件的更新去保证状态流转这是 ORM 自动生成 SQL 很难做到精确控制的场景。JPA 也能做但很多新手在 JPA 的乐观锁注解里绕半天不如直接写 SQL 来得直观。1.3 权限角色怎么设计才不啰嗦用户端、美容师、管理员三个角色。我见过一些毕设把每个角色建一张用户表PetUser、Beautician、Admin结果一个登录接口要查三张表联动起来非常痛苦。正确做法是把登录主体统一为 user 表用一个 role 字段区分role 1 普通用户role 2 美容师可同时是登录用户但不承担用户端下单功能role 3 管理员美容师的排班信息另外建 beautician 表主键和 user.id 关联。这样登录校验统一后面做 HandlerInterceptor 只需要判断 session 里的 user.role 是否在允许的集合里不需要三套拦截逻辑。这个设计够应付课程设计也够说明权限设计的基本思路。提示项目里可以让美容师也能登录用户端但通过角色拦截限定他会自动跳到管理页面。如果嫌麻烦role2 单独走一套登录后的菜单即可。2. 项目初始化Spring Boot 整合 SSM 的完整配置2.1 依赖搭配与版本陷阱我用 Spring Boot 2.7.18 mybatis-spring-boot-starter 2.3.2。先说为什么不是 Spring Boot 3.xSpring Boot 3 全面切换到 Jakarta EE很多老教程代码里的 javax.servlet 全部失效新手排查起来非常痛苦。2.7.18 是 2.x 最后一个稳定版维护时间长、兼容资源多课程设计完全够用。pom.xml 核心依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies版本陷阱在 mybatis 扩展包里特别明显。网上搜到很多配置用的是mybatis-spring-boot-starter 1.3.x那是配合早期 Spring Boot 1.x/2.0 的直接用到 2.7 上会出现启动或 SQL 执行层面的兼容问题。判断方法很简单只要启动时看到关于MapperScannerConfigurer的告警或者 SQL 打印不出来优先怀疑 starter 版本。2.2 application.yml 里那些不说清楚会一直踩的配置核心配置如下server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/pet_store?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 username: root password: 123456 thymeleaf: cache: false jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.petstore.entity configuration: map-underscore-to-camel-case: true配置项逐个说serverTimezoneAsia/Shanghai不写这个MySQL 8.x 驱动连接时经常会报 “The server time zone value” 错误或者时间字段比真实时间差 8 小时。map-underscore-to-camel-case: true开了之后数据库的pet_name自动映射到实体属性petName少写无数 resultMap。spring.thymeleaf.cache: false开发时改模板刷新就能生效对应平时说的 Thymeleaf 热更新。生产环境再开成 true。jackson.date-format统一前后端日期格式这样返回给页面的时间就不会是2025-01-01T00:00:00这种 T 字串。这里顺便回答热词里那个问题Spring Boot 为什么能“自动装配”。SpringBootApplication内部含EnableAutoConfiguration各个 starter 在自己的META-INF/spring.factories文件里声明了自动配置类。比如 mybatis 的 starter 声明了MybatisAutoConfiguration它读取你的spring.datasource和mybatis.*配置自动创建SqlSessionFactory、SqlSessionTemplate并扫描所有Mapper接口。所以你能用一个启动类跑起来不是魔法是每个 starter 都在启动时默默干了一堆活。2.3 目录结构按层分包还是按模块分包新手最喜欢把全部 controller 堆在一个包里项目几十个接口时找文件找半天。我给的标准结构petstore/ ├── pom.xml └── src/main/ ├── java/com/petstore/ │ ├── PetstoreApplication.java │ ├── config/ # 拦截器、WebMvc配置 │ ├── controller/ # 接口层 │ ├── service/ # 业务逻辑 │ ├── mapper/ # MyBatis Mapper接口 │ ├── entity/ # 实体类 │ ├── dto/ # 请求/响应对象 │ └── common/ # 统一返回体、异常、常量 └── resources/ ├── mapper/ # MyBatis XML ├── static/ # css/js/图片 └── templates/ # Thymeleaf模板单体项目按技术层分包没问题因为业务单一。但如果这个项目后续会长成“宠物商城社区”我更推荐按业务模块拆成 pet、appointment、vaccine、system 四个子包每包里再放 controller/service/mapper。哪个方案适合你的项目取决于你打算写多少接口。课程设计接口少于 60 个按层分包足够目标是真正上线迭代按模块分包更稳。3. 数据库设计预约系统最复杂的不是 CRUD是约束3.1 五张核心表的字段全解建表语句我不贴全量脚本挑关键字段讲。预约系统至少五张表user、pet、beautician、vaccine_batch、appointment。userCREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(30), phone VARCHAR(20), role TINYINT DEFAULT 1 COMMENT 1-用户 2-美容师 3-管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );petCREATE TABLE pet ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, pet_name VARCHAR(50) NOT NULL, breed VARCHAR(50), birth_date DATE, gender TINYINT COMMENT 0-女孩 1-男孩, photo_url VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );appointmentCREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) UNIQUE COMMENT 业务订单号, user_id BIGINT NOT NULL, pet_id BIGINT NOT NULL, pet_name VARCHAR(50) COMMENT 冗余避免列表页反复联表, service_type TINYINT NOT NULL COMMENT 1-美容 2-疫苗, beautician_id BIGINT COMMENT 美容师id仅美容, vaccine_id BIGINT COMMENT 疫苗批次id仅疫苗, dose_seq TINYINT COMMENT 第几针仅疫苗, appoint_date DATE NOT NULL, time_slot VARCHAR(20) NOT NULL COMMENT 如 09:00-10:00, status TINYINT DEFAULT 0 COMMENT 0-待确认 1-已确认 2-已完成 3-已取消, cancel_reason VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_date (user_id, appoint_date), KEY idx_date_slot (appoint_date, time_slot) );说下冗余字段的取舍。appointment.pet_name是故意冗余的预约列表页、管理端要看宠物名如果每次都去pet表查一次列表 N 条预约就是 N1 次查询。但像beautician_name我反而建议不冗余因为美容师改名、离职后历史订单显示旧名字还是新名字是业务规则问题留给联表查询更清晰。冗余不是不联表是只冗余稳定且高频的字段。3.2 时间段和状态机怎么建模时间段的两种建模方式快照式appoint_datetime_slot字符串如2025-06-0109:00-10:00。简单直观、页面展示方便适合门店固定时间段如每半小时一个槽位。区间式start_timeend_time两个 DATETIME。灵活支持任意时间粒度但计算冲突稍微复杂需要判断区间重叠。课程设计我强烈推荐第一种因为展示层几乎不用处理格式SQL 判断也变成了简单的等值判断。如果你要做一个真正商用的门店系统再升级成区间式。状态机用一张表说清楚操作原状态目标状态条件提交预约-0 待确认通过冲突检测和库存检测管理员确认0 待确认1 已确认美容师有空、疫苗有库存用户/管理员取消0/13 已取消未开始服务美容师完成1 已确认2 已完成服务已执行我从不允许从“已完成”直接取消也不允许从“已取消”再变回已确认。状态流转代码必须校验当前状态后面管理端章节会讲怎么用 update 语句做原子状态流转。3.3 动态 SQL 实现双重冲突检测冲突检测是预约系统的灵魂。拿美容预约举例需要检测“同一美容师同一日期同一时段是否已有未取消的预约”拿疫苗预约举例需要检测“同一用户同一宠物同针序是否已预约”通用规则还有“同一用户同一时间不能有两个预约”。select idcountConflict resultTypeint SELECT COUNT(*) FROM appointment WHERE appoint_date #{appointDate} AND time_slot #{timeSlot} AND status IN (0, 1) choose when testserviceType 1 AND beautician_id #{beauticianId} /when otherwise AND user_id #{userId} AND vaccine_id #{vaccineId} AND dose_seq #{doseSeq} /otherwise /choose /select这个查询逻辑对应三句话规则美容预约beautician_id相同的未完成记录不能存在疫苗预约user_id vaccine_id dose_seq相同的记录不能存在通用状态只算待确认和已确认取消的不算占用资源。这里特别要注意status IN (0, 1)。很多初学者判断冲突时把已取消的预约也算进去导致用户取消一次之后就再也预约不了同一个时间段这是个非常隐蔽的 bug。建议初始化数据时就把“取消不算占用”的规则写进注释。提示为了避免用户端输入任意时间前端日期选择器只开放未来 7 天时间段下拉框固定为门店配置的槽位。这个约束写在前端只是一层皮后端接口仍要再校验一次时间不能在今天之前。4. 用户端核心流程从注册登录到提交预约4.1 登录鉴权一个 HandlerInterceptor 就够了单体 Thymeleaf 项目里Session 登录是最自然的方案。写一个拦截器Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object loginUser request.getSession().getAttribute(loginUser); if (loginUser null) { response.sendRedirect(/login); return false; } return true; } }再通过 WebMvcConfigurer 注册并顺便区分角色权限Configuration public class WebConfig implements WebMvcConfigurer { private final LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /register, /css/**, /js/**); } }如果需要“管理端必须 role3”再写一个 AdminInterceptor或者在一个拦截器里根据 URL 前缀判断。我习惯把 admin 端接口统一挂在/admin/**下拦截器里判断前缀角色代码最集中。用过滤器也能做但拦截器能拿到 HandlerMethod后续做接口级权限控制更方便。顺带提一句如果项目改成前后端分离Session 要换成 Token可以把拦截器里的校验逻辑换成解析请求头 Authorization核心思路不变。毕设演示用 Session 完全没有问题。4.2 宠物档案接口与表单校验用户要先有宠物档案才能预约。核心接口就四个新增、编辑、删除、我的宠物列表。PostMapping(/pet/add) public R addPet(Valid RequestBody PetForm form, HttpSession session) { User loginUser (User) session.getAttribute(loginUser); petService.addPet(loginUser.getId(), form); return R.ok(); }PetForm上标注校验Data public class PetForm { NotBlank(message 宠物名不能为空) private String petName; NotBlank(message 品种不能为空) private String breed; DateTimeFormat(pattern yyyy-MM-dd) private LocalDate birthDate; private Byte gender; }注意DateTimeFormat不能漏。前端 input 提交的是2025-06-01字符串不标注格式Spring MVC 转换LocalDate时会直接报 400。这是我带项目时被问得最多的报错之一。删除宠物时有个业务规则如果宠物存在未完成预约不允许删除。这个校验放在 Servicepublic void deletePet(Long userId, Long petId) { // 先校验宠物属于当前用户 Assert.isTrue(petMapper.findByIdAndUserId(petId, userId) ! null, 无权操作); // 再校验没有未完成预约 int activeCount appointmentMapper.countActiveByPetId(petId); Assert.isTrue(activeCount 0, 该宠物存在未完成预约不能删除); petMapper.deleteById(petId); }4.3 提交预约的完整实现事务冲突库存这是全项目最核心的 Service 方法我建议所有新手把这段代码逻辑理清Transactional(rollbackFor Exception.class) public Long createAppointment(AppointmentRequest req, Long userId) { // 1. 基本校验 Pet pet petMapper.findById(req.getPetId()); Assert.notNull(pet, 宠物不存在); Assert.isTrue(pet.getUserId().equals(userId), 宠物不属于当前用户); // 2. 同用户同时间段不能有重复预约 int userConflict appointmentMapper.countConflictByUser(userId, req.getAppointDate(), req.getTimeSlot()); Assert.isTrue(userConflict 0, 您在该时间段已有预约); // 3. 按服务类型做专项冲突检测 int conflict; if (req.getServiceType() 1) { conflict appointmentMapper.countConflictByBeautician(req.getBeauticianId(), req.getAppointDate(), req.getTimeSlot()); Assert.isTrue(conflict 0, 该美容师该时段已被预约); } else { conflict appointmentMapper.countConflictByVaccine(userId, req.getVaccineId(), req.getDoseSeq()); Assert.isTrue(conflict 0, 该宠物此针次已预约过); // 疫苗库存扣减依赖 MySQL 行锁 int rows vaccineBatchMapper.reduceStock(req.getVaccineId()); Assert.isTrue(rows 1, 疫苗库存不足); } // 4. 生成订单号并入库 Appointment appointment new Appointment(); appointment.setOrderNo(generateOrderNo()); appointment.setUserId(userId); appointment.setPetId(pet.getId()); appointment.setPetName(pet.getPetName()); appointment.setServiceType(req.getServiceType()); appointment.setAppointDate(req.getAppointDate()); appointment.setTimeSlot(req.getTimeSlot()); appointment.setStatus(0); appointmentMapper.insert(appointment); return appointment.getId(); }Transactional(rollbackFor Exception.class)里的rollbackFor必须写。Spring 默认只在 RuntimeException 上回滚如果业务异常是 checked exception不加这个参数事务不会回滚会出现“疫苗库存扣了但预约单没生成”这种数据不一致。订单号生成我用了最简单的方案private String generateOrderNo() { return AP LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMddHHmmssSSS)) String.format(%04d, ThreadLocalRandom.current().nextInt(10000)); }单体项目并发量小这个生成方式足够支撑课程设计和门店日常量。如果后面要面向高并发再换雪花算法。课程设计阶段把事务、冲突、库存三件事写对已经能拿高分了。5. 管理端落地排班、疫苗批次和预约状态流转5.1 管理端功能划分与权限隔离管理端接口统一挂在/admin/**。功能表大致如下菜单接口说明用户管理GET/POST/admin/user查看用户、禁用用户宠物档案GET/admin/pet查看所有宠物档案美容师排班GET/POST/admin/beautician维护美容师信息和工作时段疫苗批次GET/POST/admin/vaccine入库、修改库存、失效批次预约审核GET/POST/admin/appointment确认、取消、完成预约数据统计GET/admin/stats每日预约量、服务占比、库存预警权限隔离方式在拦截器里判断 requestURI 是否以/admin开头并且 session 里的角色等于 3。这比在每个 Controller 方法里写权限判断强一百倍不会漏一个接口。5.2 疫苗批次管理的细节批号、效期、库存扣减疫苗批次不是简单的增删改查。三个字段必须处理严谨batch_no批号疫苗溯源关键字段入库时校验唯一expire_date效期过期批次不能允许预约。查询在售批次时WHERE expire_date CURDATE()dose_left剩余库存每次预约扣一退货/取消要回补。扣减库存用单条 UPDATE 保证原子性Update(UPDATE vaccine_batch SET dose_left dose_left - 1 WHERE id #{id} AND dose_left 0) int reduceStock(Param(id) Long id);UPDATE是行级锁WHERE dose_left 0保证不会扣成负数。影响行数为 0 就说明没货直接在 Service 层抛“库存不足”。这个方案简单有效不需要显示加锁这也是之前说为什么用 MyBatis你可以精确控制 SQL。取消疫苗预约时记得回补库存。课程设计最容易漏的是“取消预约只改了状态疫苗库存没加回来”。回补 SQL 同理Update(UPDATE vaccine_batch SET dose_left dose_left 1 WHERE id #{id}) int increaseStock(Param(id) Long id);5.3 预约审核状态机与并发防重管理端点击“确认”时很不幸两个管理员可能同时对同一个订单点确认。如果代码写成appointment.setStatus(1); appointmentMapper.updateById(appointment);两个请求都会成功状态字段只是被覆盖了两次虽然结果看起来正常但接口被并发打多个请求时会出现不可控的中间态。正确做法是把状态当作更新条件Update(UPDATE appointment SET status #{newStatus}, update_time NOW() WHERE id #{id} AND status #{oldStatus}) int updateStatus(Param(id) Long id, Param(oldStatus) int oldStatus, Param(newStatus) int newStatus);Service 层public void confirm(Long id) { int rows appointmentMapper.updateStatus(id, 0, 1); Assert.isTrue(rows 1, 预约状态已变化请刷新后重试); }这样两个管理员并发确认时只有第一个rows1第二个rows0并抛出提示。这就是最朴素的乐观锁不需要引入 version 字段因为预约状态本身就可以充当版本号。同理取消、完成操作都走这个模式。管理端其他功能本质上都是这些基础操作的组合。美容师排班可以做成按周设置工作时段预约下单时只展示该美容师可用的槽位数据统计则用几条 group by 查询完成比如SELECT service_type, COUNT(*) FROM appointment WHERE DATE(appoint_date) #{today} GROUP BY service_type;我平时最反感那种把所有逻辑都写在 Controller 里的项目管理端的这类状态流转放到 Service 并返回明确的业务异常前后端联调时能节省很多时间。6. 部署上线与踩坑复盘6.1 本地启动最常见的三个报错报错原因解决方案Failed to configure a DataSource数据源配置缺失或依赖没有引入检查 yml 的 datasource 节点确认引入了spring-boot-starter-jdbc或以 mybatis starter 间接引入Invalid bound statement (not found)Mapper XML 没被加载或 namespace/方法 id 写错检查 mapper XML 路径是否与mybatis.mapper-locations匹配namespace 必须是 Mapper 接口全限定名方法 id 必须与接口方法同名Unknown column pet_name in field list数据库字段是下划线Java 属性是驼峰但驼峰映射没开mybatis.configuration.map-underscore-to-camel-case: true第三个报错几乎是新人必踩。如果开了驼峰映射还报看看是不是用了 MySQL 保留字当列名比如user表就要加反引号否则某些驱动版本会报语法错误。启动阶段还有个小技巧如果 jar 包启动时发现端口被占用用lsof -i:8080macOS/Linux或netstat -ano | findstr 8080Windows看占用进程。不要反复改端口回避问题。6.2 MyBatis 的 N1 查询和缓存陷阱预约列表页是最容易写出 N1 的地方。新手版代码是循环查宠物信息for (Appointment a : list) { Pet pet petMapper.findById(a.getPetId()); a.setPetName(pet.getPetName()); }20 条预约就是额外 20 次查询。正确做法是在 SQL 层一次性 LEFT JOINselect idselectPage resultTypemap SELECT a.id, a.order_no, a.service_type, a.appoint_date, a.time_slot, a.status, p.pet_name, p.breed, b.nickname AS beautician_name, v.vaccine_name FROM appointment a LEFT JOIN pet p ON a.pet_id p.id LEFT JOIN user b ON a.beautician_id b.id LEFT JOIN vaccine_batch v ON a.vaccine_id v.id WHERE a.user_id #{userId} ORDER BY a.create_time DESC /select这也就是为什么我在建表时冗余pet_name列表页甚至可以省掉 pet 的 JOIN保证页面加载速度。缓存方面MyBatis 二级缓存在预约系统这种状态频繁变化的业务里是个隐患。默认二级缓存是关闭的建议保持关闭。你想象一下管理员确认了一个预约但因为二级缓存没失效用户端列表还显示“待确认”这比慢 50ms 难接受得多。除非你对 Cache Key 的刷新规则非常清楚否则此场景不开比开更安全。6.3 用 Docker 一键部署 Spring Boot 应用本地跑通只是第一步把这个项目交给宠物店老板时最好给一个一键启动镜像。Dockerfile 非常简单FROM eclipse-temurin:8-jre WORKDIR /app COPY target/petstore.jar app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, app.jar]构建mvn clean package -DskipTests docker build -t petstore:1.0 . docker run -d --name petstore \ -p 8080:8080 \ -e DB_HOSTyour-mysql-host \ -e DB_PORT3306 \ -e DB_USERroot \ -e DB_PASSWORD123456 \ -e TZAsia/Shanghai \ petstore:1.0前提是 yml 里把连接信息改成环境变量形式spring: datasource: url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/pet_store?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 username: ${DB_USER:root} password: ${DB_PASSWORD:123456}-e TZAsia/Shanghai一定要加。JVM 默认时区是 UTC如果不指定你在页面上看到的所有LocalDateTime都会比浏览器时间慢 8 小时排查起来很痛苦。数据库迁移也顺便说一句别再用 Navicat 手动导 SQL 了。搭建环境时把建表脚本固定到项目sql/目录并写进部署文档这样换机器重建数据库只需要执行一条命令mysql -u root -p pet_store sql/petstore.sql截至这里项目的核心链路已经全部跑通了。如果还有余力可以继续加给美容师增加一个排班周期表、给疫苗临期批次加一个定时提醒任务Spring 自带的Scheduled就能实现、用 Redis 做店铺公告和今日可约时段缓存。这个项目本身不难难的是把业务约束和状态边界想清楚。我个人的建议是做完之后试着把“同一时间段并发预约”这个问题讲给别人听讲明白了比你把代码刷三遍收获都大。