
简介医院挂号预约系统源码包是一套基于SpringBoot与Vue构建的现代化医疗服务平台实例面向医疗信息化开发者与Java/Vue初学者覆盖患者在线挂号、医生排班、科室查询、预约取消、费用支付、报告查询及消息推送等全流程旨在减少就医排队、提升医疗资源利用效率。包内共1076个文件压缩后约24.35MB核心类型包括Java后端服务、Vue组件页面、JavaScript逻辑、JSON配置、PNG/SVG界面素材以及微信小程序相关的wxml/wxss文件说明源码同时覆盖Web端与移动端入口目录结构完整便于按模块检索与复用。目前已有49人学习下载。源码包还提供install/run/build批处理脚本可快速完成安装、运行与构建多个样式备份与IDE工程配置文件则便于对照学习和迁移开发可直接运行或作为二次开发基础尤其适合学习SpringBootVue前后端整合、小程序联调及医疗业务建模的读者。系统内置数据分析与可视化能力可帮助医生识别就诊高峰时段、管理者优化排班计划进一步提升医疗服务价值。1. 这个压缩包解决的是就医流程里最痛的一段链路早上 8 点放号候诊大厅排成长队窗口挂号员一边点鼠标一边被催系统偶尔还会卡到白屏——这个场景对做过医疗信息化的人来说并不陌生。标题里的“医院挂号预约系统”是一个典型的 SpringBoot Vue 全栈项目把患者在线挂号、医生排班管理、科室查询、预约取消、费用支付、报告查询和消息推送串成了闭环。它要回答的核心问题是怎么让号源分配从“窗口抢”变成“在线约”让医院管理员看得清号源和订单状态同时真正减少现场排队。适合两类人一类是中小医院或诊所的信息科想用最短路径把预约流程数字化另一类是正在做课设或外包项目、需要一份可扩展参考实现的开发者。下面按“模块边界 → 本地跑通 → 核心流程参数 → 踩坑记录 → 最后一公里优化”的顺序拆透。2. 先看清SpringBootVue的服务端与前端边界模块拆分与数据流2.1 为什么挂号系统卡在“并发集中”而不是功能多少在线挂号系统表面上功能很多科室查询、排班列表、预约、支付、报告查询、消息推送但真正决定系统能不能用的只有两个时刻放号那一刻比如每天 8:00和支付回调那一刻。放号时几十个患者同时提交预约如果后端只是简单update余号字段就会出现超卖支付回调时如果不对幂等做处理订单状态就会在“已支付”和“已取消”之间来回跳。所以模块拆分的第一原则不是按页面拆而是按“有状态资源”拆。医生排班是号源池预约订单是状态机支付记录是对账依据这三块必须独立成模块才能把并发控制和状态流转的代码集中在同一批类里避免散落在各个 Controller。反过来科室查询、报告列表这类只读业务可以适当放松把它们放进同一个查询模块用缓存扛住读取压力。这套系统也不要一上来就拆微服务。单体内聚模块足够支撑几百个并发预约拆成微服务后预约服务和支付服务之间的同步调用会引入分布式事务复杂度翻倍。压缩包里的方案应该以 Spring Boot 单体为主模块边界通过 Maven module 或 packages 目录划分团队协作时只要约定好 Service 层接口不互相侵入对方的 Mapper 即可。2.2 后端模块边界从患者端到管理端的七个服务用这个标题来搭建后端时我一般会把 Spring Boot 工程拆成下面七个模块既贴合业务又方便把权限和事务边界划清楚模块核心实体主要接口说明用户认证user, patient登录、注册、JWT刷新患者和管理员共用一套账号体系用 role 区分权限科室管理department科室列表、分页查询静态数据缓存到 Redis医生排班doctor_schedule按日期查排班、生成号源号源池核心库存扣减在这里预约订单appointment_order提交预约、取消、订单状态查询状态机所在模块支付服务payment_record发起支付、回调处理、退款对接支付平台的壳支持模拟回调报告查询patient_report报告列表、PDF 预览与检验检查系统对接的预留接口消息推送message站内信、WebSocket/SSE 推送推送预约结果、报告提醒模块之间通过 Service 调用不在多个 Controller 里重复写订单状态判断逻辑。这样做有一个直接好处当医院现有系统要接入时只需要替换“医生排班”模块的数据来源其他模块基本不动。比如某医院使用旧 HIS 的排班表格我可以在scheduleService里加一个 Adapter把 HIS 的排班数据转换成doctor_schedule记录患者端无感知。数据库层面还要留意“患者主数据”。同样一个患者可能在预约挂号表里叫patient在报告表里叫patient_info两边的手机号不一致就会出现“挂了号却查不到报告”的问题。所以我在设计时会让所有业务表都引用patient_id患者姓名和手机号只在user/patient主表里维护业务表只存冗余的patient_name快照避免每次关联查询。2.3 前端路由与权限Vue侧不是单纯页面堆砌Vue 侧如果只按页面堆组件后面一定会栽在权限控制上。常见做法是路由表分成三份公共路由登录页、科室查询、患者路由预约、支付、报告、我的预约、管理路由排班管理、号源监控、订单管理。在路由守卫里读本地存的 JWT 和 role用户没有对应权限就重定向到 403 页。另一个容易被忽略的是 Axios 请求拦截器要在请求头带上Authorization: Bearer token在响应拦截器里统一处理 401 过期跳转。和后端接口约定保持一致我一般会让后端返回统一的ResultT包装体结构包含 code、message、data前端在响应拦截器里只认这个结构。这样“科室查询”“报告查询”这类只读接口和“提交预约”“支付”这类写接口可以共用同一套错误处理逻辑不需要每个页面重复弹提示。前端开发期的跨域问题最好在 Vite 配置里解决而不是每次后端都去允许跨域来源。vite.config.js里配一个 proxy/api代理到http://localhost:8080这样浏览器看到的是同源请求后端也不需要在 CORS 配置里暴露所有来源。上线后由 Nginx 做反向代理前端构建产物放在dist后端接口走/api一套规则贯穿开发和生产。组件库我建议用 Element Plus 这类成套组件表格、表单、日期选择器直接用现成的把精力留给排班日历这种定制组件。排班日历的难点在于“日期 上下午 号源余量”三层结构纯 Element 的el-calendar需要二次封装我会用v-model绑定选中日期再用插槽渲染每个日期的号源摘要点击日期后拉取doctor_schedule列表。3. 把系统跑起来的最小路径初始化、建库、联调3.1 环境清单与版本选择JDK、MySQL、Node 的匹配边界本地跑这套方案环境版本不需要追新但要注意匹配边界。我建议的搭配是 JDK 8 或 11、Spring Boot 2.7.x、MySQL 8.0、Redis 6.x可选、Node 16 或 18。JDK 17 对 Spring Boot 2.7 也能跑但部分旧版 Lombok 会有兼容问题所以别盲目上新。前端用 Vue 3 ViteNode 版本高于 16 即可低于 14 会直接报依赖版本错误。如果是为了快速验证MySQL 和 Redis 可以用 Docker 起但要注意容器内的 MySQL 默认case_sensitive_table_names0在 Linux 下表名大小写不敏感Windows 上则敏感这是“表找不到”一类问题的源头。踩过一次坑后我建表时统一小写表名字段也统一小写下划线避免操作系统差异带来的玄学问题。Redis 在这套系统里承担两个职责一是缓存科室、医生这类读多写少的数据二是做预约锁和支付回调锁。如果本地不想装 Redis可以在配置里把缓存降级为 Caffeine但预约锁必须要有独立实现不能直接省掉。否则第 5 章的并发问题会原样复现。3.2 数据库初始化表结构里的三个关键设计解压后先找sql/init.sql或类似初始化脚本。下面三张表决定了预约流程能不能跑通。第一张是doctor_schedule字段中包含doctor_id、work_date、start_time、end_time、total_slots、remain_slots、version。其中remain_slots是余号version是乐观锁版本号。第二张是appointment_order状态字段用status0 待支付、1 已支付、2 已就诊、3 已取消、4 已过期。第三张是payment_record用payment_no和order_no两个唯一键来保证回调幂等。CREATE TABLE doctor_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, work_date DATE NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, total_slots INT NOT NULL DEFAULT 1, remain_slots INT NOT NULL DEFAULT 1, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_doctor_time (doctor_id, work_date, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE appointment_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, patient_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0, pay_amount DECIMAL(10,2) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_patient_status (patient_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明doctor_schedule用(doctor_id, work_date, start_time)唯一键防止同一医生同一时间段生成两条排班appointment_order的order_no用唯一键是为了在重复提交订单时让数据库层兜底。remain_slots没有直接用total_slots - 预约数计算而是单独维护方便做UPDATE ... SET remain_slots remain_slots - 1 WHERE remain_slots 0的原子扣减。这是防超卖的核心第 5 章会展开说。消息推送表我也建议一起建上。message表至少包含id、patient_id、message_type、content、is_read、created_at。无论前端用 WebSocket 还是 SSE业务消息都要先落库前端断线重连后靠拉取接口补齐未读消息这是“实时推送 拉取补偿”双通道的基础。3.3 启动后端与前端两个命令和一个联调开关后端是标准的 Maven 工程第一次启动先把依赖拉全# 后端启动 mvn clean install -DskipTests mvn spring-boot:run -Dspring-boot.run.profilesdev参数说明-DskipTests跳过测试是为了本地快速验证如果你改了业务代码建议把单测跑一遍-Dspring-boot.run.profilesdev表示加载application-dev.yml里面配置数据库连接和 Redis 地址。实际执行时如果端口被占用会看到Port 8080 was already in use这时要么改server.port要么把占用端口的进程杀掉。前端是 Vite 工程# 前端启动 npm install npm run dev启动完默认起在 5173 端口。此时浏览器访问http://localhost:5173如果登录接口返回跨域错误优先在vite.config.js里配置 proxy 解决而不是改后端 CORS。联调时最容易翻车的是接口前缀不一致后端 Controller 类上写RequestMapping(/api/patient)前端 axios 的 baseURL 却写成/api请求路径变成/api/api/patient。我统一约定axios baseURL 不写路径只在具体请求里写/api/...配合 proxy 不容易出歧义。如果登录页能加载但点登录后 5 秒没响应先按这个顺序排查看浏览器 F12 的 Network接口返回 401 说明 JWT 没签发或 token 没带上返回 500 就翻后端日志九成是表名或字段名对不上。这里给一个本地验证的完整路径先注册一个患者账号用该账号登录拿 token调科室查询接口选一个科室查医生排班再对一个可预约时段提交预约。把这条链路跑通说明前后端联调没有结构性问题剩下的只是业务参数调优。4. 核心流程的参数设计排班、预约、支付怎么才算可用4.1 医生排班号源池与时间段滑动的实现思路排班模块是整套系统的“库存中心”。常见做法不是让医生手动添加 50 个号而是只维护一个时间段由代码生成号源池。比如某个医生上午 8:00-12:00每个号源 30 分钟总号数就是 8 个每个号源默认total_slots1。如果医院希望一个时段覆盖更多患者把slotMinutes调小或者让total_slots放大比如普通门诊每 10 分钟一个号上午 4 小时就是 24 个号。public void generateSchedule(DoctorScheduleRequest req) { LocalTime cursor req.getStartTime(); while (cursor.isBefore(req.getEndTime())) { DoctorSchedule schedule new DoctorSchedule(); schedule.setDoctorId(req.getDoctorId()); schedule.setWorkDate(req.getWorkDate()); schedule.setStartTime(cursor); schedule.setEndTime(cursor.plusMinutes(req.getSlotMinutes())); schedule.setTotalSlots(req.getSlotsPerPeriod()); schedule.setRemainSlots(req.getSlotsPerPeriod()); scheduleService.save(schedule); cursor cursor.plusMinutes(req.getSlotMinutes()); } }逻辑说明cursor从开诊时间开始每步加slotMinutes到endTime之前停止endTime本身不作为开诊时间否则 12:00 会被多生成一个号源。参数设置上getSlotMinutes一般填 10、15 或 30getSlotsPerPeriod默认 1即一个时间段一个号。生成之前要先删除该医生在work_date当天已有的排班否则唯一键会报错报错信息容易让人误以为是数据库异常。排班发布后还会遇到“医生停诊”的场景。最简单的处理是给排班表加一个status字段0 表示正常1 表示停诊。患者端查询排班时只展示status0的记录已预约该医生的患者在停诊操作里批量发送站内信并引导改约。这里要注意停诊只释放尚未预约的号源已预约订单要单独走“改约”流程不能直接取消否则就变成强制退号了。4.2 预约与取消状态机、并发锁与超时释放预约接口是整个系统并发压力最大的地方。我推荐两层控制第一层是 Redis 分布式锁锁的 key 是schedule:lock:{scheduleId}防止同一号源段被并发扣减第二层是数据库乐观锁UPDATE doctor_schedule SET remain_slots remain_slots - 1, version version 1 WHERE id ? AND remain_slots 0。两层都要有因为只用 Redis锁过期或 Redis 宕机时会超卖只用数据库乐观锁高并发下大量请求打到数据库日志里全是更新失败重试。public AppointmentOrder createOrder(CreateOrderRequest req) { String lockKey schedule:lock: req.getScheduleId(); boolean locked redisLock.tryLock(lockKey, 3, TimeUnit.SECONDS); if (!locked) { throw new BizException(当前预约人数较多请稍后重试); } try { int updated scheduleMapper.deductSlot(req.getScheduleId()); if (updated 0) { throw new BizException(号源已约满); } AppointmentOrder order new AppointmentOrder(); order.setOrderNo(generateOrderNo()); order.setPatientId(req.getPatientId()); order.setScheduleId(req.getScheduleId()); order.setStatus(0); order.setPayAmount(req.getAmount()); orderMapper.insert(order); return order; } finally { redisLock.unlock(lockKey); } }参数说明tryLock的第一个参数 3 秒是获取锁的等待时间不是锁自动过期时间第三个参数才是锁的持有时间。如果业务执行时间超过锁持有时间锁会被自动释放导致并发问题所以锁内代码不要包含外部 HTTP 调用只做数据库操作。deductSlot是 Mapper 里带条件更新的 SQL返回影响记录数0 表示没扣到号源直接抛“号源已约满”。订单状态设为 0 表示待支付支付完成后改成 1。预约订单的状态机可以概括为一张迁移表当前状态允许动作目标状态说明待支付支付成功已支付支付回调触发待支付取消 / 超时已取消 / 已过期释放号源已支付取消限时段已取消触发退款已支付签到就诊已就诊就诊台登记已取消 / 已过期无终态不可再修改取消预约的规则要明确已就诊不能取消已支付状态取消要触发退款流程待支付状态取消要立刻释放号源。超时释放建议用定时任务扫描“创建超过 30 分钟仍为待支付”的订单把状态改成 4 并把remain_slots加回。注意修改订单状态和释放号源必须在同一个事务里先改订单再回补号源否则会出现订单已取消但号源没加的半截状态。4.3 费用支付回调幂等与对账字段支付模块不需要真的对接支付机构也能验证流程但回调处理代码一定要按真实场景写。患者在前端点支付后后端先创建payment_record状态为 0再跳转支付页面。模拟支付时直接调用“支付成功回调”接口即可。回调里有两个必须的幂等设计payment_no唯一索引防止同一笔支付重复入账支付金额与订单金额不一致时直接拒绝。Transactional public void handlePayCallback(PayCallbackReq req) { PaymentRecord record paymentMapper.selectByPaymentNo(req.getPaymentNo()); if (record null || !SUCCESS.equals(req.getPayStatus())) { return; } if (record.getStatus() ! 0) { return; // 已处理过直接返回 } AppointmentOrder order orderMapper.selectForUpdate(req.getOrderNo()); if (order null || order.getPayAmount().compareTo(record.getAmount()) ! 0) { return; } record.setStatus(1); paymentMapper.updateById(record); order.setStatus(1); orderMapper.updateById(order); messageService.pushPaySuccess(order); }逻辑说明selectByPaymentNo先查本地支付记录没有就忽略record.getStatus() ! 0表示这条支付流水已经处理过直接幂等返回。selectForUpdate是数据库行锁防止两个回调线程同时把同一订单从待支付改成已支付。金额比较用compareTo而不用equals因为BigDecimal.equals会把1.0和1.00判为不相等这是支付回调里最典型的细节坑。对账字段建议在payment_record里加channel、pay_time、callback_data。callback_data存原始回调 JSON排查问题时直接看原始报文不用再翻三方日志。如果要支持退款再加refund_amount和refund_status避免多次退款超过订单金额。这里强烈提醒消息推送不要放在handlePayCallback事务里推送失败会导致支付状态回滚把推送改成事务提交后的异步事件或者用ApplicationEvent解耦。5. 落地避坑预约并发、时间边界与支付回调的常见问题5.1 余号扣减被超卖为什么乐观锁也防不住现象放号瞬间同一号源段有 10 个患者同时提交最后只有 8 个成功但日志里一条更新失败记录都没有更严重的是remain_slots已经变成 0后端预约接口还返回成功。原因最常见的是deductSlot的 SQL 没带remain_slots 0条件直接写成UPDATE doctor_schedule SET remain_slots remain_slots - 1, version version 1 WHERE id ?。这样即使余号为 0 也能更新成功从 0 扣到 -1。另一个原因是前端先调“查询余号”接口后端把余号缓存一段时间再返回页面显示有号但实际已经被抢完。查询接口和扣减接口之间没有做关联余号只是个展示数据。解决扣减 SQL 必须写成WHERE id ? AND remain_slots 0并且用updated影响行数判断是否扣号成功。查询余号接口只做展示不要拿它的结果做库存判断。如果用了 Redis 预扣库存要在事务提交后才把缓存余号减掉否则事务回滚后缓存和数据库不一致。我自己习惯的做法是数据库字段remain_slots是唯一权威Redis 只用于展示和锁不做预减。5.2 患者取消预约后号源没回到池子现象患者在“我的预约”里取消一个已支付订单退款也成功了但第二天这个时间段依然约不了remain_slots没有增加。原因取消的 Service 方法里更新订单状态和释放号源不在同一个事务里。常见写法是先orderMapper.updateById再scheduleMapper.releaseSlot释放号源时抛了异常订单状态已提交号源却被回滚。另一种情况是取消时没校验订单状态把“已就诊”的订单也当成可取消对象号源释放逻辑走了但实际医疗行为已经发生号源回收后会造成重复约。解决在取消方法上加Transactional(rollbackFor Exception.class)并且先执行号源释放再更新订单状态。释放号源的 SQL 要带remain_slots total_slots条件防止重复取消时余号被放大。另外角色权限要分开患者只能取消自己的订单导诊台可以取消指定订单但取消理由要记录到cancel_reason字段方便后续统计爽约率。5.3 支付回调重复通知导致订单状态漂移现象支付平台回调重试了 3 次第一次成功把订单改为已支付第二次又把状态改成已取消患者端显示订单被取消钱却已经扣了。原因回调处理逻辑里没有幂等判断。第一次回调把状态从 0 改成 1第二次回调进来时没判断当前状态已经是 1而是直接走了取消分支。更隐蔽的是“查询订单”和“更新订单”写了两个事务第二次回调读到的是旧状态然后拿着旧状态去更新。解决回调处理必须遵循“先查支付流水状态再决定是否处理”的幂等模式payment_record.status非 0 一律直接返回。如果要对账可以在回调入口加 Redis 锁pay:callback:{orderNo}过期时间设 30 秒确保同一订单的回调串行执行。不要在同一事务里做“支付状态更新 消息推送”推送失败会导致支付状态回滚推送应该发到 MQ 或 Spring 事件里异步消费。5.4 跨天排班用字符串存时间导致的查询错乱现象查询某医生一周排班时周五早上的排班出现在周四的数据里导出报表时“上午”“下午”排序错乱。原因设计表时图省事把start_time和end_time设成 VARCHAR存8:00、08:30。MySQL 按字典序排序8:00会排在12:00后面而且8:00和08:00是两个值前端传的格式一旦不是后端存储格式就查不到记录。跨天排班还会出现end_time是次日凌晨 1:00直接用 TIME 类型也表达不了“次日”语义。解决后端实体类用LocalTime数据库用TIMEJDBC 驱动自动映射避免字符串比较。跨天排班要单独加end_date字段或者把排班拆成两段当天到 23:59次日 0:00 到 1:00 单独一条记录。这样查询“某天排班”直接用work_date ?不用做跨天拼接。时间相关的条件查询也统一用LocalDate入参避免前端传字符串导致时区偏差。5.5 消息推送连不上WebSocket被网关断掉现象患者把手机锁屏一段时间后再打开报告提醒迟迟不出现刷新后才能看到部署在 Nginx 反代后面WebSocket 时不时断开重连。原因前端用的 WebSocket 连接在移动端空闲时被系统回收后端没有做断线重连和离线消息补偿Nginx 默认proxy_read_timeout60 秒WebSocket 超过 60 秒没有数据交互就被切断前端又没有自动重连机制消息自然推不过来。解决推送方案建议优先使用 SSEServer-Sent Events底层是 HTTPNginx 不用配置升级协议断线后前端用EventSource自动重连。如果坚持 WebSocketNginx 的proxy_read_timeout和proxy_send_timeout要调到 3600 秒前端监听close事件做指数退避重连。关键业务消息预约成功、支付成功、报告已出不能只靠实时推送必须同时落message表前端拉取未读消息走普通 HTTP 接口“实时推送 拉取补偿”才是可靠组合。6. 最后一公里报告查询与消息推送的优化技巧报告查询最影响体验的不是接口数量而是“出报告那一刻的确定性”。患者关心的是“报告出了没有”而不是“报告查询接口好不好看”。我会在预约订单旁边放一个report_ready状态由后台的模拟 LIS 接口在生成报告后回调更新患者端在“我的预约”里就能看到“报告已出可查看”的角标而不是非得进报告页才能发现。具体实现上报告 PDF 用接口直接返回二进制流前端用 Blob 接收并交给预览组件不要为了省事把 PDF 转成 Base64 塞进 JSON否则 3MB 的报告会让首屏直接卡死移动端内存也被吃满。下载报告时在响应头里带Content-Disposition: attachment; filename*UTF-8report.pdf中文文件名就能正常显示避免浏览器把空格解析成号。消息推送的另一个实用技巧是“合并未读提醒”。一个患者上午可能收到 3 条推送预约成功、支付成功、排队提醒如果三条都实时推通知栏会被刷屏。我的做法是类型相同的消息合并为一条聚合通知点击后进入消息列表看明细未读数放在右上角角标由拉取接口返回而不是由每一条推送去修改角标。这样既保留实时性又不会让用户觉得系统在轰炸。最后说一个自己踩过的教训早期这套平台把支付回调和消息推送放在同一个事务里回调一到推送服务没起整个事务回滚患者钱扣了订单却停在待支付。后来才把推送改成事务提交后的异步事件。如果你在做类似系统记住一条原则和钱相关的状态变更要实时同步和通知相关的动作全部异步化。希望帮到你。本文还有配套的精品资源点击获取