☰
Java+SpringBoot医疗预约挂号系统实战:号源模型与防超卖核心设计
2026/10/7 10:22:46 网站建设 项目流程

我这两年带过不少学生做毕业设计,也面试过不少拿着类似项目来找工作的应届生。医疗预约挂号系统确实是个出镜率极高的题目,同时也是个典型的“听着简单、做起来全是细节”的业务系统。它不像电商那样拼并发,也不像管理系统那样拼CRUD,核心难点在于那个“号源模型”——怎么把一个医生的出诊时间切成可预约的号,怎么防止两个患者同时抢到同一个号,怎么处理停诊、退号、爽约这些医疗特有的业务状态。这篇博文就围绕这套Java+SpringBoot的医疗预约挂号平台,把项目从需求拆解、技术选型、数据库设计到核心模块的落地实现完整走一遍。不管是准备毕业设计答辩,还是想用SpringBoot做个能写进简历的Web项目,这篇都能给你一些能直接抄作业的东西。

1. 项目定位与需求拆解:先搞清楚要解决什么问题

1.1 这个系统到底在解决什么

很多同学拿到这个题目第一反应是“不就是管理员维护医生信息、用户能挂号嘛”,结果做出来的东西文档里写的是预约挂号平台,演示起来就是个增删改查后台。这是毕设最容易翻车的地方:没有把业务问题拆透。

真实场景里,医院挂号难的本质问题有三个:号源信息不透明、排班靠人工、爽约率高。患者不知道哪个医生哪天还有号,必须跑到医院窗口问;医生排班改了,挂号处和患者可能都不知道;挂了号不来的人多,医生时间被白白浪费。

所以这个系统要解决的,是在这三件事上给出数字化方案:第一,让患者能在Web端实时查看科室、医生、排班和剩余号源,在线完成预约;第二,让医院管理员可以维护科室医生信息、配置每周排班、设置号源数量、处理停诊;第三,让医生能查看自己的排班和预约患者记录,并做简单的出诊确认或停诊操作。这三条主线,对应了患者端、管理员端、医生端三种角色,整个系统的功能边界就清晰了。

1.2 用户角色与核心流程

我一般建议学生把角色拆成三类,权限划分直接融入功能设计:

  • 患者:注册登录、浏览科室与医生、查看排班、在线预约、我的预约管理(取消预约、查看历史记录)
  • 医生:查看个人排班、查看某天的预约患者列表、标记出诊状态、维护个人简介与擅长领域
  • 管理员:科室管理、医生账号管理、排班管理(按周生成班次)、号源数量配置、停诊/放号操作、系统数据统计

核心流程是这条链路:患者选择科室 → 查看该科室下的医生列表 → 进入医生详情查看7天排班 → 选择某个时间段点击预约 → 系统校验是否还有剩余号源 → 生成预约订单 → 患者可在个人中心查看和取消。这条链路里最容易出问题的环节,就是“选择时间段”这一步的并发冲突,后面我会专门讲。

这里要提醒一点:不要把功能做得太泛。比如有的同学想加在线支付、药品配送、病历电子化,对毕业设计来说这完全是往自己身上加工作量。预约挂号的边界就是“号源管理 + 预约流程 + 基础档案”,把这条线打通做扎实,答辩时把并发预约和排班设计讲清楚,就已经是一个完成度很高的项目了。

1.3 为什么说它适合做毕业设计

从技术训练角度,这个项目的业务链路足够完整,涉及SpringBoot的配置体系、MyBatis-Plus的持久层操作、关系型数据库的表设计与事务处理,还牵涉前后端分离落地。从实操角度,它有一个非常明确的业务重难点——号源扣减,这需要你真正去理解数据库锁和事务隔离级别,而不是背概念。从答辩角度,评委老师大概率会问“两个用户同时抢最后一个号怎么办”,而这个问题如果你真的做过、思考过,会答得非常扎实。

2. 技术选型与架构设计:版本搭配是第一个大坑

2.1 后端技术怎么选:SpringBoot版本别跟风

后端选型基本没什么悬念:JDK + SpringBoot + MyBatis-Plus + MySQL。但这里有个真实的坑,我必须提前说。

现在很多教程上来就让你用SpringBoot 3.x,但SpringBoot 3.x对JDK版本要求是17以上,而且它的starter命名和部分配置项和2.x差异很大。如果你本机装的是JDK 8(很多学校实验室和老教程都是8),直接建3.x项目会发现编译都过不去。我的建议是:不是非要用新版就选SpringBoot 2.7.x + JDK 8,这套组合有海量的资料、问题排查记录,MyBatis-Plus兼容性也最稳,放服务器上内存占用还小。等你把业务跑通了,想升级再升级,不要在起步阶段跟版本较劲。

