简介:本资源为基于SpringBoot的医院挂号就诊系统完整项目源码,面向计算机专业学生、Java初学者及需要完成课程设计或毕业设计的人群,帮助解决挂号、就诊信息管理等场景下的系统开发与学习需求。项目采用Java、SpringBoot、Vue、Ajax、Maven、MySQL、MyBatisPlus等技术栈,前端使用ElementUI与B/S架构,涵盖用户信息管理、图片素材管理、视频素材管理等功能模块,并附有绪论、相关技术介绍、系统分析、系统设计、系统实现等完整文档章节。压缩包共770个文件,包含101个Java源码、60个Vue组件、157个JavaScript脚本、50个CSS样式、33个HTML页面及大量svg、gif、jpg等图片素材,另有xml配置、yml文件与bat启动脚本,整体约34.42MB,目录结构清晰,便于按模块查阅与二次开发。目前已有162人学习下载,适合需要完整项目参考、技术栈练手或毕设素材的读者。
1. 从一张挂号单说起:SpringBoot 医院挂号就诊系统到底在解决什么
早上七点半,门诊大厅已经排了三十多人,窗口里护士一边翻纸质登记本一边喊名字,患者拿着手写小票去对应科室,结果科室门口又排一次队。这个场景里真正被浪费的不是患者的时间,而是医院对号源、医生排班、就诊状态这三件事完全没有实时掌控。基于 SpringBoot 的医院挂号就诊系统,要解决的就是把「号源」变成可被程序管理的资源:哪个科室、哪位医生、哪个时间段、还剩几个号、谁抢到了、谁爽约了,全部落库并可追溯。它适合正在做 Java 课程设计的学生、需要一套可二次开发门诊模块的初级工程师,以及想用 SpringBoot 练手真实业务建模的后端开发者。热词里反复出现的 springboot、java、源码,本质上都指向同一件事——你需要一套能跑起来、能改、能讲清楚数据流转的工程骨架,而不是一个只能看不能动的演示页面。
2. 号源、排班、订单三张表怎么设计才不返工
2.1 先想清楚挂号系统的核心实体关系
很多同学一上来就写 Controller,结果写到第三天发现号源扣减对不上,回头改表结构,血泪经验。挂号就诊系统的数据模型其实只有三条主线:医生排班决定「有哪些号可挂」,号源记录决定「每个号当前状态」,挂号订单决定「谁在什么时候占了哪个号」。这三者必须用外键或业务字段串起来,否则并发一上来就是玄学超卖。
我一般会先画实体关系再建表。核心表包括:department(科室)、doctor(医生)、schedule(排班,医生+日期+时段)、source(号源,排班下具体序号)、registration(挂号订单)、patient(患者)。其中schedule和source是一对多,source和registration是一对一(一个号只能被一个有效订单占用)。这个关系定死了,后面扣号、退号、爽约释放才有依据。
2.2 建表 SQL 与关键字段说明
下面这段 SQL 是可直接在 MySQL 8 执行的精简版,字段命名保持和 Java 实体一致,方便 MyBatis-Plus 映射。
-- 科室表 CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT '科室名称', location VARCHAR(128) COMMENT '门诊位置' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 医生表 CREATE TABLE doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, title VARCHAR(32) COMMENT '职称', department_id BIGINT NOT NULL, KEY idx_dept (department_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 排班表:一个医生一天一个时段一条记录 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, work_date DATE NOT NULL, time_slot TINYINT NOT NULL COMMENT '1上午 2下午', total_source INT NOT NULL COMMENT '总号源数', remain_source INT NOT NULL COMMENT '剩余号源数', UNIQUE KEY uk_doc_date_slot (doctor_id, work_date, time_slot) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 挂号订单表 CREATE TABLE registration ( id BIGINT PRIMARY KEY AUTO_INCREMENT, patient_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, source_no INT NOT NULL COMMENT '第几号', status TINYINT NOT NULL DEFAULT 1 COMMENT '1已挂号 2已就诊 3已取消 4爽约', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_schedule_source (schedule_id, source_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:schedule上的唯一键保证同一医生同一天同一时段不会重复排班;registration上的uk_schedule_source是防超卖的兜底,即使应用层判断失误,数据库也会拒绝重复占号。参数上time_slot用 TINYINT 而不是字符串,是为了后续按上午下午统计时索引更小;status用数字枚举,避免中文状态在代码里到处硬编码。
提示:
remain_source是冗余字段,目的是减少每次挂号都去 count 订单。它必须和registration在同一个事务里更新,否则会出现「显示有号但下不了单」。
2.3 用 MyBatis-Plus 生成实体与 Mapper
热词里有人搜「mybatisplus根据java实体类生成创建表的sql语句」,方向反了,更常见的是先有表再生成实体。SpringBoot 项目里引入依赖后,实体加注解即可。
@Data @TableName("schedule") public class Schedule { @TableId(type = IdType.AUTO) private Long id; private Long doctorId; private LocalDate workDate; private Integer timeSlot; private Integer totalSource; private Integer remainSource; }逻辑说明:@TableName指定表名,@TableId声明自增主键。LocalDate对应 MySQL 的 DATE,MyBatis-Plus 3.x 默认支持 JSR-310 类型,不需要额外配置。参数上如果数据库字段是下划线而实体是驼峰,开启map-underscore-to-camel-case: true即可,不用每个字段写@TableField。
3. 挂号扣号接口:从 Controller 到事务的完整落地
3.1 扣号为什么必须放在一个事务里
挂号的核心动作只有一句:把schedule.remain_source减一,同时插入一条registration。这两步如果分开提交,中间任何一步失败都会导致号源和订单不一致。常见翻车场景是:先减库存成功,插入订单时唯一键冲突抛异常,库存没回滚,号就凭空少了一个。所以 Service 方法必须加@Transactional,并且异常要往外抛,不能自己 catch 掉。
3.2 可复现的挂号 Service 代码
@Service public class RegistrationService { @Autowired private ScheduleMapper scheduleMapper; @Autowired private RegistrationMapper registrationMapper; @Transactional(rollbackFor = Exception.class) public Long register(Long patientId, Long scheduleId) { // 1. 悲观锁锁定排班行,防止并发扣减 Schedule schedule = scheduleMapper.selectByIdForUpdate(scheduleId); if (schedule == null) { throw new BizException("排班不存在"); } if (schedule.getRemainSource() <= 0) { throw new BizException("号源已挂完"); } // 2. 计算本次挂到的序号 int sourceNo = schedule.getTotalSource() - schedule.getRemainSource() + 1; // 3. 扣减号源 schedule.setRemainSource(schedule.getRemainSource() - 1); scheduleMapper.updateById(schedule); // 4. 写入挂号订单 Registration reg = new Registration(); reg.setPatientId(patientId); reg.setScheduleId(scheduleId); reg.setSourceNo(sourceNo); reg.setStatus(1); registrationMapper.insert(reg); return reg.getId(); } }逻辑说明:selectByIdForUpdate对应 SQLSELECT ... FOR UPDATE,在事务内锁住这一行排班,其他并发请求会排队等待,从而避免两个线程同时读到remain_source=1都去扣。sourceNo用「总数减剩余加一」推算,保证序号连续且不重复。参数上rollbackFor = Exception.class确保受检异常也回滚,这是很多人漏掉的一行。
对应的 Mapper 方法:
@Select("SELECT * FROM schedule WHERE id = #{id} FOR UPDATE") Schedule selectByIdForUpdate(@Param("id") Long id);注意:
FOR UPDATE必须在事务内才生效。如果 Service 没加@Transactional,这条 SQL 会退化成普通查询,锁不住任何东西。
3.3 并发压测时看什么指标
本地可以用 JMeter 或ab对挂号接口发 200 并发,观察三件事:一是registration表有没有重复的(schedule_id, source_no);二是remain_source最终是否等于total_source减去有效订单数;三是接口平均响应时间是否因为行锁飙升。如果响应时间从 20ms 涨到 800ms,说明锁竞争严重,这时候要考虑把号源按时间段拆细,而不是继续加锁粒度。
4. 排班与号源生成:别让护士手工录一百条
4.1 批量排班的常见做法
真实门诊里医生排班是按周或按月制定的,如果让护士在页面上一条条点,基本没人愿意用。常见做法是提供一个「按周期生成排班」的接口:输入医生、起始日期、结束日期、上午下午是否出诊、每时段号源数,后端循环插入schedule记录。这里要注意跳过医生已有的排班,否则唯一键会报错。
4.2 批量生成排班的代码与边界处理
@Transactional(rollbackFor = Exception.class) public int batchCreateSchedule(BatchScheduleDTO dto) { int count = 0; LocalDate cursor = dto.getStartDate(); while (!cursor.isAfter(dto.getEndDate())) { for (Integer slot : dto.getTimeSlots()) { // 已存在则跳过,避免唯一键冲突 Long exists = scheduleMapper.selectCount( new LambdaQueryWrapper<Schedule>() .eq(Schedule::getDoctorId, dto.getDoctorId()) .eq(Schedule::getWorkDate, cursor) .eq(Schedule::getTimeSlot, slot)); if (exists != null && exists > 0) { continue; } Schedule s = new Schedule(); s.setDoctorId(dto.getDoctorId()); s.setWorkDate(cursor); s.setTimeSlot(slot); s.setTotalSource(dto.getSourcePerSlot()); s.setRemainSource(dto.getSourcePerSlot()); scheduleMapper.insert(s); count++; } cursor = cursor.plusDays(1); } return count; }逻辑说明:外层循环日期,内层循环时段,每次插入前先查重。参数上sourcePerSlot建议不超过 50,超过这个数单个医生一上午也看不完,属于业务不合理而非技术问题。timeSlots用 List 传入,前端可以勾选上午、下午。
4.3 号源序号是生成时定还是挂号时定
这是一个容易争论的点。我的做法是挂号时定序号,也就是第 3 章里的sourceNo推算逻辑。原因是排班生成时还不知道谁会来,提前把 1 到 N 号写进source表虽然直观,但退号后序号会出现空洞,患者看到「3 号、5 号」会疑惑。挂号时按剩余量推算,序号始终连续,退号后下一个患者补上,体验更顺。
5. 避坑与排查:挂号系统上线前必须过的五道坎
5.1 号源显示有但下单提示已挂完
现象:列表页显示剩余 3 个号,点进去提交却返回「号源已挂完」。原因通常是列表查询走了缓存或从库,而扣号走主库,两者有延迟。解决:列表页的剩余号源直接读主库,或者接受短暂不一致但在提交时以主库为准,前端把「已挂完」作为正常业务提示而不是系统错误。
5.2 同一患者重复挂号同一时段
现象:一个患者连点两次提交,生成两条订单。原因是没有做患者维度的幂等控制。解决:在registration上加(patient_id, schedule_id, status)的业务唯一约束,或者在 Service 入口用 Redis 锁按patientId+scheduleId加锁,提交前先查是否已有有效订单。
5.3 退号后号源没有回滚
现象:患者取消挂号,但remain_source没变,号白白浪费。原因:退号逻辑只改了订单状态,忘了加回号源。解决:退号 Service 同样要加@Transactional,先selectByIdForUpdate锁排班,再把remain_source加一,最后把订单状态改为 3。三步顺序不能乱。
5.4 时间字段时区导致排班错一天
现象:本地测试正常,部署到服务器后当天排班查不到。原因:LocalDate和数据库时区、JVM 时区不一致。解决:统一在application.yml里设置spring.jackson.time-zone: GMT+8,数据库连接串加serverTimezone=Asia/Shanghai,并且排班日期只用 DATE 类型,不要用 DATETIME 存日期。
5.5 接口返回慢但数据库 CPU 不高
现象:挂号接口偶尔卡顿,数据库监控却正常。原因:FOR UPDATE锁等待,某个事务长时间未提交导致后续请求排队。解决:检查是否有事务里调用了外部接口或做了耗时计算,把非数据库操作移出事务;同时给schedule的(doctor_id, work_date)加索引,减少锁扫描范围。
6. 从能跑到好用:给挂号系统加一层状态机与对账
把系统跑起来只是第一步,真正让医院愿意用的是状态可追踪。挂号订单的status不应该在代码里到处set,而是收敛成一个状态机:1 已挂号 → 2 已就诊 / 3 已取消 / 4 爽约。每次状态变更都走同一个方法,方法内校验当前状态是否允许目标状态,不允许就抛业务异常。这样做的好处是,后面加「爽约三次限制挂号」这类规则时,只需要在状态机入口加判断,不用翻遍所有 Service。
public void changeStatus(Long regId, int target) { Registration reg = registrationMapper.selectById(regId); int current = reg.getStatus(); // 只允许 1->2、1->3、1->4 if (current != 1 || target < 2 || target > 4) { throw new BizException("当前状态不允许该操作"); } reg.setStatus(target); registrationMapper.updateById(reg); }逻辑说明:入口只接受从「已挂号」出发的变更,已就诊的订单不能再取消,已取消的不能再改成爽约。参数上target用常量类而不是魔法数字,方便前端对齐。这个状态机不复杂,但它是对账的基础。
对账我一般写一个定时任务,每天凌晨跑一次:统计每个排班的total_source、有效订单数、remain_source,三者对不上就告警。SQL 大致是SELECT schedule_id, COUNT(*) FROM registration WHERE status IN (1,2) GROUP BY schedule_id,再和schedule表逐条比对。这个任务不写也行,但一旦号源对不上,没有对账就只能靠患者投诉来发现,那代价就大了。
最后说个我自己的习惯:每次改完挂号或退号逻辑,我都会手动在数据库里把remain_source改成 0,再发一次挂号请求,看系统是返回业务提示还是抛 500。如果抛 500,说明边界没处理干净,这种代码我不敢交给别人用。希望帮到你。
本文还有配套的精品资源,点击获取