简介:面向计算机、通信、人工智能、自动化等专业学生与从业者的医院预约挂号系统设计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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 层做一次数据校验,然后传递参数给 Mapper:
@Service public class ScheduleServiceImpl implements ScheduleService { @Resource private ScheduleMapper scheduleMapper; @Override public List<ScheduleVO> 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 id="selectAvailable" resultType="com.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 test="deptId != null"> AND dep.id = #{deptId} </if> <if test="date != null and date != ''"> AND s.schedule_date = #{date} </if> <if test="onlyAvailable"> 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 1=1;第三,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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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这里每一行都对应一个经典报错。serverTimezone=Asia/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)。原因:接口里方法叫reduceRemainCount,XML 里的 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,而数据库会话时区是东八区。解决:把serverTimezone=Asia/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 --> <packaging>war</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 版本不对,连数据库都连不上,后来我把整套环境写成了一个启动脚本并验证了两遍,才没在答辩现场翻车。这个习惯后来一直留在我的项目交付清单里,希望帮到你。
本文还有配套的精品资源,点击获取