资讯动态

基于Spring Boot的社区诊所挂号排队系统设计与实现

发布时间:2026/10/5 11:26:14 来源:尧图企业网站定制
做社区诊所这类挂号排队系统最容易被低估的就是“号源管理”和“签到状态流转”。很多新手把注意力全放在CRUD接口上结果做到排队叫号的时候发现数据对不上、状态乱套。我基于Spring Boot把在线挂号、排队、就诊签到这几个核心流程完整走了一遍这篇就把设计思路和踩坑记录整理出来尤其是那些网上教程不会明说的细节。1. 项目定位与整体架构设计1.1 社区诊所的业务场景分析社区诊所和大医院的挂号系统有个本质区别号源规模小但高峰时段集中。三甲医院一天几千个号用的是分时段批量放号而社区诊所一天可能就两三百个患者医生也就五六个患者对“具体哪个医生”的敏感度远高于“哪个时间点”。这就导致系统设计思路完全不一样。我梳理业务时把核心流程画成了四条线患者线注册登录 → 选择医生/科室 → 选择时段 → 生成挂号单 → 到院签到 → 进入候诊队列 → 叫号就诊医生线查看今日排班 → 查看当前候诊患者 → 呼叫下一个 → 完成就诊前台线处理线下挂号 → 处理退号 → 手动签到确认 → 队列异常干预管理线医生信息维护 → 排班设置 → 号源池配置 → 数据统计这个场景下系统的难点不在业务复杂而在状态一致性。一个患者从预约到最终见到医生中间经历预约成功、签到、进入队列、被叫号、开始就诊、完成就诊六个状态任何一个环节没做对都会导致患者在前台和医生之间来回跑。做完这个项目我的体会是社区诊所的系统稳定性比功能多重要。功能做多了没人用但一旦挂号数据出错前台人员得花半小时手工修复这才是最致命的。1.2 技术选型为什么是Spring Boot MyBatis-Plus Redis技术上我用的组合是Spring Boot 2.7 MyBatis-Plus Redis MySQL 8.0前端用Vue 3 Element Plus。选这套组合的理由很直接Spring Boot 2.7稳定资料多遇到问题基本都能搜到解决方案。Spring Boot 3没用是考虑到JDK版本和部分旧依赖兼容性社区诊所项目没必要追新。MyBatis-Plus单表CRUD直接省掉写Mapper XML的时间分页插件也现成。说句实话这种项目90%的操作是单表查询MyBatis-Plus的LambdaQueryWrapper能少写很多代码。Redis核心作用是号源预扣减和当前叫号队列的数据支撑。不引入Redis的话用MySQL悲观锁硬扛也不是不行但高峰期并发一上数据库连接池容易被打满体验差距很大。MySQL业务数据最终落库选用InnoDB引擎事务隔离级别默认的REPEATABLE READ就够了。这个组合在两三百号规模的社区诊所场景下性能冗余非常大。我做了一个简单的压测模拟50个并发同时挂号接口响应平均在180ms左右数据库连接池用默认的HikariCP配置完全没压力。提示技术选型时不要为了“炫技”引入微服务、消息队列这类重型组件。社区诊所系统单体应用 缓存就是最合理的方案部署简单、排查方便、成本也低。1.3 系统整体模块划分我把整个系统拆成了五个模块每个模块职责清晰模块职责范围核心实体用户模块注册、登录、角色权限user、role挂号模块号源查询、挂号、退号doctor、schedule、appointment排队模块签到、入队、叫号、就诊registration、queue排班管理医生排班、号源生成schedule、time_slot统计模块挂号量、候诊时长、医生工作量基于业务表聚合模块划分的原则是“按业务边界切不按技术层面切”。比如签到和排队我放进了同一个模块因为签到之后紧接着就是入队操作拆开反而要在多个Service之间做事务协调。2. 数据库设计与号源模型拆解2.1 核心表结构设计与字段说明数据库是这类系统的地基设计得好不好直接影响后续开发体验。我先列一下核心表的字段设计然后重点解释几个关键设计思路。用户表user字段类型说明idbigint主键usernamevarchar(50)登录名passwordvarchar(100)BCrypt加密存储real_namevarchar(50)真实姓名phonevarchar(20)手机号登录绑定id_cardvarchar(18)身份证号就医登记roletinyint1-患者 2-医生 3-前台 4-管理员create_timedatetime创建时间身份证号建议加唯一索引。社区诊所不少中老年患者记不住手机号前台用身份证号能直接查到人实测下来比手机号好用。医生排班表doctor_schedule字段类型说明idbigint主键doctor_idbigint医生IDwork_datedate出诊日期time_slot_idbigint时段IDtotal_numberint该时段总号数remain_numberint剩余号数versionint乐观锁版本号statustinyint0-停诊 1-正常这个表是防超卖的核心remain_number在每次挂号成功时减一version字段用于乐观锁控制。挂号单表appointment字段类型说明idbigint主键order_novarchar(32)挂号单号user_idbigint患者IDschedule_idbigint排班IDdoctor_idbigint医生IDvisit_datedate就诊日期time_slot_idbigint时段IDappointment_statustinyint状态流转核心字段sign_in_timedatetime签到时间create_timedatetime创建时间appointment_status状态字段是整张表最关键的我设计的取值如下值含义说明0已预约预约成功未到院1已签到到院签到已进入候诊队列2就诊中医生点击“呼叫”状态进入诊疗3已完成就诊结束4已退号患者退号/过期未到2.2 号源库存的设计思路时段窗口与医生排班号源模型是这个项目最核心的设计决策。我在做之前对比了两个方案方案A一次性生成全天号源。比如医生一天放40个号提前在数据库生成40条号源记录。优点是逻辑直观但问题也明显表数据量大、号段管理麻烦、退号后空位要靠定时任务回收。方案B按排班记录 剩余号数。一张排班表就代表一个医生在某时段的全部号源不生成具体号源明细。挂号时对remain_number做乐观锁更新减到0就代表该时段号源已满。我最终选了方案B原因很实际每时段只更新一个字段不会产生大量冗余数据退号逻辑简单退号后remain_number加一即可查询号源状态只需要扫排班表性能高举例说明某医生上午出诊设置时段为“08:00-10:00”共20个号“10:00-12:00”共20个号那么doctor_schedule就生成两条记录total_number20, remain_number20。患者挂号时对当前记录执行UPDATE doctor_schedule SET remain_numberremain_number-1, versionversion1 WHERE id? AND remain_number0受影响行数为1才代表扣号成功。这个设计唯一的代价是拿不到“具体是几号”但社区诊所不靠号段叫号靠队列顺序叫号所以完全够用。2.3 数据关系与状态机设计实体之间的关系比较明确用户与挂号单是一对多一个用户可以有多个挂号记录不同日期医生与排班是一对多一个医生在不同日期有多条排班排班与挂号单是一对多一条排班对应多个预约者挂号单与签到记录是一对一比较容易被忽略的是状态机约束。我踩过一个坑患者还没签到医生端页面却能直接点击“呼叫下一个”结果叫了一个根本不在候诊区的人。所以我在代码层做了一个强制校验状态流转只能是下面这条链路已预约(0) - 已签到(1) - 就诊中(2) - 已完成(3) 已预约(0) - 已退号(4) 已签到(1) - 已退号(4) // 特殊情况签到后因故离开每个状态变更的接口Controller层都要先校验当前状态是否允许流转。比如签到接口只接受appointment_status0的单子其他状态直接抛业务异常。这个校验逻辑不复杂但属于“不加就会出事故”的防守型代码。3. 核心功能实现在线挂号、排队与就诊签到3.1 在线挂号选择医生与时段号源锁定防超卖挂号是整个系统并发压力最大的接口核心要求是绝不超卖。我在实现时用了“Redis预扣减 MySQL乐观锁兜底”的双保险策略。挂号接口整体流程分为四步查询排班信息校验号源是否充足Redis中对排班ID做预扣减防止超量请求打到数据库创建挂号单状态为“已预约”对doctor_schedule执行乐观锁扣减失败则回滚代码核心逻辑如下Transactional(rollbackFor Exception.class) public AppointmentResult createAppointment(CreateAppointmentRequest request) { Long userId request.getUserId(); Long scheduleId request.getScheduleId(); // 1. 校验Redis预扣减 String lockKey schedule:stock: scheduleId; Long remaining redisTemplate.opsForValue().decrement(lockKey); if (remaining null || remaining 0) { // 恢复库存并抛出异常 redisTemplate.opsForValue().increment(lockKey); throw new BusinessException(该时段号源已满); } try { // 2. 校验用户当日是否重复挂号 long count appointmentMapper.selectCount(new LambdaQueryWrapperAppointment() .eq(Appointment::getUserId, userId) .eq(Appointment::getVisitDate, request.getVisitDate()) .in(Appointment::getAppointmentStatus, 0, 1)); if (count 0) { throw new BusinessException(您当天已有挂号记录请勿重复挂号); } // 3. 创建挂号单 Appointment appointment new Appointment(); appointment.setOrderNo(generateOrderNo()); appointment.setUserId(userId); appointment.setScheduleId(scheduleId); appointment.setDoctorId(request.getDoctorId()); appointment.setVisitDate(request.getVisitDate()); appointment.setAppointmentStatus(0); appointmentMapper.insert(appointment); // 4. 乐观锁扣减数据库库存 int updateRows scheduleMapper.decreaseRemainNumber(scheduleId); if (updateRows 0) { throw new BusinessException(号源已被抢完请选择其他时段); } return AppointmentResult.success(appointment); } catch (Exception e) { // 回补Redis库存 redisTemplate.opsForValue().increment(lockKey); throw e; } }说说这么设计的原因。只用Redis的话一旦Redis宕机就全乱了只用MySQL每个挂号请求都要等数据库写。双保险的意思是Redis先挡掉绝大部分并发量数据库做最终一致性兜底Redis万一超扣了MySQL的乐观锁照样拦得住。generateOrderNo()我用的规则是日期 医生ID 随机数 序列比如20250115120001。保证唯一性的同时前台人员一眼能看出挂号的日期和医生。注意Transactional一定要加rollbackFor Exception.class否则事务只回滚RuntimeException自定义的BusinessException没继承RuntimeException的话会导致数据不一致。这个坑我踩过排错花了一下午。3.2 排队叫号候诊队列的核心逻辑候诊队列的设计我采用的是**“签到顺序即队列顺序”**的公平策略同一时段的患者按签到先后排队。队列怎么实现这里有两个选择选择一Redis List。患者签到后执行LPUSH queue:{scheduleId} userId医生呼叫时执行RPOP天然先进先出。选择二MySQL排序列。在挂号单表增加一个queue_number字段签到时就确定排队序号。我选了方案二原因是queue_number患者端和前台端都要展示直接查库更直观也方便做“前面还有几人”的计算。Redis方式在展示“排队人数”时反而要多一步同步。队列逻辑的核心代码Transactional(rollbackFor Exception.class) public void signIn(Long appointmentId, Long userId) { // 1. 校验待签到状态 Appointment appointment appointmentMapper.selectById(appointmentId); if (appointment null || appointment.getAppointmentStatus() ! 0) { throw new BusinessException(挂号单不存在或状态不允许签到); } if (!appointment.getUserId().equals(userId)) { throw new BusinessException(无权操作他人挂号单); } // 2. 获取当前队列最大值新患者排到末尾 Integer maxQueue queueMapper.getMaxQueueNumber(appointment.getScheduleId()); int currentQueueNumber (maxQueue null ? 0 : maxQueue) 1; // 3. 更新挂号单状态为“已签到”写入排队序号和签到时间 appointment.setAppointmentStatus(1); appointment.setSignInTime(new Date()); appointment.setQueueNumber(currentQueueNumber); appointmentMapper.updateById(appointment); // 4. 更新候诊队列表 QueueRecord queueRecord new QueueRecord(); queueRecord.setScheduleId(appointment.getScheduleId()); queueRecord.setAppointmentId(appointmentId); queueRecord.setQueueNumber(currentQueueNumber); queueRecord.setStatus(0); // 0等待中 1已呼叫 2已完成 queueRecordMapper.insert(queueRecord); }医生端“呼叫下一个”的逻辑就简单了从当前排班下所有status0的候诊记录中找到queue_number最小的那个然后把预约单状态更新为“就诊中”。这里有一个实际问题医生看的是自己的排班队列前台看的是整个诊所队列。所以schedule_id作为队列分区键很重要每个医生就是一个独立队列互不干扰。3.3 就诊签到从预约到候诊的状态流转签到环节看似简单实际上有两个隐蔽的坑要处理。坑一患者提前到院怎么办。社区诊所的患者时间观念不严格预约的是上午10点可能8点半就来了。如果直接签到入队会打乱整个上午的号源节奏。我的处理是允许提前签到但入队保持顺序。签到时间只影响候诊顺序的计算不影响时段归属。反正社区诊所的时段只是一个粗粒度的时间框医生看得完是整个时段的全部患者。坑二过了预约时间还没来怎么办。我在签到接口做了一个时间判断超过预约时段结束时间30分钟后仍未签到系统自动把状态置为“已退号”号源回补。这个判断既可以做成定时任务扫描也可以在签到接口的校验逻辑里顺带处理。// 判断是否超时未签到超过时段结束时间30分钟自动退号 Date now new Date(); Date deadline DateUtil.offsetMinute(appointment.getTimeSlotEndTime(), 30); if (now.after(deadline)) { appointment.setAppointmentStatus(4); appointmentMapper.updateById(appointment); scheduleMapper.increaseRemainNumber(appointment.getScheduleId()); throw new BusinessException(已超过签到时限本次挂号自动退号); }这个“宽限30分钟”的策略是跟诊所前台反复沟通后定的。太短老年人走路慢容易误判太长医生的号源会被无意义占用。4. 实操过程中的关键技术点与实现细节4.1 登录认证与角色权限控制这种系统不建议引入太重的安全框架我用的是Spring Security JWT轻量且够用。认证流程用户登录成功 → 生成JWT Token返回前端 → 前端请求时放到Authorization头 → 后端拦截器校验Token合法性并解析用户信息。权限控制上的核心点患者只能操作自己的挂号单校验时拿userId比对医生只能访问自己的排班和队列数据前台可以操作所有患者的签到和退号管理员拥有全部权限JWT的有效期我设置的是12小时。社区诊所的就诊场景患者一般当天挂号当天就诊12小时足够覆盖整个就诊周期。Token过期后让用户重新登录比刷新Token机制简单得多也更适合这个项目规模。// JWT工具类核心方法生成Token public String generateToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 12 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }4.2 队列状态实时刷新与通知机制社区诊所没有大医院的叫号大屏但候诊区通常有一块电视或前台电脑屏幕展示排队信息。前端采用定时轮询的方式每5秒请求一次当前队列接口刷新队列展示。接口设计GetMapping(/queue/list) public ResultListQueueVO getQueueList(RequestParam Long scheduleId) { // 查询该排班下所有候诊记录 // 按queue_number升序排列 // 返回患者姓名脱敏、排队号、候诊状态 }轮询间隔为什么是5秒不是1秒因为候诊信息对实时性要求没那么高1秒轮询反而给服务器增加无意义的负载。实测下来5秒轮询对接口的QPS压力大概是每分钟12次/终端完全可接受。另外我在患者挂号和签到成功时设计了短信/公众号模板消息的占位接口。考虑到诊所实际条件没有对接第三方短信服务但接口预留了扩展点。这样后续诊所想接微信通知改动成本会很低。4.3 前端页面与接口的配合方案前端我用的Vue 3 Element Plus页面按角色拆分角色主要页面核心交互患者首页、科室选择、医生列表、挂号确认、我的挂号选科→选医→选时段→支付预留医生今日排班、候诊队列、呼叫操作点击“呼叫下一个”→弹出患者信息→开始就诊前台签到确认、队列总览、退号处理扫描核对身份→确认签到→处理退号前端和后端的接口约定我重点强调两点。第一所有列表接口都做分页哪怕当前数据量很小第二时间字段统一返回时间戳由前端做格式化避免时区问题。第一个是规范基础第二个是我被坑过很多次的坑。分页的响应结构{ code: 200, message: success, data: { records: [...], total: 128, current: 1, size: 10 }, timestamp: 1705209600000 }4.4 项目打包与部署的实战经验这个项目最终是前后端分离部署的前端打包成静态文件放在Nginx里后端打成Jar包用systemd托管。Spring Boot的打包部署有几个细节值得留意。第一配置文件按环境拆分。我用application-dev.yml和application-prod.yml区分开发和生产环境。生产环境用mysql-prod数据源、Redis密码、不同的JWT密钥。启动时通过--spring.profiles.activeprod指定环境。第二Jar包启动参数要调。对于这种小项目内存分配不用太激进java -Xms256m -Xmx512m -jar clinic-appointment.jar --spring.profiles.activeprod固定堆内存的好处是避免JVM启动后频繁调整堆大小对稳定性和响应时间都有帮助尤其是排队叫号这种实时性要求高的场景。第三数据库连接池参数要调。HikariCP默认最大连接数是10社区诊所的高峰并发可能就四五十个10个连接够用。但如果后续接了线上挂号要记得调大maximum-pool-size我给的建议是配置成20并且在高峰期观察连接池使用率。spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 300005. 常见问题排查与避坑指南5.1 号源超卖问题的排查思路我在开发阶段做过一次并发模拟用了JMeter开100个线程同时抢同一个时段目标验证超卖保护是否可靠。排查超卖问题的核心手段是同时观察Redis剩余库存和数据库的remain_number。超卖问题通常出在三个位置故障位置典型现象解决方法Redis未做预扣减数据库未被保护直接打到乐观锁给decrement增加存在性判断防止空值乐观锁失效remain_number扣减不是原子操作使用UPDATE ... WHERE remain_number 0异常回滚未补库存Redis库存被扣了但数据库回滚在catch里做increment补偿我在压测中发现过一个问题Redis库存扣到负数时还能继续扣因为decrement返回负数并不代表失败。后来我在逻辑里加了判断扣减后检查是否小于0小于0就回补并抛异常。5.2 挂号时间段冲突问题社区诊所存在一个特殊情况两个不同科室的医生共用同一间诊室。这时如果排班表没做诊室冲突校验前台的排班管理就可能出现同一诊室同一时段被排了两个医生。这个问题的排查方法很直接给doctor_schedule表增加room_id字段在保存排班时查询该诊室该时段是否已有排班记录有则拒绝保存。// 诊室冲突校验 long conflictCount scheduleMapper.selectCount(new LambdaQueryWrapperDoctorSchedule() .eq(DoctorSchedule::getRoomId, request.getRoomId()) .eq(DoctorSchedule::getWorkDate, request.getWorkDate()) .eq(DoctorSchedule::getTimeSlotId, request.getTimeSlotId()) .eq(DoctorSchedule::getStatus, 1)); if (conflictCount 0) { throw new BusinessException(该诊室当前时段已有排班); }5.3 Redis缓存与数据库一致性排队叫号场景里我用了两个缓存一个是号源剩余数schedule:stock:{id}一个是队列展示数据。前者是预扣减的核心后者是为了减轻数据库查询压力。一致性最大的风险在于号源扣减时先写了MySQL但Redis缓存没更新导致前端看到的“剩余号数”是旧的。我采用的方案是扣减时同步更新缓存不做双删策略因为这种项目的缓存更新频率低双删和延迟双删反而增加了复杂度。具体逻辑在createAppointment方法里更新数据库后立即执行redisTemplate.delete(schedule:stock: scheduleId)下次查询时直接查数据库回填缓存。这个方案在社区诊所的量级下完全够用没有出现不一致的问题。5.4 前端Vue打包放Spring Boot的踩坑记录按热词里的“vue打包放进springboot中”搜索说明不少人在部署阶段卡住了。我尝试过两种方式方式一独立部署。前端Vue打包后的dist目录放到Nginx的html目录后端Spring Boot单独跑8080端口Nginx做反向代理转发/api请求。这种方式推荐在生产使用因为前后端可以独立重启、独立升级。方式二前端静态资源放进Spring Boot。把前端打包后的dist文件复制到后端项目的src/main/resources/static目录重新打包Jar这样访问应用根路径就能直接看到前端页面。这种方式适合快速演示但有个大坑前端路由使用 history 模式时刷新页面会出现404。解决history模式刷新404的方案Configuration public class WebConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { // 将未匹配到的路径转发到首页由前端路由接管 registry.addViewController(/{path:[^\\.]*}).setViewName(forward:/index.html); } }实际操作中建议直接选方式一省去这种绕弯子的处理。Nginx配置中加上location /api/ { proxy_pass http://127.0.0.1:8080; }做反向代理简单省心。5.5 其他值得留意的细节问题身份证号唯一索引失效。患者注册时如果没做身份证校验同一个身份证号可以用不同手机号重复注册导致一人多号。处理方案注册接口做身份证查重提示“身份证已注册如需变更手机号请联系前台”。日期边界问题。排班表里的work_date只存日期不存时间避免时区转换导致跨天问题。所有日期传输统一用LocalDate前后端格式统一为yyyy-MM-dd。候诊队列跨天清理。每天凌晨执行一个定时任务把前一天状态仍为“已签到”或“就诊中”的记录强制置为“已完成”防止脏数据影响第二天的队列计算。6. 项目做完后的几点总结这个项目从设计到开发我的核心结论是社区诊所挂号系统的关键不在“挂号”本身而在“号源模型”和“状态流转”。做得顺不顺全看前期建表和状态机的设计是否清晰。回头复盘最值得称道的一个设计决策是直接用doctor_schedule.remain_number代表号源而不是生成号源明细表。这个决策让我在做乐观锁控制时逻辑清晰也减少了事务操作的数量。想模拟不同时段、不同医生的号源池只需要加一条排班记录就够了扩容成本趋近于零。另外我在实际运行的体验中还有一个细节想分享系统做完后要去诊所现场蹲半天看一天真实的就诊流程是怎样的。我第一次做完时以为流程设计得很好结果现场发现前台是拿着患者的身份证挨个核对的根本没有时间操作电脑。所以后来给前台增加了一个“扫码签到”的入口用身份证读卡器读取身份证号来绑定用户才真正解决了前台的效率问题。最后再说一个建议如果你想在这个基础上扩展方向可以是电子病历联动。把挂号系统和病历系统打通医生接诊时能直接看到患者的预约记录和历史病史。这个扩展在社区诊所场景里很实用而且技术难度不高就是在现有接口基础上增加一个病历模块的数据交互。如果后续有时间我会把排队叫号的语音播报功能也做了那个对社区诊所的老龄患者非常友好。目前这个项目已经交付给诊所用了大半年运行稳定。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