拓冰建站拓冰建站
首页 / 资讯中心 / 正文

医院挂号系统设计与实现:Spring Boot + Vue3前后端分离实战

1. 藏在挂号系统里的核心业务逻辑为什么要做线上化改造做医院挂号系统最先要搞清楚的问题不是“用什么技术”而是“挂号这件事在医院里到底是怎么跑的”。我接过一个三甲医院门诊部的信息化改造项目之前的挂号方式还是窗口排队加电话预约患者早上六点来排队窗口七点半开闸热门科室十五分钟就挂完了。现场时不时有家属着急医生也头疼因为号源到底还剩多少只有挂号窗口的人心里有数诊间里的医生根本不知道。这类系统落到技术实现上本质上是把“号源”这种紧俏资源从线下搬到了线上让患者、医生、院方管理三方的信息透明化。我这里说的“医院医疗挂号信息管理系统”就是指基于Spring Boot作为后端服务框架、Vue3作为前端交互框架的一套前后端分离应用核心功能覆盖科室管理、医生排班、预约挂号、号源锁定、退号释放、就诊记录查询以及管理端的统计看板。技术栈本身不算新但业务规则绕人比如“同一个时段只能挂一个号”“退号之后号源要立刻释放”“专家号和普通号不能混在同一张排班表里”“黑名单患者不能预约”等等每一行代码背后都是真实门诊场景的约束。对于做毕设的同学、刚转行做Java开发的新人或者想在公司内部快速搭建一套预约类系统的后端开发来说这个项目是个很完整的练手样本。它不是一个“玩具级CRUD”而是要考虑并发、状态机、防重复提交、权限隔离这些生产级问题。后面几节我会从需求模型、表结构设计、后端核心接口实现、前端页面落地、以及我们踩过的坑这几个维度来拆内容偏工程实践每一段都可以直接对应到代码里去验证。先说业务模型。任何挂号系统不管界面多花哨最终跑的都是三条核心链路。第一条是患者找号从科室列表进入医生排班选择可预约的时段提交挂号请求。第二条是医生的接诊闭环医生登录后台查看当天预约列表按序接诊标记完成或爽约。第三条是管理员的运营链路维护科室、医生、排班模板、号源限额、停诊停挂。这三条链路坐在一张系统里相互之间的状态必须实时同步。从技术实现上说前端和后端天然适合分离开发因为患者端的操作路径有多端变化小程序端、H5端、PC端管理后台后端只需要把统一的API暴露出来。我们的技术选型是Spring Boot 2.7 Vue3 MySQL 8.0 Redis用Spring Security JWT做登录认证用MyBatis-Plus做数据持久化。没有引入过于复杂的微服务治理框架因为医院门诊的并发量虽然存在早高峰比如八点到九点之间挂号请求激增但单机加Redis锁完全扛得住引入Nacos、Sentinel这类组件反而增加部署成本和维护负担。这是当时我和团队反复争论后达成的共识后面再细说。2. 数据库设计先想清楚“号源”这张表系统就成功了一半所有预约类系统最核心的表不是用户表也不是订单表而是号源排班表。这张表决定了你能承载多少并发、能否防重复、以及退号之后的号源能不能自动回流。我见过不少新手设计把号源挂在医生表下面加一个date字段和一个count字段每次挂号就把count减一看起来直接实际上一上线就出问题上午的号挂完了下午还能不能挂这个医生今天停诊了已经预约的患者怎么通知同一个上下午时段某个医生一周只出诊三天这规则写在哪所以排班表必须单独建而且要拆成“排班模板”和“排班实例”两层。2.1 核心表结构与字段设计我们最终的库表包括科室表、医生表、排班模板表、排班实例表即号源表、预约挂号表、患者表、管理员表、操作日志表。重点说几个表的字段设计思路。排班模板表解决“重复排班”的问题。比如心内科的张医生固定每周一、三、五上午出诊下午在病房那就建一张模板week_day1,3,5periodAM诊费25元号源数30。管理员不需要每周手动录一次系统启动一个定时任务根据模板自动生成未来两周的实际排班。这就是排班实例表的数据来源它加上了具体的date、剩余号源数remain_count、总号源数total_count以及状态字段status0未开始放号、1放号中、2已约满、3已停诊。这里有个很小的细节为什么要把总号源数和剩余号源数分开存因为门诊经常发生临时加号。比如某天专家临时决定多加五个号直接改排班实例的total_count和remain_count就行不需要动历史已预约的数据。如果只存一个count字段加号和预约之间会有数值打架的问题。别小看这个细节真实门诊场景中加号和停诊几乎每周都有。预约挂号表是这个系统的交易主表字段包括预约单号、患者ID、排班实例ID、预约日期、时段序号、状态10待就诊、20已就诊、30已取消、40爽约。很多人会纠结要不要存冗余的医生ID和科室ID我们的经验是存一份快照式冗余因为医生可能调科室、诊费可能调价但患者手里的历史预约单不能跟着变。这就是为什么订单类的表要格外小心“外键依赖”那些看似可以JOIN查询的信息一旦源表数据被修改历史订单就变成了错误数据。2.2 索引与并发锁设计挂号表的高频查询有两种一是按患者ID查“我的预约”二是按排班实例ID查“剩余号量”。所以数据库索引至少要覆盖这两个方向。我们给预约表建了联合索引patient_id, status和schedule_id, status给排班实例表建了唯一索引doctor_id, date, period, time_slot这个唯一索引就是防并发重复排班的关键。并发控制在数据库层面使用乐观锁排班实例表加一个version字段更新剩余号源时执行“update schedule set remain_count remain_count - 1, version version 1 where id ? and remain_count 0”受影响的行数为0就说明号源被抢完了。这套方案简单可靠配合Redis做前置预热计数能轻松支撑日常300左右的瞬时QPS。如果你要做秒杀级的高并发比如全城统一放号那就要引入Redis Lua脚本预扣减、MQ异步落库、订单超时未支付自动释放这一整套机制我们在系统二期才用上前期不需要过度设计。数据库表设计这块建议动手之前先用Excel拉一张“业务状态流转图”把每个核心字段在什么状态下允许什么操作列出来比如“已取消的预约单不能改成待就诊”“停诊的排班不能发起挂号请求”有了这张表再建表逻辑会清晰很多。我自己最早做这类系统时不重视状态流转直接导致了后面写后端代码时出现一堆if/else嵌套后来重构了一遍才理顺。3. 后端实现要点那些“不是CRUD”的业务边界问题Spring Boot后端看起来无非是Controller、Service、Mapper三层但挂号系统真正的复杂度全在Service层。我把核心经验分成三块认证与授权、放号与退号的状态机、以及第三方对接的扩展点。3.1 JWT登录态与权限控制我们用的是Spring Security JWT没有用Session因为前后端分离之后Vue3前端和后端API可能部署在不同域名下Session跨域处理Cookie比较麻烦JWT把用户身份信息放在Token里前端拿到后存到localStorage或Pinia中每次请求放进Authorization请求头里就行。JWT生成的代码核心逻辑不复杂我直接写一个最小可用的实现片段String token Jwts.builder() .setSubject(userId.toString()) .claim(role, user.getRole()) .claim(name, user.getRealName()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();然后在网关层或拦截器里解析Token把用户信息塞进ThreadLocal供后续Service使用。但这里有个最容易踩的坑JWT是无状态的一旦签发在过期之前无法主动失效。如果用户的账号被管理员禁用他手上的Token在24小时内依然有效。我们在系统里加了一层“Token版本号”字段用户表存一个token_versionJWT里也带上这个值每次校验时比对不一致就拒绝。改密码、被踢下线、管理员禁用账号都通过自增token_version来强制失效。这个设计在真实项目中很实用教科书里的JWT示例往往不会提到。角色权限上我们分了三种ROLE_PATIENT、ROLE_DOCTOR、ROLE_ADMIN。接口用PreAuthorize注解控制比如医生的接诊列表接口只允许ROLE_DOCTOR访问管理员的排班管理接口只允许ROLE_ADMIN访问。规则很简单但注意Spring Security的注解默认是关闭的需要加上EnableGlobalMethodSecurity(prePostEnabled true)开启。3.2 放号、锁定与退号的状态机逻辑放号的后端接口看似简单查排班实例剩余号数大于0插入预约单扣减剩余号数。但要在同一事务里完成否则会出现“插入预约单成功但扣减号源失败”的数据不一致。我们的事务方法大致是这样Transactional(rollbackFor Exception.class) public AppointmentBookResult book(BookRequest request) { // 1. 锁排班实例行 ScheduleInstance instance scheduleInstanceMapper.selectByIdForUpdate(request.getScheduleId()); // 2. 业务校验 if (instance.getStatus() ! 1) throw new BusinessException(当前时段不可预约); if (instance.getRemainCount() 0) throw new BusinessException(号源已约满); if (appointmentMapper.countByPatientAndDate(request.getPatientId(), instance.getDate()) 0) { throw new BusinessException(同一日期只能预约一次); } // 3. 扣减号源 instance.setRemainCount(instance.getRemainCount() - 1); scheduleInstanceMapper.updateById(instance); // 4. 创建预约单 Appointment appointment new Appointment(); // ... 省略字段赋值 appointmentMapper.insert(appointment); return new AppointmentBookResult(appointment.getId()); }粗看没问题但有一个边界情况容易漏如果患者先挂了一个上午的号又想在下午挂同一个医生的号按“同一日期只能预约一次”这个规则就会被挡掉。真实业务是否允许上下午各挂一次这个问题必须和院方确认清楚不同医院的规则不一样。我们最后定的方案是同一科室每天最多一次不同科室不同时段可以多次。校验条件就变成了“该患者在同一天是否已有同一科室的待就诊或已就诊记录”这个口径明确之后SQL就好写了。退号逻辑刚好是放号的逆操作但状态流要小心退号后不仅要把remain_count加回来还要把排班实例的status从2约满回退为1放号中否则前端会继续显示约满。同时要给患者发一条通知我们集成了阿里云短信因为医生可能看到号源又释放了会加号。有个小建议退号操作一定要记录操作日志包括退号人、退号时间、原始预约单号医院审计经常会查这个。3.3 停诊与改约的后端处理医生临时停诊也是高频场景。管理员在后台把某一天的排班实例status置为3停诊系统要自动做两件事一是把所有已预约该时段的预约单状态改为“已停诊”二是给患者发送停诊通知并引导改约。这里最容易出的bug是医生只是临时外出学习并不是永久停诊所以不能把排班模板删掉也不能把排班实例删除只能标记状态。而且改约不能搞成“先退号再重新挂”那样号源极有可能被别人抢走。我们当时的做法是提供一个改约接口在一个事务里完成“原预约单状态置为已取消原因停诊改约新时段预约单创建并锁定号源”并且给这类改约用户一个优先级标记新时段若恰好只剩最后一个号改约用户优先。这属于业务规则层面的优化虽然不算通用需求但在真实系统里非常重要医生停诊对患者体验的影响可以被大大削弱。3.4 与HIS系统对接的现实问题医院环境里通常不是只有一个挂号系统还有个叫HISHospital Information System医院信息系统的老大哥。老系统的接口五花八门有的提供WebService有的提供HTTP接口返回XML的都有。我们做了一套简单的适配层定义一个统一接口public interface HisGateway { // 同步患者信息 Result syncPatient(Patient patient); // 同步挂号单到HIS Result pushRegistration(Appointment appointment); }然后为每个医院的HIS写一个实现类。好处是如果换了一家医院只需要新增一个实现类业务代码完全不动。这块属于集成层面的工程经验很多市面上的教程是不讲的。4. 前端Vue3部分的落地不只是画页面还要管状态前端我用的是Vue3 Vite Pinia Element Plus这套组合在目前的开源社区里相当主流。Vue3的Composition API写起来逻辑内聚Pinia比Vuex更轻量Element Plus的组件覆盖了后台管理系统90%的交互需求。4.1 项目初始化和工程目录划分用Vite创建项目很简单重点说说目录划分。我们按“功能模块”而不是“文件类型”来组织代码src/api放接口请求src/views放页面级组件src/components放公共组件src/stores放Pinia状态src/router放路由配置。这样一个科室模块的所有请求函数都放在src/api/dept.js里而不是散落在各个页面中。登录状态的持久化是前端的关键点之一。Token存放在Pinia的auth模块里初始化时从localStorage读取// stores/auth.js export const useAuthStore defineStore(auth, { state: () ({ token: localStorage.getItem(token) || , userInfo: null, }), actions: { setToken(token) { this.token token; localStorage.setItem(token, token); }, logout() { this.token ; localStorage.removeItem(token); }, }, });路由守卫里加一个判断每次跳转前检查有无Token没有就重定向到登录页。这个方法虽然基础但确实保证了系统的最小安全门槛。4.2 Axios封装与401统一处理Axios封装是我每次都要强调的。就算项目再小也值得用十几行代码做一个统一的request模块把baseURL、超时时间、请求拦截器和响应拦截器集中处理。尤其响应拦截器里要做几件事后端返回的code不等于200时弹出错误提示HTTP状态码是401时自动跳回登录页后端返回的Blob文件流要单独处理错误因为Blob是二进制无法直接读取里面的JSON错误信息。我直接给一个典型的响应拦截器片段service.interceptors.response.use( (response) { const res response.data; if (res.code ! 200) { ElMessage.error(res.message || 请求失败); if (res.code 401) { useAuthStore().logout(); router.push(/login); } return Promise.reject(new Error(res.message)); } return res; }, (error) { ElMessage.error(error.message || 网络异常); return Promise.reject(error); } );这里有个容易被忽略的细节当后端返回code401时说明业务上用户登录态失效但HTTP状态码可能依然是200因为我们在Result结构体里放了一个业务状态码。所以拦截器既要处理HTTP层的错误也要处理业务层的错误。4.3 医生排班选择器的组件化设计挂号页最核心的交互是在“科室 - 医生 - 日期 - 时段”这条路径上。我们把它拆成了三个组件DeptTree科室树形选择、DoctorCard医生卡片列表、SchedulePanel排班时段选择。医生卡片上显示姓名、职称、简介、挂号费点击卡片后SchedulePanel拉取该医生未来七天的排班数据。每个日的每个时段渲染成一个格子可约的显示绿色约满显示灰色停诊显示红色禁用。这个组件的数据流转关键在“父子组件通信”。SchedulePanel内部展开某一天的时段列表时需要先请求后端获取排班实例详情和剩余号数点击某个时段之后把选中的排班实例ID和时段序号emit到父组件父组件再调用“确认挂号”接口。Vue3里用defineProps和defineEmits来声明这些接口比Vue2的选项式写法更直观script setup defineProps({ doctorId: { type: Number, required: true }, }); const emit defineEmits([select-slot]); function onSlotClick(slot) { emit(select-slot, slot); } /script4.4 管理后台的Vue3动态路由管理员端的菜单和角色权限是联动变化的比如医生登录后不应该看到“排班模板管理”菜单。我们做的是动态路由前端只保留登录页和布局框架的路由用户登录后根据返回的角色权限列表用router.addRoute动态注册菜单对应的路由。核心步骤是登录接口返回用户角色和可访问的菜单标识前端维护一份“菜单标识到路由组件的映射表”遍历菜单标识逐个调用router.addRoute把动态路由信息存到Pinia里驱动侧边栏渲染。这个方法能避免把后端返回的权限字符串硬编码到路由表里新增一个菜单只改一份映射表就行。对于想快速搭建后台管理系统的同学这一套动态路由方案可以直接借鉴开源项目中的成熟写法。5. 真实联调踩坑记录这些细节不经历一次真的想不到开发过程不总是一帆风顺的有几个坑比较有价值我逐个复盘一下。第一个坑是跨域问题。开发环境Vite默认跑在5173端口后端跑在8080端口本来我预计会在浏览器报跨域错误所以提前在Spring Boot里加了个CorsFilter允许跨域。但上线部署后前端静态文件放在Nginx里API请求通过Nginx代理转发到Java后端此时后端接收到的请求头中Origin不是来自浏览器页面域名而是经过代理后变成了“null”这个空值在CORS校验里被放行没问题但部署时发现登录请求总是失败。查了半天发现是Nginx配置里少了proxy_set_header Host $host、Origin相关字段导致后端获取不到真实域名。这个问题提醒我CORS的配置最终要以Nginx转发的实际场景为准开发环境的配置和线上环境的配置是不一样的。第二个坑是前面提到过的号源并发。我们用单元测试模拟了50个线程同时抢最后一个号预期只有1个成功实际有2个成功。排查后发现原因是订单表插入和排班实例扣减不在同一个事务里。我一开始觉得不过是一次普通的update操作不至于出问题但MyBatis-Plus的updateById方法默认只更新非空字段如果想要更新count字段必须显式传值我在业务代码里多了一步先查询后更新的写法相当于拆成了两步操作就在这两步之间发生了并发穿透。后来改成了先直接执行update ... where remain_count 0然后再select校验问题就消失了。这里想提醒新手一句并发问题要当成一等公民来对待不要觉得“我系统没多少人用”。第三个坑是时间格式化。后端返回的预约日期和时间段是一个LocalDateTime对象默认序列化之后是“2025-03-21T09:00:00”这种格式前端直接显示出来很难看而且用户在不同时区看到的字符串可能不一样。解决方案是统一在Jackson配置里指定日期格式和时间时区返回给前端的时间统一为yyyy-MM-dd HH:mm格式前端拿到字符串直接展示避免时区转换出现偏移。第四个坑是浏览器缓存导致的页面刷新后数据不更新。医生调整了排班前端列表还是显示旧的剩余号数。原因是后端接口没有设置Cache-Control响应头部分浏览器对GET请求做了强缓存。我们在Spring Boot拦截器里对API路径统一设置了Cache-Control: no-cache并且在Axios请求里给关键查询接口加时间戳参数或using fresh protocol确保每次请求都真正到达后端。第五个坑是Element Plus的日期选择器组件。在Vue3里日期选择器返回的Date对象时区是UTC而后端接收到的参数经过JSON序列化和反序列化后日期会差一天。比如用户在页面上选了2025年3月21日后端收到的却是2025年3月20日。这个其实是老问题了解决方案还是依靠JVM时间时区和Jackson配置统一成GMT8并在前端提交日期前做一次格式化用dayjs转成“YYYY-MM-DD”字符串提交不要传Date对象。6. 部署上线Docker Compose一键编排以及后续还能做什么项目开发完成后部署我们采用的是Docker Compose方式把MySQL、Redis、后端服务、前端静态文件四个容器编排到一起。前端用Vite构建生成dist目录直接挂载到Nginx镜像里Nginx除了托管静态资源外还把/api路径的请求反向代理给后端服务。Docker Compose文件关键片段大致是这样services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: hospital_reg volumes: - ./mysql-data:/var/lib/mysql ports: - 3306:3306 redis: image: redis:7-alpine ports: - 6379:6379 backend: build: ./backend depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod ports: - 8080:8080 frontend: image: nginx:alpine volumes: - ./frontend/dist:/usr/share/nginx/html - ./nginx.conf:/etc/nginx/conf.d/default.conf ports: - 80:80 depends_on: - backend这套方案有个很直观的好处迁移到另一台服务器时只需要安装Docker和Docker Compose然后执行docker compose up -d整个环境就起来了。比手工安装MySQL、JDK、Nginx再逐个配置快得多。对于医疗项目还需要格外注意等保合规和数据安全的要求但我们这里主要讨论技术实现不展开讲合规细节。需要提醒的是患者的病历、身份证号、联系方式都属于敏感数据数据库里对这些字段要做加密存储日志打印时要脱敏。我们用的方案是自定义一个MyBatis类型处理器对身份证号和手机号自动加密查询时自动解密这样业务代码完全无感知。如果这个系统还要继续演进我建议下一步可以做这几个方向一是把支付环节接进来很多三甲医院已经有线上支付的要求挂号后支付挂号费爽约自动退款二是引入消息队列比如退号后异步发送短信通知避免阻塞核心事务三是做号源池的监控大屏实时展示每家科室的号源使用率、患者爽约率、平均候诊时长这个对医院运营管理来说非常实用。7. 写在最后的几点个人体会我做了这么多年开发最深的感受是像挂号管理系统这类业务技术从来不是最难的最难的是把复杂的现实业务流程抽象成清晰的数据模型和状态机。你花在业务梳理上的时间到最后都会变成代码里的每一行判断条件省不掉也绕不开。如果你正在做类似的毕设或项目练手我建议从“预约挂号”这个小闭环开始先跑通完整流程再考虑添花活。比如先实现科室列表、医生排班、生成号源、预约、退号五个核心接口前端把挂号页、我的预约页、管理排班页这三个界面做出来项目就已经完成大半了。并发控制、JWT权限、Docker部署这些工程化内容是给你加分的地方但不要让它们拖住主线进度。最后分享一个效率技巧开发阶段后端Swagger一定要开起来接口联调时让前端对着Swagger看字段就不用反复用Postman导JSON给前端了。我把所有接口都加了Swagger注解前端同事干活效率直接翻了倍。这种小事情做项目时能省下大把沟通成本。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门