依赖引入的时候注意几个核心点:

  • spring-boot-starter-web:提供Web能力,内嵌Tomcat
  • mybatis-plus-boot-starter:持久层框架,注意要用适配当前SpringBoot版本的
  • mysql-connector-j或mysql-connector-java:MySQL驱动
  • lombok:简化实体类代码(记得在IDEA装好Lombok插件)
  • spring-boot-starter-validation:参数校验,这个很重要,预约接口不能靠手写if去判断参数

2.2 前端与打包方式:Vue打包放进SpringBoot

前端我推荐用Vue 2 + Element UI,不要上Vue 3 + TypeScript + Vite那套,一是对毕设来说复杂度没必要,二是Vue 2 + Element UI在国内教程存量最大,出了问题搜得到答案。

很多学生纠结前后端要不要分离部署。现实情况是:你答辩演示的机器可能没有Node环境,也可能没条件跑两个服务。最省心的方案是:前端开发时用Vite或Webpack的devServer做联调,联调完成后执行npm run build,把生成的dist目录下的所有文件复制到SpringBoot的src/main/resources/static目录里。这样SpringBoot启动后,访问localhost:8080就能直接打开页面,静态资源和后端接口在同一个端口下,同源策略的问题也顺带解决了。

这个方案有两点要注意。一是前端里所有请求路径不要用绝对路径,统一用相对路径比如/api/user/login,这样打包后才不依赖ip和端口配置。二是Vue路由如果用history模式,刷新页面会出现404,解决办法有两个:后端写一个简单的转发Controller,或者把路由改成hash模式。我建议直接用hash模式,省事。

2.3 项目分层:别把代码全塞Controller

项目分层直接决定了代码能不能看懂、能不能答辩时讲清楚,也决定了面试官第一眼对你的印象。我建议按经典四层结构来:

com.example.hospital ├── controller // 接受请求,参数校验,返回结果 ├── service // 业务逻辑层,事务边界在这里 ├── mapper // MyBatis-Plus的数据访问层 ├── entity // 数据库实体映射 ├── dto // 前端传参的封装对象 ├── vo // 返回给前端的数据视图对象 ├── config // 配置类(跨域、MyBatis-Plus分页等) └── common // 统一返回值、异常处理、工具类

为什么一定要分Controller、Service、Mapper?因为预约扣减号源这种操作,一定是跨了好几个表的业务动作:校验排班状态、扣减剩余号数、生成订单记录、可能还要记录日志。这些必须放在Service层的方法里并且加上事务注解,如果散写在Controller里,事务没法控制,出了问题难以维护,答辩证这段代码也说不清楚。说白了,分层不是炫技,是给后续的维护和排查省时间。

2.4 最佳实践:登录鉴权 Session 而非 JWT

前后端分离项目会有一个惯性思维,觉得不用JWT就不是现代项目。但对这个系统来说,我推荐用Session + 拦截器的方式做登录态,原因很实际:项目规模小、同一时间在线用户数量级低、单机部署,Session方案足够用还简单,不存在分布式会话问题;答辩时面试官如果问“为什么不选JWT”,答“单体应用用Session更简单,JWT适合无状态分布式场景,这里没有这个需求”比硬上一个JWT然后解释半天它的缺点要好得多。

前端登录成功后,后端request.getSession().setAttribute("userId", userId),再写一个HandlerInterceptor,在预检请求和没有登录态的请求上做放行与拦截判断。被拦截的请求前端配合响应拦截器统一跳到登录页,整个用户体系就转起来了。

3. 数据库设计:医疗业务的核心难点全在这张ER图里

3.1 核心表结构拆解

我见过太多人数据库就建了三张表:用户表、医生表、预约表。这远远不够。真正支撑起预约流程的,至少需要这几张核心表:

表名核心字段作用
sys_userid, username, password, real_name, phone, role, status统一用户表,role区分患者/医生/管理员
deptid, dept_name, dept_desc科室信息
doctorid, user_id, dept_id, title, specialty, intro, avatar医生档案,关联用户表和科室表
scheduleid, doctor_id, dept_id, work_date, period_type, start_time, end_time, total_count, remain_count, status排班表,决定某医生某天有哪些时段可约
appointmentid, schedule_id, user_id, doctor_id, dept_id, appoint_date, appoint_time, status, create_time预约订单表,记录一次预约
appointment_logid, appointment_id, operate_type, remark, create_time操作日志,方便排查问题

