ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot体检预约系统源码解析:分层架构、事务与并发校验

2026/9/12 12:51:50 拓冰建站 浏览量
Spring Boot体检预约系统源码解析:分层架构、事务与并发校验 简介这是一份基于Spring Boot的体检预约系统完整源代码包面向Java后端开发者、毕业设计及课程设计学生解决体检机构预约管理、数据库设计与前后端联合调试等实际需求。项目采用Java 1.8 Spring Boot框架搭配MySQL 5.7/8数据库支持Eclipse/IDEA开发环境已通过测试可正常运行。压缩包共646个文件约24.36MB核心类型包括132个Java源码、102个Vue前端文件、63个JS脚本、15个CSS样式并包含SQL数据库脚本、docx/dot说明文档及部署相关bat命令覆盖项目从环境配置、数据表设计到前后端交互的完整链路。资源附带了数据库设计文档和Java项目部署文档可清晰查看各数据表字段与表间关联按文档指引即可完成本地部署与运行。已有54人学习浏览适合用于毕业设计参考、Spring Boot项目实践或体检预约业务二次开发。1. 为什么体检预约系统适合拿来拆 Spring Boot 源码预约系统的难点一定不在增删改查而在「同一时段能约几个人、冲突之后谁优先、到检之后状态怎么流转」。这套仁和机构体检预约系统把 Spring Boot MySQL 的完整链路放进了一个压缩包前后端页面、数据库脚本、说明文档、启动脚本都在。技术栈是 JDK 1.8 Spring Boot MySQL 5.7/8Eclipse 和 IntelliJ IDEA 都能直接打开。对正在做 Java Spring Boot 毕业设计或课程设计的同学来说它能帮你把分层、事务、静态资源托管一次看懂对已经工作几年的后端预约时段校验的并发边界和 MySQL 版本迁移的坑仍然值得重新翻一遍。2. 从压缩包到数据表Spring Boot 项目结构与 MySQL 表设计2.1 解压后先看文件清单而不是急着双击 run.bat拿到压缩包先别急着跑文件列表本身就是线索。里面有main.js.bak、app.930dbec9.css、1-install.bat、2-run.bat、3-build.bat、install.bat、run.bat、build.bat、.classpath和mvnw.cmd。.classpath是 Eclipse 的工程配置文件说明这套源码在 Eclipse 里导入过mvnw.cmd是 Maven Wrapper 的 Windows 入口项目因此不依赖全局安装的 Maven。app.930dbec9.css带哈希后缀说明前端资源是构建后的产物直接由 Spring Boot 静态目录托管main.js.bak则是开发者留下的手动备份文件正常运行时不参与加载。我一般会按数字前缀把三个 bat 当成执行顺序来看。它们只是把 Maven 命令包装了一层避免每次手敲mvnw.cmd clean install -DskipTests # 1-install清理并安装依赖 mvnw.cmd spring-boot:run # 2-run开发模式启动联调时看日志 mvnw.cmd clean package # 3-build打出可部署的 jar 到 target 目录第一条里-DskipTests表示跳过测试执行但仍然会编译测试代码适合在没配测试数据库的机器上快速跑通如果想让构建过程完全不碰测试代码可以用-Dmaven.test.skiptrue两者在持续集成环境里的行为差异值得注意。三个脚本的分工用下表记最清楚脚本包装的 Maven 命令实际作用1-install.batmvnw.cmd clean install -DskipTests清理并安装依赖把产物放进本地仓库2-run.batmvnw.cmd spring-boot:run开发模式启动适合改完代码直接看效果3-build.batmvnw.cmd clean package打出可部署 jar交付或换机演示时用提示第一次执行mvnw.cmd会下载 Maven 和依赖看到大量下载日志是正常现象中途中断容易留下残缺的本地仓库重试时可以先删掉用户目录下的.m2/repository再跑。2.2 体检预约核心表预约单字段与状态机预约系统的核心不是用户表也不是套餐表而是预约单表。用户、套餐、体检报告都可以看成围绕预约单的附属信息。这类项目的数据表命名习惯是加t_前缀预约单通常这样建CREATE TABLE t_appointment ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 预约单主键, user_id BIGINT NOT NULL COMMENT 用户ID关联 t_user.id, package_id BIGINT NOT NULL COMMENT 体检套餐ID关联 t_package.id, appoint_date DATE NOT NULL COMMENT 预约日期, time_slot TINYINT NOT NULL DEFAULT 1 COMMENT 时段1-上午2-下午, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0-待确认1-已确认2-已完成3-已取消, remark VARCHAR(200) DEFAULT NULL COMMENT 用户备注, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), KEY idx_user_date (user_id, appoint_date), KEY idx_date_slot (appoint_date, time_slot) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT体检预约单;几个字段是刻意这么设计的。appoint_date用 DATE 而不是 DATETIME因为业务上只精确到天存时间反而让范围查询多一层DATE()转换索引也容易失效。time_slot用 TINYINT 而不是 VARCHAR既压缩存储又方便在代码里用常量描述「上午/下午」。update_time上的ON UPDATE CURRENT_TIMESTAMP是 MySQL 5.7 之后常用的自动更新写法修改行时不需要在应用层手动维护时间字段。status用 TINYINT 表达状态机配合注释能看完整条流转status 值含义什么时候变更0待确认用户提交预约后1已确认机构确认排期后2已完成体检报告录入后3已取消用户取消或超时未到检索引设计上要覆盖两条最高频查询idx_user_date用于「同一用户同一天是否已有预约」的防重检查idx_date_slot用于「某天某时段已约多少人」的容量统计。这两个索引都不在status上单独建因为状态字段区分度太低单列索引收益有限。2.3 用 Navicat 导入脚本与字符集排雷数据库脚本的导入路径通常是新建连接、创建数据库、运行 SQL 文件。命令行方式反而更直观# 建库时必须把默认字符集设成 utf8mb4否则中文备注会变成问号 mysql -uroot -p --default-character-setutf8mb4 -e CREATE DATABASE IF NOT EXISTS springboot06t067ij DEFAULT CHARACTER SET utf8mb4; # 导入初始化脚本 mysql -uroot -p --default-character-setutf8mb4 springboot06t067ij db/init.sql第一条命令里的--default-character-setutf8mb4同时控制客户端发送和接收的编码不只影响建库那一步。第二条命令把init.sql的内容灌进springboot06t067ij库是重定向符号表示把文件作为标准输入交给 mysql 客户端。用 Navicat 时右键连接选「运行 SQL 文件」也是同样的效果但要注意连接属性里的编码必须同步选择 utf8mb4。提示MySQL 8 导出的脚本可能带utf8mb4_0900_ai_ci排序规则导入 MySQL 5.7 时会报Unknown collation: utf8mb4_0900_ai_ci把脚本里的排序规则全局替换成utf8mb4_general_ci即可。2.3.1 导入后的核对清单导入完成后我一般会在 Navicat 里做三件事确认t_user里至少有一条测试账号方便登录确认t_package的价格字段类型是DECIMAL(10,2)而不是 FLOAT避免金额精度问题确认t_appointment建表语句里的默认值生效尤其是status默认 0 和update_time的自动更新。三张表结构都对上再接 Spring Boot 项目否则后面排查会先在业务逻辑里绕一圈最后发现是表结构少了个字段。3. 预约核心链路分层架构、事务与时段冲突校验3.1 Controller-Service-Mapper 三层结构与注入方式Spring Boot 项目最常见的包结构是 controller、service、mapper也叫 dao、entity 四层。这个系统的业务入口放在AppointmentController只做参数接收和结果封装不写任何业务判断package com.renhe.controller; RestController RequestMapping(/api/appointment) public class AppointmentController { private final AppointmentService appointmentService; public AppointmentController(AppointmentService appointmentService) { this.appointmentService appointmentService; } PostMapping(/submit) public Result submit(RequestBody AppointmentDTO dto) { Long id appointmentService.create(dto); return Result.success(预约成功, id); } }这里有一个源码里经常被忽略的细节构造函数注入字段是final而Autowired字段注入不是。构造函数注入能保证对象创建时依赖一定就位测试时也方便直接 new 一个 mock 进去Spring 官方文档推荐的就是这种写法。如果打开源码看到的是Resource字段注入那是早期风格功能没问题但写新代码时不建议照抄。3.2 预约提交与 Transactional 回滚边界预约的核心操作是先做防重校验再做容量校验最后插入一条预约单。三个动作必须在一个事务里任何一个失败都不能留下半条数据Service public class AppointmentServiceImpl implements AppointmentService { Resource private AppointmentMapper appointmentMapper; Override Transactional(rollbackFor Exception.class) public Long create(AppointmentDTO dto) { // 1. 同一用户同一天只能预约一次 int exists appointmentMapper.countByUserAndDate(dto.getUserId(), dto.getAppointDate()); if (exists 0) { throw new BusinessException(同一用户同一天只能预约一次); } // 2. 当天该时段剩余名额判断 int booked appointmentMapper.countByDateAndSlot(dto.getAppointDate(), dto.getTimeSlot()); if (booked 100) { throw new BusinessException(该时段已约满请选择其他时段); } // 3. 插入预约单 Appointment appointment new Appointment(); appointment.setUserId(dto.getUserId()); appointment.setPackageId(dto.getPackageId()); appointment.setAppointDate(LocalDate.parse(dto.getAppointDate())); appointment.setTimeSlot(dto.getTimeSlot()); appointment.setStatus(0); appointmentMapper.insert(appointment); return appointment.getId(); } }Transactional(rollbackFor Exception.class)这个写法值得单独说。Spring 的默认事务策略是只有 RuntimeException 才回滚如果业务代码抛的是受检异常比如 IOException事务不会回滚。显式指定rollbackFor Exception.class等于把所有异常都纳入回滚范围这是写商业项目时比较稳妥的习惯。BusinessException一般继承自 RuntimeException所以这里不指定也能生效但明确写上会让读代码的人不产生歧义。还有个高频面试点Transactional只对 public 方法且通过代理调用时生效。如果在同一个类里用this.create(dto)调用事务注解会被绕过因为调用发生在对象内部没走 Spring 的动态代理。拆项目时如果发现某个方法写了事务却不起作用先检查是不是同类内部调用。3.3 冲突校验的两种实现与并发边界3.3.1 查询后判断的适用场景上面的countByUserAndDate加countByDateAndSlot属于典型的 check-then-act 模式在单机低并发下没问题但两个请求同时到达时可能出现「都查到 99 人、都放行、最后约了 101 人」的情况。毕业设计演示通常碰不到但如果想在答辩里讲出深度要能主动指出这个边界查询和插入之间存在时间窗口窗口里进来的请求读到的都是旧值。3.3.2 用更新配额表代替 count一个常见改进是引入配额表用一条 UPDATE 的受影响行数来判断是否抢到名额UPDATE t_quota SET used used 1, update_time NOW() WHERE appoint_date 2025-07-20 AND time_slot 1 AND used capacity;执行后如果受影响行数为 1说明当前时段还有名额继续插入预约单受影响行数为 0说明名额已经用完直接拒绝。UPDATE 在事务内会持有这行的锁后到的请求会阻塞到前一个事务提交相当于把并发判断交给了数据库行锁比 count 再 insert 可靠得多。这种方案的代价是吞吐量下降但体检预约这种一天几千单的业务完全够用属于典型的「用一点并发换正确性」。3.4 日期与时段参数的解析习惯前端传过来的日期在预约场景里几乎都是字符串比如2025-07-20。后端 DTO 里最省事的做法是直接用 String 接收再在 Service 里LocalDate.parse这样格式错误能精确到业务层处理public class AppointmentDTO { NotNull(message 预约日期不能为空) JsonFormat(pattern yyyy-MM-dd, timezone GMT8) private LocalDate appointDate; NotNull(message 时段不能为空) private Integer timeSlot; }JsonFormat里的timezone GMT8很容易被漏掉。Jackson 默认按 JVM 时区解析日期如果服务器时区是 UTC2025-07-20可能被解析成前一天晚上 22 点入库时 DATE 字段截断后变成2025-07-19用户看到的预约日期比选的早一天。这种 bug 不在报错信息里出现只体现在数据错位上是最难搜的一类问题。4. 前后端资源与启动脚本从 main.js 到 Spring Boot 静态托管4.1 main.js.bak 是什么能不能删这个压缩包里的前端不是独立工程而是已经构建好的静态资源由 Spring Boot 统一托管。main.js是页面运行时真正加载的入口文件main.js.bak是开发者改代码前手动复制的备份Spring Boot 不会引用它。如果页面脚本出了问题可以把.bak重命名回.js恢复现场正常情况下删掉.bak不影响运行。app.930dbec9.css带哈希后缀同理它是打包工具生成的产物文件名哈希变了浏览器就会重新拉取而不是命中缓存这是前后端分离项目实战里常见的缓存控制手段只不过这套项目把构建结果直接放回了后端静态目录。4.2 前后端接口约定与 fetch 对接既然页面和接口都在同一个 Spring Boot 服务里联调时没有跨域问题直接请求相对路径即可// 提交预约POST /api/appointment/submit fetch(/api/appointment/submit, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ userId: 1, packageId: 2, appointDate: 2025-07-20, timeSlot: 1 }) }) .then(response response.json()) .then(result { if (result.code 200) { window.location.href /appointment/success.html?id result.data; } else { alert(result.msg); } });这个示例里userId直接写死是为了演示接口结构。实际项目里用户身份应该从登录后的 session 或 token 里取后端从SecurityContext或HttpSession拿当前用户而不是信任前端传入的用户 ID否则任何人改一下参数就能替别人预约。result.code 200这种约定是这类系统最常见的统一返回格式对应后端的Result.success(...)和Result.error(...)字段一般包含 code、msg、data 三块。看一下整体接口面能更快理解前后端各自负责什么接口方法作用关键参数/api/user/registerPOST用户注册username, password, realName/api/user/loginPOST登录username, password/api/package/listGET套餐列表page, size/api/appointment/submitPOST提交预约packageId, appointDate, timeSlot/api/appointment/listGET我的预约userId4.3 Spring Boot 配置与 bat 脚本的分工前后端都齐了能不能跑起来要看application.yml。换数据库密码、改端口都在这一个文件里改spring: datasource: url: jdbc:mysql://localhost:3306/springboot06t067ij?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd server: port: 8080URL 里的参数每个都有用处。characterEncodingutf8要求服务端使用 UTF-8 编码传输中文useSSLfalse关闭 SSL 握手本地开发能省掉一条告警日志serverTimezoneAsia/Shanghai是 MySQL 8 的必填项不写会在连接时报The server time zone value错误。密码字段要和本机 MySQL 实际密码一致否则启动日志里会出现Access denied。bat 脚本在这个配置之外只做了一件事固定 JVM 环境。常见写法echo off set JAVA_HOMEC:\Program Files\Java\jdk1.8.0_202 call mvnw.cmd spring-boot:run pause这里set JAVA_HOME是为了避免机器上装了多个 JDK 导致版本错乱。配好之后日常开发用2-run.bat交付演示用3-build.bat打 jar然后java -jar target/springboot06t067ij-0.0.1-SNAPSHOT.jar启动两条路效果一致区别只在于是否经过打包压缩。5. 换机跑通与答辩排错版本匹配、数据库连接、演示路径5.1 版本匹配JDK 1.8、Spring Boot 2.x 与 MySQL 驱动的对应关系这套项目声明的是 JDK 1.8对应的 Spring Boot 版本在 2.x 系列。如果拿到源码后直接用 IDEA 默认的 JDK 17 打开启动时会报UnsupportedClassVersionError提示 class 文件版本是 55.0 或 61.0 之类。解决办法不是升级代码而是把 Project Structure 里的 SDK 切回 1.8并确认 pom.xml 里spring-boot-starter-parent的版本仍是 2.x。Spring Boot 3.x 要求 JDK 17 起步同时包名从javax.servlet换成jakarta.servlet贸然升级会让大量 import 报红属于典型的「版本太高反而更难跑」。MySQL 驱动同理。MariaDB 或低版本驱动连 MySQL 8 时常报Public Key Retrieval is not allowed最稳定的做法是按数据库版本选择驱动坐标MySQL 8 用com.mysql.cj.jdbc.DriverMySQL 5.7 用com.mysql.jdbc.Driver。很多「连不上」其实是驱动类名和数据库版本不对应。5.2 数据库连接失败的排查路径启动失败时先区分是端口问题、账号问题还是脚本错误。我一般按这个顺序查# Windows 下确认 MySQL 是否在 3306 端口监听 netstat -ano | findstr :3306 # 用命令行验证账号密码避开应用配置干扰 mysql -uroot -p -e SELECT VERSION();如果netstat查不到 3306说明 MySQL 服务没启动去服务管理里把 MySQL 启动如果命令行能登录但应用连不上问题就在application.yml的连接串、账号或密码上。Navicat 能连不代表应用能连因为两者读的是不同配置。密码改过之后要同步三个地方MySQL 用户表、Navicat 保存的连接、application.yml漏掉任何一个都会在日志里看到Access denied for user rootlocalhost。5.3 答辩演示路径与日期格式的经典坑演示时不要跳着点按一条业务主线走下来评委能看到完整闭环注册一个普通用户并登录确认 session 生效。打开套餐列表选择一个套餐进入预约页。选择日期和时段提交预约页面跳转成功页。回到列表再次提交同一天的预约系统必须提示「同一用户同一天只能预约一次」。用管理员账号把状态从待确认改成已确认用户端能看到状态变化。第三步最容易翻车的地方是日期格式。如果前端日期控件输出的是2025/07/20或2025-07-20 10:00:00而后端 DTO 里的JsonFormat写的是yyyy-MM-ddJackson 会直接抛DateTimeParseException。这类报错不会定位到业务代码第一眼会以为是预约逻辑的问题。处理顺序按数据库字符集、连接参数、接口字段格式去排查而不是先去改业务代码。现场遇到这问题时把前端日期控件的 value-format 切回yyyy-MM-dd重新提交一次即可事务和状态机都不需要动。本文还有配套的精品资源点击获取