简介:本资源为基于SSM框架的医院门诊在线预约挂号管理系统完整项目代码,面向计算机相关专业毕业设计学生及Java Web初学者,帮助解决挂号预约类选题从设计到实现的全流程需求。项目采用Java语言开发,技术栈涵盖Spring、SpringMVC、MyBatisPlus、Vue、Ajax、Maven与MySQL,JDK版本为1.8,数据库为MySQL 5.7,可在Eclipse、MyEclipse或IDEA中运行。压缩包共640个文件,约15.61MB,其中152个java文件承载后端业务逻辑,111个vue文件与44个js文件构成前端交互界面,另有svg、jpg、png等图片素材及xml、css、sql等配置与样式文件,并附有项目说明文档。系统实现用户信息管理、图片与视频素材管理等功能模块,目录结构清晰,便于按模块阅读与二次开发。目前已有140人学习下载,适合需要完整赛题方案、代码参考与排错思路的读者使用。
1. 从挂号窗口到浏览器:一套 Java Web 门诊预约系统到底要解决什么
医院门诊大厅早上七点半的场面,做过医疗信息化的人都懂:挂号窗口前排着长队,导医台被围得水泄不通,患者手里攥着身份证和医保卡,反复问「今天还有没有专家号」。这套「医院门诊在线预约挂号管理系统」要干的事,就是把这套线下排队逻辑搬到浏览器里——患者打开网页选科室、选医生、选时段、提交预约,后台把号源扣减、生成挂号单、推给医生工作站。它属于典型的 Java Web 企业级项目,技术栈通常落在 Spring Boot + MyBatis + MySQL + 前端模板或前后端分离这一套上,也是很多 Java 开发工程师面试时被追问「你做过什么项目」的高频答案。
这篇文章面向三类人:想拿它当课程设计或毕业设计的在校生、想复刻一套练手项目的 Java 初学者、以及需要快速评估这套系统能不能落地的初级开发。我会按「业务模型怎么建 → 数据库怎么设计 → 核心接口怎么写 → 并发和号源怎么防超卖 → 上线前怎么排查」的顺序讲,中间给可抄的代码和参数,最后收在几个能直接用的调优技巧上。不吹架构,只讲能跑起来的东西。
2. 先把业务模型立住:科室、医生、排班、号源四张核心表怎么设计
很多人一上来就写 Controller,结果写到一半发现「一个医生一天到底放多少个号」这个问题没想清楚,返工重来。血泪经验是:挂号系统的复杂度不在增删改查,而在「号源」这个会随时间变化、会被并发争抢的实体。所以先把领域模型定死,再动手写代码。
2.1 四个核心实体和它们的关系
一套门诊预约系统,剥掉花哨功能,核心就是四个实体:
- 科室(Department):一级科室如内科、外科,下面可能还有二级科室如心血管内科。用
parent_id做自关联树。 - 医生(Doctor):属于某个科室,有职称(主任医师/副主任医师/主治医师)、挂号费、简介。
- 排班(Schedule):医生在某个日期、某个时段(上午/下午)出诊,这是「医生」和「时间」的交叉点。
- 号源(Slot / 号别):排班下的具体号,比如上午 8:00-8:30 有 5 个号。号源是真正被扣减的对象。
关系是:科室 1:N 医生,医生 1:N 排班,排班 1:N 号源。预约记录(Appointment)挂在号源上,一个号源被预约一次就减一。
提示:不要把「号源数量」直接做成排班表的一个
remain_count字段就完事。真实门诊里不同时段号量不同,且退号要能精确回补到原时段,所以号源最好独立成表,一行代表一个可预约单元。
2.2 建表 SQL 与字段说明
下面是我一般会用的最小可用表结构,MySQL 8.0 语法:
-- 科室表 CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT '科室名称', parent_id BIGINT DEFAULT 0 COMMENT '父科室ID,0为顶级', sort_no INT DEFAULT 0 COMMENT '排序', status TINYINT DEFAULT 1 COMMENT '1启用 0停用' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 医生表 CREATE TABLE doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_id BIGINT NOT NULL COMMENT '所属科室', name VARCHAR(32) NOT NULL, title VARCHAR(32) COMMENT '职称', reg_fee DECIMAL(8,2) DEFAULT 0 COMMENT '挂号费', intro VARCHAR(512), status TINYINT DEFAULT 1, KEY idx_dept (dept_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 COMMENT '出诊日期', period TINYINT NOT NULL COMMENT '1上午 2下午', total_slots INT NOT NULL COMMENT '总号量', UNIQUE KEY uk_doc_date_period (doctor_id, work_date, period) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 号源表:排班下的具体号 CREATE TABLE slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, seq_no INT NOT NULL COMMENT '第几号', start_time TIME COMMENT '就诊开始时间', status TINYINT DEFAULT 0 COMMENT '0可约 1已约 2锁定 3停诊', version INT DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_sch_seq (schedule_id, seq_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段设计里有几个点值得说清楚。schedule上的唯一索引uk_doc_date_period保证同一个医生同一天同一时段不会重复排班,这是数据一致性的第一道闸。slot表用seq_no表示第几号,配合start_time就能给患者展示「8:00 第 1 号、8:15 第 2 号」这种精确到分钟的候诊时间。version字段是为后面乐观锁准备的,先埋上。
2.3 号源生成:排班创建后怎么批量铺号
排班建好后,号源不是手动一条条插的,而是按规则批量生成。常见做法是:一个时段固定时长(比如上午 8:00-12:00 共 240 分钟),总号量 N,则每个号间隔240/N分钟。
/** * 根据排班批量生成号源 * @param schedule 排班对象,含 totalSlots 和 period */ public void generateSlots(Schedule schedule) { int total = schedule.getTotalSlots(); // 上午从 8:00 开始,下午从 14:00 开始 LocalTime start = schedule.getPeriod() == 1 ? LocalTime.of(8, 0) : LocalTime.of(14, 0); int interval = 240 / total; // 每个号间隔分钟数 List<Slot> slots = new ArrayList<>(total); for (int i = 1; i <= total; i++) { Slot s = new Slot(); s.setScheduleId(schedule.getId()); s.setSeqNo(i); s.setStartTime(start.plusMinutes((long) interval * (i - 1))); s.setStatus(0); slots.add(s); } slotMapper.batchInsert(slots); // 建议用 foreach 批量插入 }逻辑说明:interval用整数除法,如果 240 不能被 total 整除会有余数,实际项目里要么限制 total 是 240 的约数,要么把余数摊到最后一个号。参数上,period决定起始时间,totalSlots决定号量,这两个值来自排班录入界面。批量插入时记得在 MyBatis 里用<foreach>拼一条INSERT INTO slot (...) VALUES (...),(...),...,比循环单条插入快一个数量级。
3. 预约核心链路:从选号到落库的接口怎么写
模型立住之后,真正体现功力的是预约这条链路。它要处理「查号 → 校验 → 扣减 → 生成挂号单 → 返回」五步,每一步都有坑。
3.1 查询可约号源的接口与分页
患者进页面第一件事是查某医生某天还有哪些号。这个查询要快,因为它是最高频的读操作。
@GetMapping("/slots") public Result<List<SlotVO>> listSlots(@RequestParam Long doctorId, @RequestParam @DateTimeFormat(pattern="yyyy-MM-dd") LocalDate date) { // 先查排班,再查该排班下 status=0 的号源 Schedule sch = scheduleMapper.selectByDoctorAndDate(doctorId, date); if (sch == null) { return Result.ok(Collections.emptyList()); } List<SlotVO> slots = slotMapper.selectAvailable(sch.getId()); return Result.ok(slots); }对应的 SQL 只查可约号,避免把已约的也捞出来:
SELECT id, seq_no, start_time FROM slot WHERE schedule_id = #{scheduleId} AND status = 0 ORDER BY seq_no;逻辑说明:这里没有做分页,因为一个时段的号量通常不超过 50,一次返回完全够用。如果号量很大(比如某些专科一天几百号),再加LIMIT。参数doctorId和date是前端传的,注意日期格式用@DateTimeFormat绑定,否则LocalDate会报转换异常——这是新手最常翻的车之一。
3.2 提交预约:校验、扣减、生成挂号单
提交预约是整个系统最需要保护的地方。核心逻辑是:先校验号源是否可约,再扣减,再写预约记录,三步必须在一个事务里。
@Transactional(rollbackFor = Exception.class) public Appointment book(Long slotId, Long patientId) { // 1. 查号源,带行锁(悲观锁)或后面用乐观锁 Slot slot = slotMapper.selectByIdForUpdate(slotId); if (slot == null || slot.getStatus() != 0) { throw new BizException("该号源已被预约或不可约"); } // 2. 扣减:状态改为已约 int rows = slotMapper.updateStatus(slotId, 1, slot.getVersion()); if (rows == 0) { throw new BizException("号源已被抢走,请重新选择"); } // 3. 生成挂号单 Appointment appt = new Appointment(); appt.setSlotId(slotId); appt.setPatientId(patientId); appt.setStatus(1); // 1已预约 appt.setCreateTime(LocalDateTime.now()); appointmentMapper.insert(appt); return appt; }逻辑说明:selectByIdForUpdate走的是SELECT ... FOR UPDATE,在事务内锁住这一行,防止并发下两个请求同时读到status=0。updateStatus里带上version做二次校验,是悲观锁 + 乐观锁的双保险。参数slotId是号源主键,patientId是当前登录患者。事务注解rollbackFor = Exception.class必须加,否则受检异常不会回滚,这是 Java 事务的经典坑。
3.3 退号与号源回补
退号不是简单删记录,而是把号源状态改回可约,同时把预约记录标记为已取消。
@Transactional(rollbackFor = Exception.class) public void cancel(Long appointmentId) { Appointment appt = appointmentMapper.selectById(appointmentId); if (appt == null || appt.getStatus() != 1) { throw new BizException("预约不存在或已取消"); } // 回补号源 slotMapper.updateStatus(appt.getSlotId(), 0, null); // 标记预约取消 appointmentMapper.updateStatus(appointmentId, 2); }逻辑说明:回补时version传 null,表示不校验版本,直接改状态。这里有个业务规则要确认:退号是否有时限(比如就诊前 2 小时不能退)。如果有,就在cancel开头加时间判断。参数appointmentId是预约记录主键。
4. 号源防超卖:并发场景下的三种方案与选型
挂号系统最怕的就是超卖——同一个号被两个人约到。这不是理论问题,是真实门诊高峰期每秒几十个请求打进来时必然遇到的。下面三种方案我都用过,说清楚各自适用场景。
4.1 数据库悲观锁:简单但吞吐有限
就是上面SELECT ... FOR UPDATE的写法。优点是实现简单、绝对不超卖;缺点是锁行期间其他请求阻塞,高并发下响应变慢,且必须在事务内使用,事务一长锁就久。
适用场景:中小医院,日预约量几千以内,并发不高。参数上要注意FOR UPDATE必须命中索引,否则会锁表——slot表按主键查,天然走索引,没问题。
4.2 乐观锁:适合读多写少
乐观锁不加锁,靠version字段在更新时校验:
UPDATE slot SET status = 1, version = version + 1 WHERE id = #{id} AND status = 0 AND version = #{version};如果返回影响行数为 0,说明被别人抢先改了,业务层重试或直接提示用户。优点是并发性能好,缺点是冲突多时重试率高。参数version是查询时读到的值。适用场景:号源竞争不是特别激烈,或者你能接受用户重试。
4.3 Redis 预扣减:高并发下的常用做法
真正扛高并发的方案是把号源库存放到 Redis,用原子操作预扣,再异步落库。
// 号源初始化时写入 Redis:key = slot:stock:{scheduleId} // value = 剩余号量 public boolean preDeduct(Long scheduleId) { String key = "slot:stock:" + scheduleId; Long remain = redisTemplate.opsForValue().decrement(key); if (remain == null || remain < 0) { // 扣成负数,回补并拒绝 redisTemplate.opsForValue().increment(key); return false; } return true; }逻辑说明:decrement是原子操作,天然防并发。扣减成功后写一条消息到队列,由消费者异步生成预约记录并更新数据库。参数scheduleId作为 key 维度,也可以细到slotId。注意 Redis 和数据库的一致性——如果异步落库失败,要有补偿机制把 Redis 库存加回去,否则会少卖。
注意:三种方案不是互斥的。我一般会在 Redis 预扣这一层挡住绝大部分流量,落库时再用乐观锁兜底,双保险。
5. 上线前必查:挂号系统最容易翻车的五个坑
功能写完不代表能用,下面这几条是我踩过或见别人踩过的,按「现象 → 原因 → 解决」列清楚。
坑一:并发下同一号源被约两次。现象是数据库里两条预约记录指向同一个slot_id。原因是没用锁或锁的范围不对,两个事务同时读到status=0。解决:按第 4 章选一种防超卖方案,最差也要在slot表加唯一约束UNIQUE(schedule_id, seq_no)配合状态更新。
坑二:退号后号源没回补,号白白浪费。现象是患者退了号,但该号显示不可约。原因是退号逻辑只改了预约状态,忘了改slot.status。解决:退号必须在一个事务里同时更新两张表,且加日志记录回补动作。
坑三:日期时区导致排班查不到。现象是明明排了班,前端查却是空。原因是数据库存的是DATE,Java 用LocalDate,但 JDBC 连接串没配时区,或者前端传的是带时间的字符串。解决:连接串加serverTimezone=Asia/Shanghai,前端统一传yyyy-MM-dd,后端用@DateTimeFormat绑定。
坑四:事务里做了远程调用导致锁持有过久。现象是高峰期预约接口大面积超时。原因是在@Transactional方法里调了短信、支付等外部接口,事务迟迟不提交,行锁一直不放。解决:把远程调用挪到事务提交之后,用TransactionSynchronizationManager的afterCommit回调,或者干脆异步发消息。
坑五:号源批量生成时 total 为 0 导致除零。现象是创建排班直接 500。原因是240 / total里 total 为 0。解决:入参校验totalSlots > 0,并在生成前判断,别指望前端一定传对。
6. 让这套系统更耐用的两个进阶技巧
功能跑通只是及格线,真正让一套挂号系统在生产环境站住脚的,往往是几个不起眼的细节。这里说两个我反复用到的技巧。
技巧一:用数据库唯一索引做最后一道防线。不管你前面用了多严密的锁,都建议在appointment表上加一个唯一索引UNIQUE(slot_id, status)的变体——更准确的做法是加一个「有效预约」的冗余列,比如active_flag,已取消的置 0,有效预约置 1,然后UNIQUE(slot_id, active_flag)。这样即使代码有 bug,数据库也会拒绝第二条有效预约。这是黑匣子式的兜底,出问题时至少数据不会脏。
技巧二:号源查询加本地缓存,但退号要主动失效。号源列表是读多写少的典型,可以在 Service 层用 Caffeine 做 5 秒本地缓存,减少数据库压力。但退号、预约成功后必须主动invalidate对应 key,否则患者会看到过期数据。参数上缓存时间别设太长,5 到 10 秒足够,因为号源变化频繁。
// Caffeine 缓存示例 Cache<Long, List<SlotVO>> slotCache = Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .maximumSize(1000) .build(); public List<SlotVO> getSlotsWithCache(Long scheduleId) { return slotCache.get(scheduleId, id -> slotMapper.selectAvailable(id)); } // 预约或退号成功后调用 public void evictSlotCache(Long scheduleId) { slotCache.invalidate(scheduleId); }逻辑说明:expireAfterWrite控制写入后 5 秒过期,maximumSize防止内存无限增长。get方法的第二个参数是加载函数,缓存未命中时自动查库。参数scheduleId作为缓存 key,粒度合适——太细(按 slotId)缓存条目爆炸,太粗(按医生)失效范围过大。
最后说个我自己的习惯:每次改完预约或退号逻辑,我都会写一个并发测试,用 50 个线程同时抢同一个号,跑 100 轮,确认数据库里永远只有一条有效预约。这个测试比任何代码审查都管用,后悔药就是提前把并发测了。希望帮到你。
本文还有配套的精品资源,点击获取