自己对照一下,如果两张表就想覆盖所有业务,那一定是把排班信息硬塞进了预约表里,或者没有拆分操作日志。这些字段不是随便定的,比如schedule里total_count和remain_count是号源控制的关键,period_type用来区分上午还是下午;appointment里的status至少要有待就诊、已完成、已取消、已爽约这几种,不然整个业务状态流转演示不完整。

3.2 排班与号源设计:两个最容易被想简单的点

排班是医院系统的灵魂。我建议这样设计排班:管理员为医生初始化一周的排班模板,比如“周二上午限号30个、周四下午限号20个”,系统为每一天生成对应的schedule记录。患者的挂号流程,本质上是去选择一条schedule记录,然后检查remain_count > 0,扣减它,生成appointment记录。

这里有三个设计细节你要想清楚:

  1. 时段切分:不用做太细。把一天切成上午、下午两个时间段就够了,硬要切成9:00、9:30这种粒度,会让排班配置界面复杂到没法演示。
  2. 号源数量的默认规则:可以设定普通医生每天上午30个、下午20个,专家号自动减半,具体数值可配置,不要在代码里写死。
  3. 停诊逻辑:停诊不是删除排班,而是把schedule的status置为“已停诊”。同时要把已经预约的患者状态批量设置为“已取消”,并记录日志。这个操作在后台管理里要做得显然,答辩时这是个加分点。

3.3 建表的几个硬性要求

必须强调一下,MyBatis-Plus要求实体类和表字段有映射关系,所以表结构设计决定了你写实体类能有多省事。我的建议:

  • 主键统一用id并配置自增
  • 所有表必须有create_time和update_time,用MyBatis-Plus的自动填充
  • 逻辑删除字段deleted,默认0,别物理删除数据,答辩时可以讲“数据可追溯”
  • 建议加上versionint字段,乐观锁防超卖要用,后面细说

另外,表字段命名不要用驼峰,统一用下划线。MyBatis-Plus默认开启驼峰自动映射,但你建表时规范一点,后面写SQL心里舒服很多。

4. 核心模块实现与踩坑记录:串起整个业务流程的代码骨架

4.1 预约下单核心逻辑:事务、锁与防超卖

这是整个系统含金量最高的地方,也是答辩必问点。先看问题场景:一个医生上午的号还剩最后1个,患者A和患者B同时点了预约,如果两个请求都先查remain_count = 1,发现大于0,然后各自执行remain_count - 1,那就会出现超卖——两个人都预约成功,数据库里剩余号数变成-1。

解决思路我从简单到复杂给你排三个方案:

方案一:乐观锁(推荐)

在schedule表加version字段,更新号源采用UPDATE语句带上版本号作为条件,通过影响行数判断是否更新成功:

@Update("UPDATE schedule SET remain_count = remain_count - 1, version = version + 1 " + "WHERE id = #{scheduleId} AND remain_count > 0 AND version = #{version}") int deductStock(@Param("scheduleId") Long scheduleId, @Param("version") Integer version);

Service层的预约逻辑写法是:

