ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

儿童医院分时段挂号选号系统设计与SpringBoot+Vue实现复盘

儿童医院分时段挂号选号系统设计与SpringBoot+Vue实现复盘 儿童医院分时段挂号系统的设计与实现复盘从需求到SpringBootVue落地做医疗类管理系统这些年儿科门诊的挂号一直是个特殊的存在。排队两小时、看病五分钟在儿科场景里被无限放大——孩子生病往往来得急家长焦虑程度高现场扎堆挂号的混乱程度远超普通科室。去年接了一个儿童医院的分时段预约挂号选号系统项目技术栈就是标题里那套经典的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 vitelatest 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 核心表的拆解思路预约系统的核心表有五张医生表、排班表、号源表、预约订单表、患者表。以排班表为核心向外辐射表名核心字段作用doctorid, name, department_id, title, is_expert医生基础信息scheduleid, doctor_id, work_date, start_time, end_time, slot_duration, total_slots, remaining_slots, status某医生某天的出诊计划和时段配置number_sourceid, schedule_id, slot_seq, slot_start, slot_end, number_no, status, lock_expire_time每个具体号位的状态appointment_orderid, order_no, patient_id, number_source_id, status, create_time, pay_status用户预约单patientid, 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 加锁锁定和解绑操作都走 Redispublic 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() { ListNumberSource 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 系统医生接诊时调阅这是真实医院场景里的硬需求也在毕设答辩时很加分。实际开发中我最大的体会是这类系统的代码复杂度和技术难度不算高真正的挑战在业务状态的管理和对异常分支的处理。把号源锁定-超时释放-支付确认-停诊取消这条链路上每一种状态组合都梳理清楚系统的质量就有了八分保障。如果你正在做类似的挂号预约类项目建议先把号源状态机画清楚再动手写代码——这个准备的投入产出比是最高的。希望这篇复盘对你有参考价值。
返回列表