医院预约挂号系统Java源码解析:表结构、事务与防并发超卖 简介这是一套基于Java的医院预约挂号系统设计源码面向Java初学者、课程设计及毕业设计人群。系统围绕互联网挂号场景覆盖用户注册登录、医生排班查询、在线预约专家门诊、预约状态跟踪与后台管理模块医院工作人员可管理预约信息、调整排班有效简化就医环节、节约排队时间提升患者就诊体验。压缩包共47个文件约499KB其中Java源码22个、XML配置8个另有数据库SQL脚本、项目Maven配置、说明文档及9张任务截图能够支撑导入编译、数据库初始化与效果预览整体结构清晰。已有382人学习过该资源适合直接导入Eclipse或IntelliJ run调试。直接使用或在源码基础上二次开发可以帮助理解医院业务场景下的数据表设计、前后端交互流程与预约状态管理是一份可参考的Java Web项目模板。1. 排队两小时不如先拆这套Java医院预约挂号系统源码工作日上午的三甲医院挂号窗口常常排着长队专家号开号几分钟就被抢光。这套基于Java的医院预约挂号系统把查医生排班、选号源、在线预约、查看预约状态这些流程搬到线上让患者不用反复跑医院。项目压缩包里带了db_appoint.sql和完整的Maven工程是典型的Java Web课设/毕设结构也适合准备Java面试时当作项目素材来拆。它体量不算大但排班查询、号源扣减、预约状态流转、管理端调班这些互联网挂号的核心点都覆盖到了。跟着源码走一遍能看清一个真实业务系统怎么分层、怎么用唯一索引和事务防并发。适合没有完整项目经验的Java开发者以及想快速搭一套预约类系统做二次开发的工程师。2. 从文件结构反推分层架构先看数据库怎么设计预约解压upload.zip之后别急着启动先把文件列表过一遍。.classpath、.settings、.project说明这是Eclipse工程pom.xml说明依赖由Maven管理src/main/java和src/test/java是标准Maven目录db_appoint.sql是数据库初始化脚本readme.txt里通常写着运行步骤。这种组合在课设和毕设中很常见技术栈一般是Spring Boot MyBatis MySQL也可能是Spring MVC MyBatis JSP/Thymeleaf。无论哪种代码分层大多逃不出entity、mapper、service、controller四层。2.1 按照 Controller → Service → Mapper 的链路读代码拿到项目后先看readme.txt和pom.xml。readme里一般写了导入方式和启动顺序pom里能看到spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-java这类依赖。国内很多课设项目还会引入druid连接池或lombok这些属于锦上添花不影响对核心业务的理解。配置和依赖确认后再按Controller、Service、Mapper的顺序读代码而不是从entity开始逐行扫。Controller层通过RestController暴露HTTP接口只做参数接收、调用Service、封装Result返回Service层处理业务规则比如校验号源、扣减余号、状态流转Mapper层对应数据库操作。这样做的好处是改了预约规则只动Service接口和SQL都不用牵连。排查问题时顺着调用链看很快就能确定是哪一层出了问题。下面是这类项目常见的包结构src/main/java └── com/hospital/appoint ├── controller │ ├── DeptController.java │ ├── ScheduleController.java │ └── AppointmentController.java ├── service │ ├── ScheduleService.java │ └── AppointmentService.java ├── mapper │ ├── DeptMapper.java │ ├── ScheduleMapper.java │ └── AppointmentMapper.java └── entity ├── Department.java ├── Doctor.java ├── Schedule.java └── Appointment.java实际包名不一定叫com.hospital.appoint解压后以你的目录为准但整体结构基本一致。如果看到mapper下面只有接口没有impl说明SQL写在resources/mapper目录下的XML里这是MyBatis的经典用法。还有些项目会引入MyBatis-Plus实体类上出现TableName和TableId注解Service继承IServiceMapper继承BaseMapper。单表增删改查不用写SQL这时读源码的重点就变成找自定义SQL在哪里。2.2 核心表科室、医生、排班、预约打开db_appoint.sql核心表基本是四张科室表、医生表、医生排班表、预约挂号表有的项目还会加用户表。表名可能有差异比如排班表叫doctor_schedule预约表叫apt_order但字段语义逃不出下面这几类。按最常见的命名整理一份建表脚本方便对照着看CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_name VARCHAR(64) NOT NULL, intro VARCHAR(255) ) COMMENT 科室表; CREATE TABLE doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_id BIGINT NOT NULL, doctor_name VARCHAR(32) NOT NULL, title VARCHAR(16) COMMENT 职称, intro VARCHAR(255), KEY idx_dept (dept_id) ) COMMENT 医生表; CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, work_date DATE NOT NULL, work_slot VARCHAR(8) NOT NULL COMMENT 上午/下午, total_number INT NOT NULL COMMENT 总号源, remain_number INT NOT NULL COMMENT 剩余号源, UNIQUE KEY uk_doctor_date_slot (doctor_id, work_date, work_slot) ) COMMENT 医生排班表; CREATE TABLE appointment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0已预约 1已取消 2已完成 3爽约, create_time DATETIME NOT NULL, UNIQUE KEY uk_schedule_user (schedule_id, user_id) ) COMMENT 预约挂号表;这段脚本里有两个关键约束。schedule表的uk_doctor_date_slot保证同一医生同一天同一时段只有一条排班appointment表的uk_schedule_user保证同一用户不能重复预约同一时段。remain_number字段配合“UPDATE时带remain_number 0”的条件能在高并发下防止超卖这个在第3章会展开。实际项目里如果字段名不同比如把work_slot换成了start_time和end_time你要确认的是有没有等价的唯一约束。有同学会问appointment表为什么不直接存doctor_id和work_date而是要存schedule_id。因为排班是有生命周期的管理员可以停诊、加号、减号如果预约表冗余了医生和时间排班一变历史预约展示就会和当前排班冲突。用schedule_id关联后所有预约都挂在同一个排班ID下排班状态变化可以顺藤摸瓜找到所有受影响的预约。下面是一个字段速查表| 表 | 字段 | 作用 | 索引/约束 | | department | dept_name | 科室名称挂号入口第一层筛选 | 唯一索引 | | doctor | dept_id | 关联科室 | 普通索引 | | schedule | work_date work_slot | 确定一个可预约时段 | 联合唯一索引 | | schedule | remain_number | 控制是否有号扣减时作为乐观条件 | 无 | | appointment | schedule_id user_id | 防重复预约的最终防线 | 联合唯一索引 | | appointment | status | 预约状态流转 | 普通索引 |拿到数据库脚本后先看这四张表再回过去看代码很多逻辑就通了。预约接口为什么先update后insert为什么要用Transactional包住两个操作在表结构里已经能看到答案。3. 排班查询到预约落库Controller、Service、Mapper 三层怎么配合预约系统的用户端链路很清晰按科室查排班选时段点预约然后查看预约状态。对应到代码里是三个接口排班列表、创建预约、我的预约。这一章把接口实现落到三层结构里重点说明哪些判断该写在Service里哪些条件该写在SQL里。3.1 查询医生排班动态条件拼SQL用户可能只按科室筛也可能只看某一天的号所以接口参数要支持可选。典型写法如下RestController RequestMapping(/api/schedule) public class ScheduleController { private final ScheduleService scheduleService; public ScheduleController(ScheduleService scheduleService) { this.scheduleService scheduleService; } GetMapping(/list) public Result listSchedule(RequestParam(required false) Long deptId, RequestParam(required false) String workDate) { return Result.ok(scheduleService.listSchedule(deptId, workDate)); } }参数上的RequestParam(requiredfalse)表示两个参数可传可不传不传默认查全部。Controller里不做业务判断直接透传给Service。有的项目会在这里加javax.validation的NotNull用全局异常处理器统一返回错误信息但课设版本通常省略。Service层也很薄public ListScheduleVO listSchedule(Long deptId, String workDate) { return scheduleMapper.selectAvailableSchedule(deptId, workDate); }过滤逻辑放在Mapper XML里用MyBatis的动态SQL拼接条件select idselectAvailableSchedule resultTypeScheduleVO SELECT s.id, s.doctor_id, d.doctor_name, d.title, s.work_date, s.work_slot, s.total_number, s.remain_number FROM schedule s JOIN doctor d ON s.doctor_id d.id where if testdeptId ! null AND d.dept_id #{deptId} /if if testworkDate ! null AND s.work_date #{workDate} /if AND s.remain_number 0 /where ORDER BY s.work_date, s.work_slot /selectwhere标签会自动去掉第一个多余的ANDif标签只在参数非空时拼接条件这样一套SQL就能覆盖“只按科室查”“只按日期查”“按科室加日期查”三种情况。最后面的AND s.remain_number 0保证查出来的都能约前端不用再做二次过滤。参数含义可以对照下表参数类型必填说明deptIdLong否科室ID为空查全部科室workDateString否日期yyyy-MM-dd为空查全部日期3.2 创建预约先扣号源再写预约记录排班查询本身不复杂真正的坑在创建预约。创建预约要保证两个动作原子完成扣减schedule.remain_number插入appointment记录。很多课设代码先select查剩余号if大于0再update这在演示环境没毛病一旦并发就会超卖。正确做法是用一条带条件的UPDATE直接扣号Transactional(rollbackFor Exception.class) public Long createAppointment(Long scheduleId, Long userId) { int rows scheduleMapper.decreaseRemain(scheduleId); if (rows 0) { throw new BusinessException(该时段号源已满请选择其他时段); } Appointment appointment new Appointment(); appointment.setScheduleId(scheduleId); appointment.setUserId(userId); appointment.setStatus(0); appointment.setCreateTime(new Date()); appointmentMapper.insert(appointment); return appointment.getId(); }对应的Mapper方法SQLUPDATE schedule SET remain_number remain_number - 1 WHERE id #{scheduleId} AND remain_number 0这段UPDATE是防超卖的核心。数据库在更新时会锁定对应行两个并发请求同时进来后到的必须等前一个提交到自己执行时remain_number已经减过如果变成0影响行数就是0代码里用rows 0判断抢号失败并抛异常。Transactional由Spring接管保证扣号和插入预约记录要么都提交要么都回滚不会出现“号扣了但记录没生成”的中间状态。scheduleId和userId分别是排班主键和当前登录用户主键真实项目里userId从session或JWT中取不能信任前端传值。3.3 查看我的预约join三张表返回状态用户端第三个接口是查询“我的预约”需要展示医生科室、姓名、职称、排班日期和当前状态。状态字段用TINYINT存储常见含义如下状态值含义触发时机0已预约预约成功后1已取消用户取消或管理员取消2已完成就诊结束标记3爽约已预约未就诊且未取消查询SQL典型写法SELECT a.id, a.schedule_id, a.status, s.work_date, s.work_slot, d.doctor_name, d.title FROM appointment a JOIN schedule s ON a.schedule_id s.id JOIN doctor d ON s.doctor_id d.id WHERE a.user_id #{userId} ORDER BY s.work_date DESC, s.work_slot DESC这里join前只放必要字段避免把整行大字段带出来。Service层在返回前可以根据当前时间把过期的status0改成status3也可以起定时任务统一刷。课设阶段我一般放在Service里查询前先执行一条批量UPDATE保证用户看到的永远是准的状态。4. 并发下不超卖事务、唯一索引、状态机与排班调整上一章的扣号SQL挡住了“多人抢同一张号”的并发问题但预约系统还有两类经常被忽视的并发问题同一用户重复预约以及管理端改排班时和用户预约撞车。这两个坑只靠Service里的if判断防不住必须把约束下沉到数据库和事务里。4.1 唯一索引兜底重复预约的最终防线很多课设代码在创建预约前会先select一次判断当前用户有没有约过这个时段。单用户测试没问题但两个请求同时到达时两个线程都查不到记录接着都执行insert重复预约就发生了。所以建表时要设计uk_schedule_user联合唯一索引。代码层面不需要额外查询直接insert数据库报DuplicateKeyException就说明重复了try { appointmentMapper.insert(appointment); } catch (DuplicateKeyException e) { throw new BusinessException(您已预约过该时段请勿重复预约); }这里把唯一索引当成业务规则的最终防线。即使Service漏判断数据库也会拦截。注意catch范围不能扩大否则会把真正的SQL语法错误吞掉排错时会很痛苦。如果你手上的版本没有这个唯一索引强烈建议加上否则上线后一定会出现重复预约数据。4.2 取消预约先改状态再回补号源取消预约是创建预约的逆操作创建时扣号源取消时把号加回来。实现上有两个细节一是预约记录必须属于当前用户二是先改预约状态再回补号源两个动作放在同一个事务里。Transactional(rollbackFor Exception.class) public void cancelAppointment(Long appointmentId, Long userId) { int rows appointmentMapper.cancelOwned(appointmentId, userId); if (rows 0) { throw new BusinessException(预约记录不存在或无权取消); } Appointment appointment appointmentMapper.selectById(appointmentId); scheduleMapper.increaseRemain(appointment.getScheduleId()); }cancelOwned的SQLUPDATE appointment SET status 1 WHERE id #{appointmentId} AND user_id #{userId} AND status 0WHERE里带user_id是防越权防止用户知道别人的预约ID就改别人数据带status 0是防重复取消已经取消的记录不会再次影响行数。回补号源放在更新状态之后如果先加号再改状态过程中一旦异常回滚会出现“号补了但状态还是已预约”的脏数据。把整个方法加上Transactional后Spring会在RuntimeException抛出时回滚所有已执行SQL。排查事务不生效时先看两处Transactional是否加在public方法上以及是不是在同一个类内部调用比如this.cancelAppointment()。Spring事务默认走代理private方法和类内自调用都不会触发事务这是Java后端开发里最常见的误用点。4.3 管理端调整排班加号、减号与停诊医院工作人员操作的核心对象是schedule表不是appointment表。加号、减号、停诊三种操作每一种都有边界。加号直接改两个数字UPDATE schedule SET total_number total_number 10, remain_number remain_number 10 WHERE doctor_id #{doctorId} AND work_date #{workDate} AND work_slot #{workSlot}减号要保证新总号数不小于已预约数量否则剩余号会变成负数UPDATE schedule SET total_number total_number - 5 WHERE id #{id} AND total_number - 5 ( SELECT COUNT(*) FROM appointment WHERE schedule_id #{id} AND status IN (0, 2) )这条SQL把“修改后总号数大于等于已预约数”作为更新条件不满足就影响0行。停诊时不能直接DELETE排班记录因为appointment表外键还指着它删除会让历史预约变成悬空记录。合理做法是加一个排班状态字段或者把remain_number置为0同时给已预约用户发停诊通知。排班状态常见设计状态值含义用户端展示1正常排班可预约0停诊或不可约不展示已预约用户提示改约2已结束只读不可操作管理端所有写操作都要走和用户端一样的事务控制。如果停诊要发通知改状态和发通知必须在同一事务里否则会出现“通知说停诊库里排班却正常”的不一致。另外如果是带支付功能的预约系统还要处理超时未支付自动释放号源一般在订单表加expire_time定时任务扫描过期待支付订单回补号源并关闭订单。这个项目若没有支付环节暂时不用考虑。5. 把项目跑起来导入、启动、用Postman验证一条预约链路源码看完了最终要跑起来。这一章给一个验证顺序让你确认自己理解的模块真的在工作。5.1 导入前检查三处配置用Eclipse或IDEA导入Maven工程后先检查三处pom.xml里maven.compiler.source指定的JDK版本application.yml里的数据源连接以及数据库脚本是否已导入。JDK版本不匹配会导致编译失败数据库连接不对最常见的是Access denied。执行下面的命令导入初始数据mysql -u root -p db_appoint.sql确认库里已经生成department、doctor、schedule、appointment等表再启动应用。5.2 用Postman或curl验证完整链路接口路径以实际项目为准一般是下面这种风格curl http://localhost:8080/api/schedule/list?deptId1 curl -X POST http://localhost:8080/api/appointment/create \ -d scheduleId1userId1 curl http://localhost:8080/api/appointment/my?userId1第一条返回某科室的可约排班从中记下一个scheduleId第二条用这个scheduleId创建预约成功会返回预约ID和状态0第三条查当前用户的预约列表能看到医生和科室信息。把第二条重复执行一遍应该返回“已预约”的业务错误这就是唯一索引和异常处理在起作用。5.3 读源码时的重点跟踪顺序如果是为了面试读这份源码不用从第一个类读到最后一个。按ScheduleController到ScheduleMapper.xml走一遍排班查询然后在AppointmentService里重点看createAppointment的Transactional和decreaseRemain的SQL。把“先扣号源再写预约记录、唯一索引防重复预约、取消时先更新状态再回补号源”这三件事推演明白比背完整套代码更有说服力。这三个点也是Java后端面试里常被追问的地方值得多花时间想清楚。本文还有配套的精品资源点击获取