@Override @Transactional(rollbackFor = Exception.class) public Result bookAppointment(AppointmentDTO dto) { // 1. 查排班 Schedule schedule = scheduleMapper.selectById(dto.getScheduleId()); if (schedule == null || schedule.getStatus() != 1) { return Result.error("排班不存在或已停诊"); } if (schedule.getRemainCount() <= 0) { return Result.error("该时段已约满"); } // 2. 尝试扣减号源 int rows = scheduleMapper.deductStock(schedule.getId(), schedule.getVersion()); if (rows == 0) { return Result.error("手慢了,号源已被抢完"); } // 3. 生成预约记录 Appointment appointment = new Appointment(); appointment.setScheduleId(schedule.getId()); // ... 其他字段赋值 appointmentMapper.insert(appointment); return Result.success("预约成功"); }

deductStock返回0说明当前版本被别的请求改掉了或者号源已经为0,此时事务回滚,不会生成预约记录。这就是最基本的乐观锁方案,结合WHERE remain_count > 0,双保险防超卖。

方案二:悲观锁

直接SELECT ... FOR UPDATE把待更新的排班记录锁住,再执行更新。这方案实现上更“无脑”稳妥,但性能最差。对这个系统来说,性能不是问题,如果你想展示不同方案的取舍,可以在答辩时提一句“单体应用且并发量有限,也可以考虑悲观锁,但过于频繁的加锁会影响数据库吞吐”。

方案三:Redis分布式锁

如果面试官问“如果部署多个实例怎么办”,答案就是分布式锁。但毕设项目做成单机Redis锁又引入额外中间件,没必要。我建议在文档里提一句即可,别真去集成。

这里有个血泪教训:扣号和生成订单必须放在同一个事务方法里。之前有学生把扣号写在Controller里、插订单写在Service里,结果扣号成功但订单插入失败,号没了但预约没生成,数据直接对不上。事务边界一定划在对业务一致性要求高的那一层,也就是Service层。

4.2 登录鉴权与用户信息传递:拦截器怎么配才不会出bug

登录这块我推荐用Session方案,但有一个细节经常被忽略:前端用axios请求,拦截器里必须放行两个路径:/api/user/login和/api/user/register。此外,前后端分离开发时会有CORS跨域,但打包到一起后不存在跨域问题。如果你开发时是分开跑,记得在Config里配置CorsFilter允许前端地址。

拦截器获取当前用户再简单不过:

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { HttpSession session = request.getSession(); if (session.getAttribute("userId") == null) { response.setStatus(401); return false; } return true; }

前端统一处理401响应码并跳转登录页。这里我踩过坑:忘记放行静态资源。打包后前端页面本身在static目录下,如果拦截器写的是/*这种粗粒度路径,会把/index.html和CSS、JS文件全拦住。配置拦截路径建议写成/api/**,只拦接口,静态资源零成本放行。

除了登录接口,用户查询自己的预约记录时也不应该把userId放在前端传参里,应该从Session里取。常见问题就是用户A通过修改请求参数里的userId看到了用户B的预约记录,这在答辩被面试官看到直接社死。统一从Session取userId,才是正确的权限校验方式。

4.3 高效开发:MyBatis-Plus的用法与查询优化

MyBatis-Plus用起来比手写XML舒服太多了。这个项目里高频操作基本就是:通过LambdaQueryWrapper条件查询预约记录、分页查询医生排班、插入预约订单,这些统统不需要写XML。

举个例子,查询某医生某天的预约列表:

LambdaQueryWrapper<Appointment> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Appointment::getDoctorId, doctorId) .eq(Appointment::getAppointDate, date) .eq(Appointment::getStatus, 0); List<Appointment> list = appointmentMapper.selectList(wrapper);

这种链式查询的可读性比写一长串XML好得多。如果你确实需要复杂的多表联查,比如首页列表要显示医生姓名和科室名,直接写一条@Select注解在Mapper方法上就行,别为了“规范”硬把所有SQL都塞到XML里。MyBatis-Plus还提供分页插件PaginationInnerInterceptor,配置一下就能用。

4.4 统一返回结果与全局异常处理:代码质量的隐藏加分项

新手项目最喜欢在Controller里到处返回Map或者直接返回实体类,状态码时有时无,前端判断逻辑写得痛不欲生。我建议定义Result<T>统一返回结构,包含code、message、data三个字段:

public class Result<T> { private Integer code; private String message; private T data; }

成功返回Result.success(data),失败返回Result.error("提示信息")。配合@RestControllerAdvice做全局异常处理,业务异常就通过自定义BusinessException抛出,统一由全局异常处理器捕获并包装成Result返回。参数校验用@Validated+@NotBlank等注解,在Controller层直接挡掉非法参数。这套东西做得越早,后面调试越省心,而且答辩时能讲的东西又多了一个点。

4.5 医生端与管理员端:排班管理和状态流转别做复杂

管理员端的界面是整个演示的重头戏,因为讲需求的时候都是从管理员开始演示的。排班管理我做了两个入口:一个是在医生列表里点“排班管理”,弹出该医生未来7天的排班情况,可以逐个停诊、放号、调整号源;另一个是新增排班,选择医生、日期、时段、总号数,一键插入。界面用Element UI的表格和时间选择器就行,不用做拖拽排班那种过度设计。

医生端更简单,登录后只能看到自己的排班和对应的预约患者列表。预约患者列表要加一个状态筛选:待就诊、已完成、已取消,每个待就诊患者可以一键标记为“已完成”,这样患者端的“我的预约”里才能看到状态从“待就诊”变成“已完成”。完整的状态流转线,比你做十个界面都更能体现业务思考。

5. 常见问题排查与答辩准备:那些热词背后的真实场景

5.1 环境与部署常见坑

SpringBoot 版本太高:如果用了3.x但本机是JDK8,直接换2.7.x。别犹豫,时间性价比最重要。 端口被占用:server.port配置生效了吗?是不是有两个项目同时在跑? 前端请求404:先看请求路径是 /api/xxx 还是直接 /xxx,后端Mapping是否匹配。 返回到前端的中文乱码:检查MySQL连接串是否配置了 characterEncoding=utf8。 刷新页面404:Vue路由是不是history模式?切换成hash模式。 vue打包放进SpringBoot:把dist目录内容拷到resources/static,不是拷整个dist文件夹。

5.2 测试数据与演示脚本:答辩能否顺利,全靠提前排练

我见过不少同学功能全做出来了,一上台从注册用户开始现弄,折腾五分钟页面还没跑通。演示前一定要准备一套固定的演示脚本和现成的测试数据。至少要有:一个管理员账号、一个医生账号(关联好科室和排班)、一个患者账号(已有几条预约记录)、未来7天内有排班的医生数据。这样可以避免临时创建、等待验证码、排班主任没配好等意外。

演示时的pass卡:登录管理员 → 展示科室统计 → 展示医生排班配置 → 切换患者登录 → 找科室找医生 → 完成一次预约 → 个人中心看到预约记录 → 切医生端看到这位患者。这条主线走完,系统90%的功能都展示到位了。

5.3 答辩必问题与回答思路

这里整理几个高频问题,也是网上那些java面试题里常见变体:

问:为什么选择SpringBoot而不是SSH或Servlet?答:SpringBoot简化了配置,内嵌Tomcat,自动装配,适合快速构建独立运行的Web服务;底层仍是Spring的IOC和AOP,核心思想没有变。

问:预约操作如果两个人同时访问,怎么保证不超卖?答:数据库乐观锁。更新时带上version条件,配合remain_count > 0的校验,影响行数为0则说明已被其他请求抢先,抛异常回滚事务。然后可以补充一句:如果未来并发量上来,可以引入Redis分布式锁或用消息队列削峰。

问:为什么医生和用户放在同一个表?答:统一认证体系,用role字段区分角色,方便登录逻辑一致处理。扩展时如果有不同字段需求,再用单独的子表扩展。这比分开建表省掉很多重复的登录注册逻辑。

问:如果同一患者重复预约同一医生同一天,你怎么处理?答:在appointment表上加唯一索引,字段组合是user_id + schedule_id,这样数据库层面就挡住了重复预约。代码层面也可以用查询校验做前置判断,但唯一索引是最终防线。

这类问题没有标准答案,但你的回答要让人感觉到你是真的做过、想过边界条件,而不是照着博客背的。

5.4 关于爽约、退号与停诊的简单处理建议

毕设不需要把真实医院的规则完完整整做出来,但至少要体现“业务状态流转”的思考。我的建议是做这几个状态动作:

  • 患者取消预约:appointment状态置为“已取消”,同步把schedule的remain_count + 1加回来。注意这里也要用乐观锁,不过是个方向上的更新(增加库存),冲突风险更低。
  • 管理员停诊:把schedule状态置为“停诊”,同时把所有关联预约批量置为“已取消”,并生成日志。手册里写“停诊后自动通知患者”(如果没做短信,可以写“预留扩展接口”)。
  • 爽约判定:系统不做定时任务自动判定,而是在医生端“标记完成”时做一个判断:如果预约时间已过且未就诊,自动置为“爽约”。

这些规则不需要写得多复杂,但逻辑链条要在README文档里写清楚,答辩时比背概念有用一百倍。

5.5 项目文档与README怎么写

很多毕设评分里,文档占比甚至高于代码。README至少要有这些内容:项目背景与需求分析(就是第一章的内容)、技术选型说明及理由、数据库ER图及表结构说明、核心接口文档(每个接口的URL、参数、返回示例)、核心实现思路说明(包括防超卖方案、角色权限设计)、部署运行说明(如何初始化数据库、如何启动后端、如何构建前端)、测试计划与演示脚本。写文档有个技巧:核心模块的实现思路放在最前面,因为老师看文档的时间往往不超过十分钟,他能最快看到你项目最有含金量的地方。

我个人在实际带项目过程中的体会是,这个项目的难度曲线不是匀速的,排班模型的建立和预约防超卖的实现占据了整个项目接近一半的时间,也是最值得投入的部分。很多学生容易把时间耗在调页面样式、调Element UI组件上,等到了核心业务逻辑反而没时间写了,这是本末倒置。如果后面你还想往深了扩展,可以考虑加一个简单的定时任务,每天凌晨自动把超时的预约改成爽约状态;或者引入一个轻量级的消息队列,把预约成功后的通知异步处理;再或者把权限体系换成Spring Security + JWT,往真实企业级架构再靠近一步。但这些都是后话,先把这条主线跑通,你已经比大多数做同样题目的人强了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询