医院预约挂号系统实战:Spring Boot + MyBatis + MySQL 从建表到防超卖
简介面向计算机、通信、人工智能、自动化等专业学生与从业者的医院预约挂号系统设计Java实现项目囊括源码、数据库与文档说明适合毕业设计、期末课程设计及课程大作业等场景。压缩包共189个文件约1.81MB涵盖55个Java类、39个JSP页面、17个CSS样式、5个JavaScript脚本、3个SQL数据库脚本及多张PNG/JPG图片前后端结构完整便于按模块查看与调试。项目为个人毕设成果答辩评审分达98分代码经过反复调试可正常运行数据库脚本与说明文档均已就绪可直接部署学习。目前已有126人下载学习基础较弱者可借此上手能力较强的可在现有框架上修改扩展快速实现不同预约业务功能。1. 医院预约挂号系统为什么毕业季总有人卡在“号源”这个环节每年毕业季医院预约挂号系统都是 Java 方向的高频选题但真正做完、做对、敢演示答辩的人并不多。这个系统本质上是一套带资源约束的业务闭环患者按科室、医生、日期查询排班选择可用号源后下单医生出诊后完成签到后台还要处理退号、停诊和号源释放。它看着比电商简单但“同一个号不能被两个人挂走”这一条就足以把很多人的代码打回重写。这套系统最大的价值不是堆功能而是让你把 Spring Boot、MyBatis、数据库事务、并发控制这些平时靠背的知识点全都在一个真实场景里跑一遍。写这篇笔记是因为我见过太多人卡在号源超卖、事务不回滚、部署后挂不上号这几个老坑上下面直接讲怎么做以及怎么避坑。2. 技术选型和模块拆解从零搭建三层架构而不是一上来就写代码2.1 选型理由Spring Boot MyBatis MySQL 靠什么胜出医院预约挂号系统的技术栈在毕业设计里几乎被 Spring Boot MyBatis MySQL 包圆了这不是偶然。Spring Boot 的自动配置和内置容器能让你把精力放在业务代码而不是配置各种 XML 上MyBatis 的 Mapper 体系和 SQL 直接见面的风格恰好适合这种查询条件多、排序规则明确的管理系统MySQL 配合 InnoDB 引擎既支持事务也支持行锁关键在“查询可挂号源并扣减库存”这一步。有少数人会用 MyBatis-Plus 替代 MyBatis直接用实体类生成建表 SQL这确实能省掉手写建表语句的时间。但我一般不建议毕业设计这么做答辩老师很容易问“你的表结构为什么是这么设计的”你如果回答“框架生成的”这题就答砸了。手写建表 SQL把主键策略、索引设计、字段注释写清楚反而是加分项。前端方面常见做法是 Vue 做页面、后端出 JSON 接口或者直接用 Thymeleaf/JSP 做服务端渲染。如果你只有 4 到 6 周时间我建议优先选服务端渲染或极简的 Vue Axios把时间留给预约流程本身而不是花在跨域调试上。2.2 功能模块划分五种角色、六条核心流程先按人来拆模块医院预约挂号系统至少涉及患者、医生、管理员三种角色如果把门诊护士和科室主任也算进来就是五种角色。但角色多不等于功能多核心业务流只有六条患者注册登录维护个人基本信息和就诊卡患者按科室、日期、医生查询排班与剩余号源患者选择号源提交预约请求并生成挂号订单管理员维护科室、医生、排班、号源规则与停诊信息医生查看当日预约列表、执行签到或叫号患者退号系统释放号源并完成退款状态流转这六条流程里最值得写进“文档说明”的是第三条。预约不是简单的 insert 一条订单而是“查可用号源 → 锁定号源 → 生成订单 → 扣减号源 → 返回结果”这么一串动作其中任何一步失败都不能留下半截数据。这就是为什么设计时要把订单表和号源表分开而不是把号源数直接写进排班表里硬减。2.3 工程目录结构按实体、Mapper、Service、Controller 四层组织源码拿到源码之后别急着跑起来先把目录看明白。一套规范的 Spring Boot 工程包名和职责应该长这样com.hospital.registration ├── controller # 接口层预约、查询、退号等 HTTP 入口 ├── service # 业务层事务控制、号源预占都在这一层 ├── mapper # MyBatis 数据访问层旧项目常写 dao ├── entity # 实体类对应数据库表一个表一个类 ├── dto # 前端交互对象VO、请求参数、返回结构 ├── config # 跨域、拦截器、MyBatis 配置类 ├── common # 工具类、统一返回结果、异常处理 └── resources ├── mapper # XML 格式的 Mapper 映射文件 ├── application.yml # 数据源、端口、日志配置 └── schema.sql # 建库建表脚本很多项目放在 db 目录这个结构看起来简单但它至少回答了两个常见问题。第一业务逻辑必须写在 service 层而不是 controller 层不然事务注解和数据库连接管理都会失控第二mapper 接口和 XML 文件按同名对应放好MyBatis 才能扫到。等你改动“退号释放号源”这个功能时你会庆幸当初把资源文件单独放了一个目录。3. 数据库设计先把病人、医生、排班和订单的关系理顺再动手建表3.1 核心实体关系与建表顺序数据库是整个系统的地基也是最容易返工的地方。医院预约挂号的核心实体有五个用户患者、医生、科室、排班、挂号订单。关系上一个科室有多个医生一个医生属于一个科室一个医生可以有多个排班一个排班被多个订单引用订单归属到具体用户。建表顺序建议先建无外键依赖的基础表再建有依赖关系的业务表先department科室表和doctor医生表再建schedule排班表和registration_order挂号订单表。这样导入 SQL 时不会因为外键顺序报错。很多网上的源码把用户表和医生表合成一张“人员表”加上role字段区分这也能跑通。但如果你要做“医生排班”和“患者预约”两个页面分开建表会让 SQL 好写得多权限和字段维护也直观。3.2 关键表结构用户表、排班表、挂号订单表先看用户表和排班表这两张表核心字段如下CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, username VARCHAR(50) NOT NULL COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT 加密后的密码, real_name VARCHAR(50) NOT NULL COMMENT 患者真实姓名, id_card VARCHAR(18) DEFAULT NULL COMMENT 身份证号可选, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;CREATE TABLE schedule ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 排班ID, doctor_id BIGINT NOT NULL COMMENT 医生ID, schedule_date DATE NOT NULL COMMENT 出诊日期, time_slot VARCHAR(20) NOT NULL COMMENT 时段上午/下午, total_count INT NOT NULL DEFAULT 20 COMMENT 总号源数, remain_count INT NOT NULL DEFAULT 20 COMMENT 剩余号源数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0停诊, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_doctor_date (doctor_id, schedule_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT医生排班表;这里有两个容易踩的细节。第一个是remain_count不能设置为负值在应用层扣减后还得用UPDATE ... WHERE remain_count 0兜底第二个是time_slot用字符串“上午/下午”比用 0/1 状态码更直观前端拿到就能直接渲染不用再做字典翻译。挂号订单表的重点在状态位和唯一约束CREATE TABLE registration_order ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 订单ID, order_no VARCHAR(32) NOT NULL COMMENT 订单编号, user_id BIGINT NOT NULL COMMENT 预约患者ID, schedule_id BIGINT NOT NULL COMMENT 排班ID, doctor_id BIGINT NOT NULL COMMENT 医生ID, order_date DATE NOT NULL COMMENT 就诊日期, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待就诊 1已签到 2已退号 3已停诊, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_user_schedule (user_id, schedule_id), KEY idx_schedule_user (schedule_id, user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT挂号订单表;3.3 号源预占与状态设计从写 SQL 开始就防止重复挂号先说结论防重复挂号的方案不是靠synchronized不是靠应用层锁而是靠数据库的唯一索引和原子更新。uk_user_schedule这个唯一键的含义是“同一个患者同一天同一个排班只能有一条订单”无论你的代码被并发调用多少次数据库层面就会直接拒绝第二条插入。另一个必须做的约束是排班表的remain_count大于等于 0。实际操作中扣号源要用一行 SQL 原子完成UPDATE schedule SET remain_count remain_count - 1 WHERE id #{scheduleId} AND remain_count 0;这行 SQL 要放在插入订单之前执行。如果返回的影响行数为 0说明号源已经被抢完业务层直接抛出“号源不足”不再执行后面的插入。这样一来“查余号”和“扣号源”之间就不存在时间差从根上避免了超卖。状态设计也要顺着业务走待就诊 0、已签到 1、已退号 2、已停诊 3。停诊状态比较特殊它不能由患者触发而是管理员停诊排班后系统把所有未被签到的订单批量改成停诊并且要把号源加回去或者允许患者改约这块逻辑放在后面事务里一起讲。4. 核心接口实现预约、查询、取消与事务控制的联调细节4.1 按科室和日期查询排班Controller Service 的完整链路查询排班是预约流程的入口也是你第一个要跑通的接口。前端传几个条件科室 ID、就诊日期、是否只看有号。后端把条件传给 Mapper返回排班列表。这里直接用 MyBatis 的 XML 方式实现便于控制动态 SQL。Controller 层先接收参数RestController RequestMapping(/api/schedule) public class ScheduleController { Resource private ScheduleService scheduleService; GetMapping(/query) public Result queryAvailable(RequestParam(required false) Long deptId, RequestParam(required false) String date, RequestParam(defaultValue false) Boolean onlyAvailable) { ScheduleQueryVO vo new ScheduleQueryVO(); vo.setDeptId(deptId); vo.setDate(date); vo.setOnlyAvailable(onlyAvailable); return Result.success(scheduleService.querySchedule(vo)); } }Service 层做一次数据校验然后传递参数给 MapperService public class ScheduleServiceImpl implements ScheduleService { Resource private ScheduleMapper scheduleMapper; Override public ListScheduleVO querySchedule(ScheduleQueryVO vo) { if (vo.getDate() ! null !vo.getDate().isEmpty()) { // 防止传进来的日期格式不合法这里做一个简单校验 DateTimeFormatter formatter DateTimeFormatter.ofPattern(yyyy-MM-dd); LocalDate.parse(vo.getDate(), formatter); } return scheduleMapper.selectAvailable(vo); } }Mapper XML 里的核心是动态 SQL将科室、日期、号源条件拼进去select idselectAvailable resultTypecom.hospital.registration.dto.ScheduleVO SELECT s.id AS scheduleId, s.schedule_date AS scheduleDate, s.time_slot AS timeSlot, s.total_count AS totalCount, s.remain_count AS remainCount, s.status AS status, d.real_name AS doctorName, d.title AS doctorTitle, dep.dept_name AS deptName FROM schedule s LEFT JOIN doctor d ON s.doctor_id d.id LEFT JOIN department dep ON d.dept_id dep.id where if testdeptId ! null AND dep.id #{deptId} /if if testdate ! null and date ! AND s.schedule_date #{date} /if if testonlyAvailable AND s.remain_count 0 AND s.status 1 /if /where ORDER BY s.schedule_date, s.time_slot /select这里要注意三个点第一LEFT JOIN而不是INNER JOIN避免医生信息缺失导致排班列表少数据第二where标签会自动去掉多余的AND别在if条件里自己写死WHERE 11第三onlyAvailable的if判断用的是 test 表达式MyBatis 会把 Boolean 类型的 true/false 直接映射这里不需要加 true但要注意这个值是 Boolean 而不是字符串否则 MyBatis 会按字符串解析出问题。4.2 挂号下单事务、乐观锁和并发时的降级方案挂号是整个系统的核心。这一步要同时完成插入订单、扣减号源、返回订单号。任何一步失败都要全部回滚所以方法上必须加Transactional。Service public class OrderServiceImpl implements OrderService { Resource private ScheduleMapper scheduleMapper; Resource private RegistrationOrderMapper orderMapper; Override Transactional(rollbackFor Exception.class) public String createOrder(OrderCreateRequest req) { // 1. 原子扣减号源防止超卖 int rows scheduleMapper.reduceRemainCount(req.getScheduleId()); if (rows 0) { throw new BizException(号源不足或排班已停诊); } // 2. 生成订单号并插入 String orderNo generateOrderNo(); RegistrationOrder order new RegistrationOrder(); order.setOrderNo(orderNo); order.setUserId(req.getUserId()); order.setScheduleId(req.getScheduleId()); order.setDoctorId(req.getDoctorId()); order.setOrderDate(req.getOrderDate()); order.setStatus(0); orderMapper.insert(order); // 3. 返回订单号给前端 return orderNo; } }这段代码看起来短但里面藏着几个值得在答辩时讲清楚的设计决策。reduceRemainCount对应的是第 3 章那张排班表的原子更新语句它返回int代表受影响行数。如果影响行数为 0说明remain_count已经为 0 或者排班状态被改成停诊事务会因为没有抛出异常而正常提交注意这里的throw new BizException会触发事务回滚所以刚才的扣减操作会被撤销不会出现“号源扣了但订单没建成功”的情况。这就是rollbackFor Exception.class的作用——把业务异常也视为需要回滚的错误。generateOrderNo()的常见做法是日期 时间戳 随机数例如202505121430001234。不要用数据库自增 ID 当订单号因为它在插入前拿不到而且容易被猜测出业务量。并发更高时还可以在schedule表上给排班记录加乐观锁版本号每次扣减时比较版本号。但在毕业设计的评分语境里能说清楚“数据库行锁 事务 唯一索引”这三层防线已经比大多数人强了。4.3 配置文件和 Mapper 绑定跑通前后端联调的最小配置源码跑不起来八成的坑都出在application.yml和 Mapper 绑定上。先看最小可用配置server: port: 8080 servlet: context-path: /hospital spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.hospital.registration.entity configuration: map-underscore-to-camel-case: true这里每一行都对应一个经典报错。serverTimezoneAsia/Shanghai不写日期字段会差 8 小时mapper-locations配错启动直接报Invalid bound statement (not found)map-underscore-to-camel-case: true不配remain_count映射不到remainCount查询结果全是 null。如果 Mapper 接口扫描不生效还要在启动类上补一行MapperScan(com.hospital.registration.mapper)。很多源码给的示例喜欢把Mapper加到每个接口上这两种方式都行但MapperScan更省答辩时也能说清楚两种做法的差别。5. 从建表 SQL 到上线部署5 个值得记录的避坑现场5.1 建表 SQL 执行报错字符集和字段长度问题现象把网上的create_db.sql导入 Navicat第三步就报Specified key was too long。原因MySQL 5.6 及以下版本utf8mb4 字符集下VARCHAR(255) 作为唯一索引前缀索引字节数超过 767 限制。解决把字符集统一设成 utf8mb4同时把唯一索引字段控制在合理长度。-- 建库时就直接指定字符集避免后面每张表单独设置 CREATE DATABASE hospital_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;注意utf8mb4_general_ci是大小写不敏感排序规则如果你要做用户名登录时严格区分大小写可以考虑改成utf8mb4_bin。但一般医院系统不需要保持默认即可。5.2 号源超卖synchronized 锁在单体应用里“看着有用实际没用”现象压测或连续点击“立即挂号”20 次数据库里同一个排班出现 21 条订单且remain_count变成负数。原因代码里写了synchronized (this)来锁住扣减号源的逻辑但部署时开了多个实例或者直接用Transactional的事务隔离开了锁的生效范围——事务提交前锁已经释放别的线程读到旧值。解决删掉应用层锁只用数据库原子更新Update(UPDATE schedule SET remain_count remain_count - 1 WHERE id #{scheduleId} AND remain_count 0) int reduceRemainCount(Param(scheduleId) Long scheduleId);这个坑的根源是混淆了“同步”和“原子性”。synchronized只能保证一个 JVM 内的线程互斥不能保证事务隔离级别下的并发安全。真正可靠的做法是把并发底线设在数据库这一层。5.3 MyBatis 增删改报“参数未绑定”Mapper 接口的方法名和 XML id 不一致现象org.apache.ibatis.binding.BindingException: Invalid bound statement (not registered)。原因接口里方法叫reduceRemainCountXML 里的 id 写成了reduceCount或者 namespace 配错。解决打开 mapper 接口和 XML逐一核对三点——namespace 必须是对应接口的全限定名方法名必须等于 XML 里的 id返回类型resultType写的是实体类全路径而不是别名。这类问题肉眼搜索效率低直接的做法是在application.yml里临时打开 MyBatis 日志logging: level: com.hospital.registration.mapper: debug启动后看控制台MyBatis 会打印出它找到了哪些 Mapper 方法、每个方法执行的 SQL。日志一通马上就能定位到是 namespace 少了结尾还是方法名大小写错了。5.4 接口返回的时间总是比数据库时间早 8 小时现象数据库里create_time是2025-05-12 14:30:00接口返回给前端变成2025-05-12 06:30:00。原因JDBC 连接串里没指定时区MySQL 驱动用的是系统默认 GMT而数据库会话时区是东八区。解决把serverTimezoneAsia/Shanghai加进 JDBC URL同时把 Jackson 的时区也配成Asia/Shanghai。这类时区问题在本地没暴露是因为你电脑的系统和数据库在同一时区一旦部署到云服务器很多默认 UTC问题立刻暴露。所以配置里写时区这件事最好从开发第一天就做别等上线。5.5 部署到服务器后页面 404打包方式与静态资源目录不对现象本地mvn spring-boot:run访问正常打成 war 包扔到 Tomcat 后所有页面和接口全部 404。原因两种可能——第一打包方式写成了 jar 但部署在 Tomcat webapps 下第二前端静态资源放在src/main/webapp而 Spring Boot 默认不扫描这个目录。解决!-- 如果要打 war 包pom.xml 里必须改成 war -- packagingwar/packaging !-- 同时让启动类继承 SpringBootServletInitializer --SpringBootApplication public class HospitalApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { return builder.sources(HospitalApplication.class); } }如果不想牵扯外置 Tomcat 的版本兼容问题最简单的做法是打 jar 包直接用java -jar hospital.jar跑。毕业设计演示环境基本都是单机jar 方式少一个 Tomcat 配置环节翻车概率小很多。6. 把毕业设计变成可演示、可答辩的项目验证顺序与三个演示脚本临近交付时别急着写文档先用一条固定的验证路径把系统从头到尾过一遍确保演示时不会出岔。我的习惯是分三段验证。第一段验证注册登录和数据初始化。用管理员账号登录创建科室、创建医生、生成未来三天的排班每个排班设置 20 个号源。这里要看的不是界面而是排班表和订单表的数据是否一致——排班表里remain_count是 20订单表里没有脏数据。第二段验证核心预约流程。用一个测试患者账号先去查询排班选一个有号的排班反复提交两次预约第二次必须失败提示“您已预约过该排班”。然后去退号再看remain_count是否从 19 回到 20。这一套走完事务、唯一索引、号源扣减三个关键点全部覆盖。第三段验证异常路径。停诊某一个排班看看患者端查询时能否正常显示“已停诊”已预约的订单状态是否自动流转成停诊状态。如果停诊没有处理订单答辩老师大概率会追着问“停诊后患者怎么办”这一步提前做好就能给出完整的回答。如果你用的是 Vue 前端准备好一个前端演示脚本按科室入口进入、切换日期、点击预约、跳转支付如果设计了或直接生成订单号。这个脚本的价值在于让演示有节奏感而不是在现场临时乱点。最后记得把“文档说明”里最不起眼但最容易被问到的三件事写进去数据库表设计的 ER 图、核心接口的请求响应示例、以及部署步骤。我踩过一次坑演示前临时换电脑结果 MySQL 版本不对连数据库都连不上后来我把整套环境写成了一个启动脚本并验证了两遍才没在答辩现场翻车。这个习惯后来一直留在我的项目交付清单里希望帮到你。本文还有配套的精品资源点击获取