儿童医院分时段挂号系统的设计与实现复盘:从需求到SpringBoot+Vue落地
做医疗类管理系统这些年,儿科门诊的挂号一直是个特殊的存在。"排队两小时、看病五分钟"在儿科场景里被无限放大——孩子生病往往来得急,家长焦虑程度高,现场扎堆挂号的混乱程度远超普通科室。去年接了一个儿童医院的分时段预约挂号选号系统项目,技术栈就是标题里那套经典的java + vue + springboot,一路从需求梳理、表结构设计扛到前后端联调和并发压测,踩了不少坑,也沉淀了不少经验。今天把这套系统的设计方案和核心实现细节完整复盘一遍,给正在做同类毕设或医疗管理项目的朋友一个可直接参考的路线。
这个项目能做的事很明确:家长在小程序或网页端按科室、按医生查看可预约时间段,手动挑选具体号源(选号),确认后锁定号源并生成预约记录,医院端进行排班管理和号源池配置。核心难点不在CRUD,而在"分时段号源如何生成""多人同时抢一个号时如何防止超卖""患者的预约状态如何在不同环节间正确流转"。下面从业务设计到代码实现逐层拆解。
1. 儿童医院预约场景里,"分时段+选号"究竟解决了什么问题
1.1 儿科门诊区别于综合门诊的三个业务特征
做儿童医院预约系统,不能简单套用成人门诊的挂号逻辑。儿科的典型特征是:
- 病情急且变化快:发热、惊厥、异物吸入这类急症占比高,家长没有耐心等待,对号源实时性要求极高。
- 陪同家属多:一个患儿往往两三个大人陪同,现场聚集效应明显,分时段能有效疏散人流。
- 科室细分化:儿科通常细分新生儿科、呼吸科、消化科、内分泌科、儿保科等,每个科室的号源策略不同(比如儿保科预约周期可以放长到两周,呼吸科只放三天)。
"分时段挂号选号系统"的业务本质,是把医生一天的出诊时间切成固定粒度的号源片段(比如每15分钟一个号段),每个号段对应一个独立的号源,患者需要先看时段、再做选择。这和传统"上午/下午各放50个号"的最大区别在于:用户的选择粒度从"半天"细化到"具体几点几分",医院可以精确预测每个时间段的到院人数。
1.2 "选号"环节的交互设计如何降低爽约率
很多同类系统只做到了"选时间",没做到"选号". 本项目实践下来,真正的选号要做得像个小型选座系统——用户看到的不是一个笼统的时间区间,而是一张号源表,每个格子代表一个具体号位:
- 绿色格子:可预约
- 灰色格子:已被锁定或已预约
- 红色格子:该时段医生停诊
这种设计的好处是,家长在预约时已经对"几点到、大概排在第几个"有了心理预期,到了时间点会主动按时到院。后台数据也验证了这一点:启用选号界面后,该院的爽约率从改造前的约18%降到了约9%。
1.3 医院端管理诉求:排班灵活才能落地
系统不能只有患者端花哨,医生排班管理跟不上,再好的前端也白搭。这部分的业务要点有三个:
- 排班模板化:主任医师每周三上午出诊,生成排班时可以直接套用周模板,不必每天重复操作。
- 号源数量可配置:每个号段的号源数可以按医生级别设置。比如普通儿科医生每个号段放2个号(初诊+复诊各1),专家门诊每个号段只放1个号。
- 临时停诊处理:医生临时停诊时,系统要自动释放该医生所有已锁定但未支付的号源,并给已预约患者发送通知。
这套需求梳理清楚后,技术侧的整体架构就很好定了。
2. 技术栈选型:为什么是 SpringBoot 3 + Vue 3,以及版本选择的实操经验
2.1 后端框架选择的实际考量
项目采用前后端分离架构,后端用SpringBoot,前端用Vue。这个选择在今天几乎是医疗管理系统的默认组合,理由也很直接:
- SpringBoot的自动配置机制让项目初始化成本极低,内置Tomcat打包成jar后一键部署,非常适合中小型医院信息科的运维能力。
- Vue的组件化开发模式适合这种页面多、交互细节多(号源表格、排班日历、预约时间轴)的管理系统。
版本选择上,我用了SpringBoot 3.2.x搭配JDK 17。这里有个实操提醒:SpringBoot 3.x 相比 2.x 是重大版本升级,底层基于 Jakarta EE 9+,很多老教程里的javax.servlet包名要改成jakarta.servlet。如果硬要沿用网上教程的javax写法,项目一启动就报ClassNotFoundException。做毕设或企业项目时,如果团队对SpringBoot 3不熟,保守选SpringBoot 2.7.x + JDK 8也完全够用,不必盲目追新。热搜词里"springboot版本太高"这个痛点,十有八九就出在教材版本跟不上、依赖坐标对不上的问题上。
2.2 前端工程化的基础准备
前端用 Vue 3 + Vite + Element Plus。Vite 比 Vue 2 时代的 Webpack 启动速度快一个量级,开发体验好很多。如果是新手从零搭环境,记得先装 Node.js 16+(建议直接用18 LTS),然后:
npm create vite@latest child-hospital-web -- --template vue cd child-hospital-web npm install npm install element-plus axios vue-router pinia npm run dev这里最容易踩的坑是Node 版本与依赖版本不匹配。比如 Node 14 装最新版 Vite 5 会直接报requires Node ^18.0.0。我的建议是装 NVM 做版本管理,锁定 Node 18,项目里加.nvmrc文件写清版本号,团队成员拉代码后nvm use即可。
前端路由设计上,患者端和管理端分开。患者端是主要的预约操作入口,包含:首页选科室、医生排班列表、号源选择页、我的预约;管理端包含:排班管理、号源监控、预约记录、停诊通知。这里用vue-router的懒加载模式做路由分割,按需加载页面组件:
const routes = [ { path: '/', component: () => import('@/views/patient/DepartmentList.vue') }, { path: '/schedule', component: () => import('@/views/patient/DoctorSchedule.vue') }, { path: '/select-number', component: () => import('@/views/patient/NumberSourceSelect.vue') } ]跑通这套前后端分离骨架后,才进入真正的业务核心——数据库设计。
3. 数据库建模:号源表、排班表与状态机的设计取舍
3.1 核心表的拆解思路
预约系统的核心表有五张:医生表、排班表、号源表、预约订单表、患者表。以排班表为核心向外辐射:
| 表名 | 核心字段 | 作用 |
|---|---|---|
doctor | id, name, department_id, title, is_expert | 医生基础信息 |
schedule | id, doctor_id, work_date, start_time, end_time, slot_duration, total_slots, remaining_slots, status | 某医生某天的出诊计划和时段配置 |
number_source | id, schedule_id, slot_seq, slot_start, slot_end, number_no, status, lock_expire_time | 每个具体号位的状态 |
appointment_order | id, order_no, patient_id, number_source_id, status, create_time, pay_status | 用户预约单 |
patient | id, name, id_card, phone, guardian_name, guardian_phone | 患儿信息 |
number_source表是整个系统最核心的一张表。"选号"最终选中的就是这张表里的一条记录。
3.2 为什么要把"排班"和"号源"拆成两张表
一开始如果图省事,完全可以把号源信息直接埋进排班表的一个JSON字段里,甚至只在内存中生成号源。但上线后你就知道这样不行:
- 排班的停诊、加号、改时间,需要级联影响每一个号源的状态。拆成两张表后,
schedule的status字段一变,SQL里批量更新number_source就行。 - 号源需要记录单个号位的锁定者、锁定时间,用于超时释放。这些状态必须落库,不能放内存(服务重启即丢)。
拆分后的一个典型查询是:查某排班下所有号源,前端用来渲染选号表格。
SELECT id, slot_seq, slot_start, slot_end, number_no, status FROM number_source WHERE schedule_id = #{scheduleId} ORDER BY slot_seq, number_no;3.3 状态机设计:避免业务判断散落在代码各处
number_source的status字段是整个系统状态流转的核心,设计了四个状态:
0-可预约:号源空闲,用户可锁定。1-已锁定:用户选定但未支付,系统保留15分钟,超时自动释放。2-已预约:用户已确认或已支付,号源占用。3-停诊:医生停诊导致号源不可用。
appointment_order的状态则包括:待支付、已确认、已完成、已取消、已爽约。
状态机的关键约束是:只有0-可预约的号源才能被锁定;只有订单处于待支付时,号源才是已锁定状态;支付回调或确认后,号源进入已预约。这种显式的状态机设计让团队里任何一个人接手代码,看着字段注释就能理清逻辑,不用翻遍每个 Service 方法。
实际上线后我们发现,儿科的"爽约"判断跟成人科室还不太一样——患儿可能因为病情好转直接不来了,但号源在就诊时段前是不能立即释放的。因此订单状态里专门保留了已爽约标记,方便统计分析儿童门诊的失约规律。
4. 核心逻辑实现:号源生成、锁定与并发防重
4.1 分时段号源的批量生成算法
排班创建成功后,系统需要根据start_time、end_time、slot_duration三个参数批量生成号源记录。生成逻辑不复杂,但要注意首末时间段的边界处理:
public void generateNumberSources(Schedule schedule) { LocalTime slotStart = schedule.getStartTime(); int seq = 1; while (slotStart.isBefore(schedule.getEndTime())) { LocalTime slotEnd = slotStart.plusMinutes(schedule.getSlotDuration()); // 每个时段内根据号源数量生成具体号位 for (int i = 1; i <= schedule.getSlotsPerPeriod(); i++) { NumberSource source = new NumberSource(); source.setScheduleId(schedule.getId()); source.setSlotSeq(seq); source.setSlotStart(slotStart); source.setSlotEnd(slotEnd); source.setNumberNo(i); source.setStatus(NumberSourceStatus.AVAILABLE); numberSourceMapper.insert(source); } slotStart = slotEnd; seq++; } }这里的slot_duration一般按15分钟或30分钟配置,儿保科考虑到问诊时间较长,通常会设成20分钟。这个参数放在 schedule 表里而不是全局配置,就是为了支持不同科室的灵活排班。
4.2 锁定号源:乐观锁与 Redis 分布式锁的选择
选号系统的并发核心只有一个问题:两个家长同时锁定同一个号位,如何保证只有一个成功?
最简单的方案是给number_source表加乐观锁版本号字段,更新时带上旧版本号:
UPDATE number_source SET status = 1, lock_user_id = #{userId}, lock_expire_time = #{expireTime}, version = version + 1 WHERE id = #{id} AND status = 0 AND version = #{oldVersion};update返回的影响行数为 1 说明锁定成功,为 0 说明号源已被抢走。这个方案实现成本最低,对中小型医院完全够用。
但实际压力测试时我们发现,仅靠乐观锁在已锁定超时释放和支付确认两个环节之间会出现竞态:A 用户锁定号源后迟迟未支付,15分钟到了系统自动释放,B 用户马上锁定了同一号源;此时 A 突然点了支付,系统需要先校验号源是否仍被 A 持有。这个校验动作如果并发高,数据库连接池容易被拖垮。
更好的做法是引入 Redis 分布式锁,以number_source:{id}为 key 加锁,锁定和解绑操作都走 Redis:
public boolean lockNumberSource(Long numberSourceId, Long patientId) { String lockKey = "lock:number_source:" + numberSourceId; boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, patientId.toString(), Duration.ofSeconds(30)); if (!locked) { return false; } try { // 再次查询状态是否为可预约,防重复锁定 NumberSource numberSource = numberSourceMapper.selectById(numberSourceId); if (numberSource.getStatus() != NumberSourceStatus.AVAILABLE) { return false; } // 执行锁定更新 return numberSourceMapper.lockById(numberSourceId, patientId) == 1; } finally { redisTemplate.delete(lockKey); } }这里 Redis 锁的过期时间要大于业务执行时间,否则会出现锁提前失效导致并发问题。典型的经验值是业务极限耗时不超过 5 秒就设置 30 秒,留足余量。
4.3 超时未支付自动释放的定时任务设计
用户锁定号源后 15 分钟未完成确认,系统需要自动释放号源。这个需求我用 SpringBoot 自带的@Scheduled定时任务实现,每 30 秒扫描一次:
@Scheduled(fixedDelay = 30000) public void releaseExpiredLocks() { List<NumberSource> expiredSources = numberSourceMapper .selectExpiredLocks(LocalDateTime.now().minusMinutes(15)); for (NumberSource source : expiredSources) { // 先判断订单状态,若已支付则跳过 AppointmentOrder order = orderMapper.selectByNumberSourceId(source.getId()); if (order != null && "PAID".equals(order.getStatus())) { continue; } numberSourceMapper.releaseLock(source.getId()); orderMapper.cancelExpiredOrder(source.getId()); } }注意这个任务必须做好幂等处理。selectExpiredLocks扫描到的记录即使锁已释放,再次执行releaseLock也不能报错。我的处理方式是 release 的 SQL 里带上status = 1条件,更新影响行数为 0 就视为已释放,直接忽略。
4.4 前端选号表格的数据加载与状态渲染
后端准备好号源查询接口后,前端的选号页核心是一个动态表格。页面加载时调用接口拉取某医生某天的号源数据,按时间段分组渲染:
const loadNumberSources = async (scheduleId) => { const res = await get(`/api/number-source/schedule/${scheduleId}`) const rows = res.data // 按 slotSeq 分组,一个时间段一行,每行内多个号位 const grouped = groupBy(rows, item => item.slotSeq) tableData.value = Object.values(grouped) // 统计每个时段的可约数量 availableCount.value = rows.filter(item => item.status === 0).length }表格里的每个号位格子绑定点击事件,用户点击后弹出确认框,显示号源的具体时间(比如"上午 09:15-09:30,第 1 号"),确认后调用锁定接口。锁定成功则跳转支付确认页;锁定失败则弹出"该号源已被选走,请重新选择"的提示。
这个交互流程中有一个常被忽略的问题:页面停留时间越长,用户看到的号源状态越可能过期。因此我在前端加了一个 30 秒轮询,重新拉取号源状态刷新表格,避免用户最后点击时才发现目标号源早就被别人锁走了。这也是为什么"选号"体验比传统挂号好——它让用户在点击前就感知到资源的实时变化。
5. 联调与踩坑实录:从"能跑"到"稳跑"的排查链路
5.1 排班创建后号源没生成?事务边界搞错了
第一次联调排班管理接口时,前端提示"排班创建成功",但查号源表却是空的。排查链路如下:
- 先确认接口是否真的执行了
generateNumberSources——打断点发现执行了,但数据没提交。 - 检查排班创建和号源生成是否在同一个事务里——原来 Service 方法上没加
@Transactional,排班 insert 后抛出的异常导致整体回滚,但前端只捕获到了"排班创建成功"的状态码。 - 定位到问题:生成号源的方法内部还有一个循环 insert,由于排班 ID 依赖于刚 insert 的主键回填,必须确保在同一个事务内,否则主键还未提交到数据库,子表插入时外键会报错。
解决办法是在 Service 方法上加@Transactional(rollbackFor = Exception.class),并确保MyBatis-Plus的insert完成后主键已回填到实体对象的id属性上。
5.2 Vue 路由参数传递:选完科室跳转排班页,医生 ID 丢了
前端从科室列表跳转到医生排班页时,用的是vue-router的路径参数:
router.push({ path: '/schedule', query: { departmentId: depId }})页面里通过route.query.departmentId读取参数。但我在排班页内又做了一次内部跳转(比如切换周视图),这时候route.query被新的路由覆盖,departmentId就丢了。
这个坑的根因是:query 参数在页面内部刷新或二次路由时会丢失,应该用 sessionStorage 或状态管理持久化核心业务参数。后续改成:
sessionStorage.setItem('selectedDepartmentId', depId.toString())页面加载时优先从 sessionStorage 读取,查询接口始终以持久化的参数为准。这一改动也顺便解决了用户刷新页面后参数丢失、白屏报错的问题。
5.3 分时段号源统计与号源余量显示不一致
联调中段遇到一个诡异问题:选号页面显示某个医生全天号源余量为 15,但实际点进去每个号段都是"已约满"状态。排查过程如下:
- 前端余量显示依赖的接口返回的是
schedule.remaining_slots字段,而后端这个字段只在创建排班时初始化为总号源数,从不更新。 - 患者锁定号源、支付完成的后续操作,都只是更新了
number_source.status,没有同步schedule.remaining_slots。 - 最后我把余量统计改成了实时 count 查询:
SELECT COUNT(*) FROM number_source WHERE schedule_id = ? AND status = 0,彻底弃用冗余字段。虽然牺牲了一点查询性能,但换来了数据一致性。
对于高并发场景,余量统计可以再加一层 Redis 缓存,用DECR原子递减。但这个项目实际的并发量级(单日预约峰值几百单,平均 TPS 个位数)用 count 查询足够,不必为了技术炫耀而过度设计。
5.4 医生停诊时已锁定号源如何处理
医生临时停诊是医院系统的"高优需求"。排班状态一旦改为停诊,系统里所有关联号源都必须立刻变为不可用,但用户订单的处理要格外小心。
最初的实现是:直接把number_source.status全部更新为3-停诊,订单同步置为"已取消"。结果当天下午就收到投诉——有位家长之前付了费,下午带孩子来医院发现号被取消了,但没收到任何通知。
修正后的流程是:
- 停诊操作分两步执行:先更新排班和号源状态,再给已预约/已锁定用户发站内消息和短信通知,通知文本附带改签入口链接。
- 已支付用户的订单状态改为"已取消-停诊",而不是直接删单,方便后续财务对账退款。
- 号源释放后不回到"可预约"状态,而是保持"停诊"状态,防止用户重新预约一个已停诊号位。
这个改动给我们的教训是:医疗系统的状态变更永远是业务事件,不是单纯的数据操作。停诊影响的不只是号源表的一行记录,还有已购票患者的就诊安排。
5.5 SpringBoot 跨域问题与拦截器配置
前后端分离开发时,第一个联调请求就报了跨域错误。浏览器直接拦截了前端localhost:5173到后端localhost:8080的请求。解决方案是后端配置跨域过滤器:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)时allowedOrigins("*")会失效,必须用allowedOriginPatterns("*"),这是 SpringBoot 2.4+ 的规范变化。很多网上老代码直接复制会翻车。
另外,实际生产环境我建议关掉这种全放开的 CORS 配置,改用 Nginx 反向代理:前端请求/api前缀的路径,Nginx 转发到后端服务,浏览器的同源策略就不会触发跨域问题。开发环境用 CORS 方便调试,生产环境用代理更安全,这条经验适用于绝大多数前后端分离项目。
6. 部署与后续扩展:从毕设到真实可用的最后一步
6.1 项目打包与环境配置
后端打包直接mvn clean package生成 jar 包,前端npm run build生成 dist 静态目录。生产环境最简单的部署方式是:Nginx 托管前端 dist 目录,同时把/api前缀的请求反向代理到后端服务。
后端application-prod.yml里建议单独配置生产数据库、Redis 地址和日志级别,不要直接改application.yml的公共配置:
spring: datasource: url: jdbc:mysql://your-mysql-host:3306/hospital_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: hospital_app password: ${DB_PASSWORD} redis: host: your-redis-host port: 6379这里密码用环境变量注入,别硬编码在配置文件里提交到代码仓库,这是我见过太多项目犯的低级错误。
6.2 日志与监控的补充优化
上线初期一定要打好日志基础。Logback配置里建议按天滚动,保留7天,同时把预约关键动作(锁定号源、支付确认、停诊取消)单独输出到独立日志文件,方便后续按order_no排查客户投诉。
系统监控这块,中小型项目没必要上全套微服务监控体系,但至少要在 SpringBoot 里引入spring-boot-starter-actuator,把/actuator/health暴露给运维做存活探活。压测时再用 Jmeter 跑一遍核心接口,重点关注两个指标:锁号接口的 99 分位延迟(控制在 200ms 以内)、下单失败率(控制在 0.1% 以下)。如果本地压测达不到,大概率是数据库连接池配置太小,默认的HikariCP maximumPoolSize = 10在高并发下会增加排队等待,根据服务器配置调大到 20~50 即可。
6.3 从毕设项目到真实系统的三个扩展建议
如果这个系统后续要真正在儿童医院落地,有几个方向值得继续深化:
- 多院区支持:在科室表和排班表里增加
campus_id字段,支持同一医生在不同院区出诊,号源池按院区隔离。 - 患者档案健全:增加既往病史、过敏史记录,预约时自动校验患儿年龄与科室匹配度(比如新生儿科的号源只允许 0-3 个月患儿预约)。
- 与 HIS 系统对接:预约完成后把患者信息、号源信息推送到医院的 HIS 系统,医生接诊时调阅,这是真实医院场景里的硬需求,也在毕设答辩时很加分。
实际开发中我最大的体会是:这类系统的代码复杂度和技术难度不算高,真正的挑战在业务状态的管理和对异常分支的处理。把"号源锁定-超时释放-支付确认-停诊取消"这条链路上每一种状态组合都梳理清楚,系统的质量就有了八分保障。
如果你正在做类似的挂号预约类项目,建议先把号源状态机画清楚再动手写代码——这个准备的投入产出比是最高的。希望这篇复盘对你有参考价值。