ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot儿童医院挂号系统实战:从号源扣减到Docker部署

2026/9/9 15:55:39 拓冰建站 浏览量
Spring Boot儿童医院挂号系统实战:从号源扣减到Docker部署 给孩子挂过号的家长应该都有体会热门专家号一放出来手机上转个圈就没了窗口排队半天被告知上午号源已满。这种痛点放到开发视角来看就是一个很典型的“Spring Boot儿童医院挂号管理系统”需求。它不只是简单的CRUD里面牵扯到科室与医生管理、排班发布、号源池扣减、患者建档、预约挂号、退号取消、超时释放、微信通知前后端分离联调还要考虑并发下不超卖、数据一致性和部署运维。这篇文章我就按实际开发这套系统的思路把从技术选型到上线部署的完整过程、关键代码、踩过的坑一次性讲清楚。无论你是拿它做毕设、练手项目还是真实业务改造应该都能少走很多弯路。1. 项目整体设计与技术选型1.1 医院挂号业务到底复杂在哪很多新手拿到“医院挂号系统”第一反应就是一个医生表、一个科室表、一个订单表完事。但真正去梳理儿童医院业务就会发现这里面的核心不是表多而是“号源状态流转”。一个小孩来医院看病典型的流程是家长注册账号 → 绑定儿童信息姓名、出生日期、过敏史等→ 选择科室儿内科、儿外科、儿保科、新生儿科→ 选择医生和排班时段 → 预约挂号 → 支付挂号费 → 到院签到候诊。中间还穿插着取消挂号、过期退号、爽约记录、号源释放。这些环节里最敏感的就是“号源”。专家号放出去50个就必须保证最多只能挂出50个多一个都会造成现场纠纷。而儿童医院的特点更明显上午和下午的号是分开的一个医生一周的排班是固定的夜诊和周末门诊又是另一套规则。这就决定了系统的数据模型不能只做成“订单挂在医生下面”而要把“排班”和“号源”单独拆出来设计。我当时梳理业务时把整个系统拆成了三块管理后台、用户端、通用支撑。管理后台给医院运营人员用维护科室、医生、排班查看挂号统计用户端就是家长用的注册、建档、挂号、退号通用支撑包括登录鉴权、微信模板消息通知、定时任务和操作日志。先把这些边界划清楚后面写代码才不会东一榔头西一棒子。1.2 Spring Boot版本与生态选型项目名里直接带了Spring Boot那选型这块就是重头戏。我建议使用Spring Boot 2.7.x配合JDK 8。可能有人会问为什么不上Spring Boot 3不是越新越好吗这里有一个很实际的考量Spring Boot 3.0之后强制要求JDK 17而且把javax包名全部换成了jakarta。如果项目里依赖了老版本的MyBatis、PageHelper或者其他第三方库它们没有跟上Jakarta迁移的话启动直接报ClassNotFoundException。对学生项目或者中小团队来说Spring Boot 2.7是一个生态最成熟的版本——网上资料最多遇到问题最容易搜到答案JDK 8也是绝大多数面试和开发环境里最通用的组合。Spring Boot 3也不是不能用但没必要在这个项目里给自己找额外的兼容性麻烦。持久层我选MyBatis-Plus。原因很简单单表CRUD它帮你省掉大量重复的Mapper XML分页插件一条配置就能用但多表关联比如查询医生排班时连出科室名称、医生职称自己写SQL反而更清晰。有些团队喜欢Spring Data JPA我不反对但在这种业务中“扣减号源”这种精细的update SQLMyBatis的掌控感更强。数据库用MySQL 5.7或8.0都行8.0更推荐毕竟已经是主流。缓存用Redis主要扛住热门科室、医生列表、排班余号这类高频读数据。登录状态用JWT做无状态令牌适配前后端分离和微信端小程序调用。整体技术栈就是Spring Boot MyBatis-Plus MySQL Redis JWT Vue这套组合在真实项目里也特别常见。1.3 模块化分包与目录规划分包我建议按业务模块划分而不是按技术层划分。什么意思不要搞成一个controller包下面塞几十个类而是按module来common统一返回体、全局异常处理、工具类configRedisConfig、WebMvcConfig、CorsConfig等securityJWT工具、拦截器、登录上下文module/department科室管理module/doctor医生管理module/schedule排班与号源module/patient儿童建档module/register挂号订单module/task定时任务超时释放号源等这样每个业务模块内部再拆controller、service、mapper、entity代码结构清晰出了问题定位很快。而且后续要加“体检预约”“疫苗预约”这类新业务直接再加一个module就行不影响原有代码。2. 数据库设计与号源扣减模型2.1 核心表结构梳理数据库设计是这套系统能不能写顺的关键。这里我给出几张三张核心表的要点不是完整DDL但字段设计思路是照着这个来的。第一张儿童档案表child。儿童医院的最大特点是挂号主体和支付主体分离。孩子没有手机号、没有支付账号所以必须有一个parent_user表然后再挂child表。child表的核心字段包括child_name、gender、birth_date、height、weight、allergy_history过敏史、id_card有就填没有不强求。有的儿童医院还会记录监护人与儿童的关系例如父亲、母亲、其他。这块设计好了后面挂号、开药、电子病历都能复用。第二张排班表schedule。它的作用是把“医生在某天某个时段放多少号”这种业务规则落地。我设计的字段主要有schedule_id、doctor_id、department_id、schedule_date出诊日期、period_type上午/下午/晚班、total_count放号总数、remain_count剩余号数、status1启用/0停用、version乐观锁版本。这里需要特别注意remain_count是核心并发控制字段后面讲扣减时会详细说。第三张挂号订单表register_order。这张表除了order_id、user_id、child_id、schedule_id、fee、status之外一定要冗余doctor_name、department_name、visit_date、visit_period这些快照字段。为什么冗余因为医生可能调整科室甚至离职但你历史订单里的就诊信息不能跟着变。查询时也不用天天去连医生表和科室表统计报表直接从这个表里出就很快。2.2 号源扣减为什么不能用“先查再扣”这是整个系统里最容易踩坑的技术点我必须单独拿出来强调。很多人第一版写扣号源是这样的逻辑先select查询remain_count判断大于0然后再update减去1。这个逻辑在测试环境单用户跑完全没问题一旦并发上来两个请求同时读到remain_count是1然后都去update减1最终结果变成-1——超卖了。正确的做法是把判断和扣减放到同一条SQL里用数据库的原子性保证不超卖UPDATE schedule SET remain_count remain_count - 1, version version 1 WHERE schedule_id #{scheduleId} AND remain_count 0 AND status 1这句话的意思是只有剩余号数大于0且排班状态正常时才允许扣减扣减成功后受影响行数为1我们再继续创建挂号订单如果受影响行数为0说明号已经没了直接抛出“号源已被抢完”的异常。注意update语句本身会锁行这里是行级锁并发时两个请求会排队执行不会出现超卖。这里完全没有必要手动加synchronized或者分布式锁——数据库本身就是最可靠的锁。只有当同一个排班、多个服务实例同时扣减时数据库的行锁同样生效不需要额外做Redis锁。如果业务量真的很大Redis也扛不住频繁写数据库可以改成“Redis预扣库存 异步落库”但那个方案复杂度提升了一大截需要处理Redis宕机、消息丢失、对账补偿一堆问题。对于儿童医院挂号的量级数据库原子扣减完全够用别过度设计。2.3 排班库存与状态机设计排班不是医生随便哪天都能出诊的医院通常按周维护排班规则。比如儿内科的张医生每周一、三、五上午在普通门诊放30个号每周日上午在专家门诊放15个号。设计时可以在排班表里加一个schedule_type字段区分普通号、专家号、特需号每个类型价格不同。挂号订单的状态流转也要提前设计清楚我用状态码定义0待支付1已支付/已预约2已取消用户主动取消3已完成已就诊4已退号就诊前退号5爽约未取消也未就诊用户在小程序上点击“取消挂号”状态由1变为2或4如果超过规定时间无法线上取消只能去窗口处理每天凌晨定时任务把“待支付超过15分钟”的订单改成已取消并归还号源。这就是一种状态机设计写业务代码前先把这个流转图理顺后面每个接口的状态判断就非常清晰。3. 核心功能落地与实战细节3.1 JWT登录鉴权与微信端对接用户端我选择小程序或者H5接入微信登录。整体流程是前端调用wx.login拿到code后端拿code去微信接口换openid和session_key然后把openid作为用户的唯一标识。首次登录时自动注册后续登录直接签发JWT令牌。JWT的好处是无状态后端不用存session适合前后端分离部署。我的实现里拦截器会拦截所有接口除了少数白名单登录接口、Swagger文档、静态资源、微信回调接口等。白名单配置用一个List维护需要放行时直接往里加。拦截器代码的核心逻辑如下伪代码级示意public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.isBlank(token)) { throw new BusinessException(401, 未登录); } LoginUser user JwtUtil.parseToken(token); UserContext.set(user); return true; } }这里有两个细节容易踩坑。第一token过期时间不要太长我建议7天但移动端用户登录一次不容易所以加一个“刷新令牌”接口在token快过期时自动续期。第二文件上传的静态资源也要放行否则医生头像加载失败。Swagger这块Spring Boot 2.7用springfox或springdoc都行。配置好之后登录接口和业务接口都能在页面直接调试。热词里也总提到“JWT放开Swagger”核心就是让Swagger相关路径绕过JWT校验否则你怎么点都报401这一点非常影响开发体验。3.2 排班发布与挂号主流程排班发布是管理后台最重要的功能。运营人员选择科室、医生、日期、时段、放号总数提交后生成一条schedule记录。这里有一个边界条件同一个医生在同一天同一个时段不能重复排班所以入库前要做唯一性校验我是用doctor_id schedule_date period_type组成唯一索引兜底的。接下来是挂号主流程拿用户端操作来说明。第一步家长选择科室我建议使用Redis缓存科室列表key设计为department:list:enabled设置缓存30分钟。科室信息基本不变不用担心缓存一致性问题。第二步选择医生和排班。热门医生的排班数据也会缓存key设计为schedule:doctor:{doctorId}:{date}但这里要多想一步——缓存了余号后用户看到的余号可能不是最新值。所以前端展示的余号可以做“延时刷新”真正点击挂号时后端强制走数据库扣减确保最终一致。我的方案是列表页展示缓存余号但下单接口每次都查数据库最新余号并原子扣减。第三步创建挂号订单并支付。这里涉及事务我在service方法上加Transactional扣减号源、创建订单、生成支付单三个操作必须在同一个事务里。如果支付成功回调后修改订单状态又是另外一个事务。这里特意提一个典型的Spring事务失效问题类内部方法自调用时Transactional会失效。比如RegisterService里一个方法调用了同一个类的另一个带Transactional方法此时事务切面不会生效因为代理对象没有走进去。我当时排查一个“订单创建了一半号源却没扣成功”的问题就是这种原因。解决方法很简单把事务方法拆到另一个Service类里注入调用或者自己从Spring容器里拿代理对象。这个问题在真实开发里太常见了面试也经常问。3.3 定时任务超时未支付自动释放号源电商场景里订单15分钟不支付自动关闭挂号也是一样的逻辑。我在项目里用Spring的Scheduled实现了定时任务每分钟扫描一次所有“待支付”状态的订单如果创建时间超过15分钟就把订单状态改为“已取消”同时归还号源。归还号源的SQL和扣减正好相反UPDATE schedule SET remain_count remain_count 1, version version 1 WHERE schedule_id #{scheduleId}这里要注意释放号源必须判断订单当前仍是“待支付”状态否则可能出现用户已经支付成功了定时任务却把号源归还了的情况。所以更新订单状态的SQL也要带状态条件UPDATE register_order SET status 2, cancel_time NOW() WHERE order_id #{orderId} AND status 0更新受影响行数为1才去归还号源。如果项目部署了多个实例Scheduled会导致每个节点都执行一次任务。一台机器释放了号源另一台也释放一次余额就会错误地变多。解决思路有两个引入分布式任务调度框架如Quartz的集群模式、XXL-JOB或者简单点用Redis的setnx做分布式锁。我在单机部署阶段直接用Scheduled后面如果要做成多实例再加Redis锁就行。这里也顺便聊一句不要在这个阶段引入PowerJob之类的重型调度中间件功能虽然强大但对于一个挂号系统来说不是最迫切的需求。3.4 微信模板消息通知用户挂号成功、取消挂号、就诊前提醒这些都应该给家长推送微信服务通知。实现上依赖小程序的订阅消息接口需要用户在小程序里主动授权一次才能发送一条。我在挂号成功接口里调用subscribeMessage.send传入formId或者一次性订阅消息的模板ID。需要注意服务端的access_token要缓存并设置提前刷新的机制避免频繁调用getAccessToken接口被微信限流。消息服务最好单独做一个Feign或HTTP客户端封装因为这不是核心流程它挂了不能影响主业务。我的做法是发送消息时catch异常并记录日志不向上抛出。挂号成功但通知失败用户至少可以在小程序里查到订单不会造成功能不可用。4. 前后端联调与部署发布4.1 跨域、统一返回体与全局异常前端用Vue开发默认端口是5173或者8080后端运行在8080两者不同源所以必须处理跨域问题。最直接方式是后端配置CorsFilter允许所有来源、所有请求头、所有方法但有两点需要注意允许携带凭证时不能使用通配符必须写成具体origin如果走Nginx反向代理则不需要后端跨域配置。我统一封装的返回体是这样的{ code: 200, message: success, data: { } }所有Controller都返回Result而不是裸数据。前端axios响应拦截器里统一判断code非200时弹出错误提示。全局异常处理器用RestControllerAdvice捕获业务异常和系统异常这样就不会把堆栈信息直接抛给前端了。这里有一个很实用的建议不要为了省事把所有错误都返回code500业务异常应该语义化。例如“号源不足”“登录过期”“儿童档案不存在”各自对应不同的code前端可以精准弹窗或跳转。4.2 Docker打包与Docker Desktop实战部署阶段我用Docker把后端打成镜像配合Docker Compose把MySQL、Redis、App三个服务串起来。本地用Docker Desktop在Windows上开发调试非常方便但热词里也总有人问“Spring Boot打包到Docker Desktop总是失败”最常见的原因就两个Dockerfile路径不对、镜像源拉不下来。我的基础Dockerfile是这样写的FROM openjdk:8-jre-alpine COPY target/hospital-register.jar /app.jar EXPOSE 8080 ENTRYPOINT [java, -jar, /app.jar, --spring.profiles.activeprod]构建命令分两步mvn clean package -DskipTests docker build -t hospital-register:1.0 .重点提醒JAR包必须先用Maven构建出来Dockerfile里只是把JAR复制进镜像不要指望docker build时还能帮你跑Maven构建很多新手就是这一步绕进去了。数据库和Redis用docker-compose起version: 3 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: hospital ports: - 3306:3306 volumes: - mysql-data:/var/lib/mysql redis: image: redis:6.2 ports: - 6379:6379 app: build: . ports: - 8080:8080 depends_on: - mysql - redis生产环境里MySQL和Redis不建议用容器方式跑数据但开发环境这样搞特别省事。如果你的MySQL数据要保留volume挂载别忘不然容器一删数据全没了这是我见过的最高频事故之一。4.3 多环境配置与资源映射Spring Boot里我建了三个配置文件application.yml公共配置、application-dev.yml本地开发、application-prod.yml生产。数据库、Redis地址、日志级别、公众号AppId这些按环境区分。启动时用--spring.profiles.activeprod指定环境Docker方式部署时就在ENTRYPOINT里加这个参数。资源映射这块上传的医生头像、儿童病历图片不能放在项目内部路径因为每次重新打JAR包就会被覆盖掉。正确做法是在配置文件里指定一个外部存储目录然后通过继承WebMvcConfigurer下的addResourceHandlers把本地磁盘路径映射成URL访问。例如registry.addResourceHandler(/upload/**) .addResourceHandler(file:D:/data/upload/);这样用户访问http://ip:8080/upload/doctor/xxx.jpg时实际上是读磁盘上的文件。运维同学想备份图片直接备份那个目录就行不用动JAR包。5. 常见问题与排查技巧实录5.1 版本与依赖排查这个项目开发过程中团队里最容易遇到的启动问题就是“版本不兼容”。我整理一张速查表按现象、可能原因、解决方向来列几乎覆盖了Spring Boot新手阶段的高频报错。现象可能原因解决方向启动报Failed to configure a DataSource没配置数据源或配置中心未生效检查application.yml中的url、username、password确认MySQL已启动javax.servlet下找不到类Spring Boot 3.x包名变更改用jakarta.servlet或者回退到Spring Boot 2.7IDEA中application.yml不自动提示项目没有被识别为Spring Boot工程右键项目 → Add Framework Support → Spring Boot或安装Spring Assistant插件Maven依赖一直downloading后卡死访问中央仓库慢或网络问题在settings.xml配置阿里云镜像删掉本地.lastUpdated文件后重新下载MyBatis-Plus自动填充不生效没配置MetaObjectHandler新增配置类并实现insertFill、updateFill方法特别说一句POM依赖下载的问题。如果是在Eclipse里集成MyBatis时一直卡在downloading多半是网络源的问题。不要反复重启IDE而是直接配置镜像源一劳永逸。5.2 并发扣减与事务边界排查号源超卖问题我在系统上线之初遇到过。一次预约高峰期数据库里某专家号余额居然变成了-2。排查后确认不是因为UPDATE原子性不够而是我在扣减成功后创建订单时发生了异常异常的事务里包含了update回滚理论上应该没问题——但问题出在一个地方事务方法内部catch了异常直接return事务切面拿不到异常信号导致update操作没有回滚却又执行了下一步导致数据错乱。正确的处理方式是事务方法内不要自己用try-catch吞掉需要回滚的异常让事务管理器感知到RuntimeException并触发回滚。如果确实需要catch就手动设置TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()或者把异常重新抛出。另外循环依赖这个问题也值得提一句。Spring Boot 2.6开始默认禁止循环依赖如果两个Service互相注入启动直接报错。我早期写ScheduleService和RegisterService双方因为都要查对方的方法就出现了这种循环引用。解决方式不是加Lazy敷衍了事而是把公共方法下沉到第三个类或者用构造器注入把依赖关系捋直。这本质上是个设计问题不是配置问题。5.3 单元测试与并发回归验证热词里有“Spring Boot单元测试最佳实战”这里我一定要给一个实际案例怎么验证号源扣减不会超卖。我用JUnit写了一个并发测试模拟30个用户同时抢10个号Test void testConcurrentRegister() throws InterruptedException { CountDownLatch latch new CountDownLatch(1); ExecutorService pool Executors.newFixedThreadPool(30); ListFutureInteger results new ArrayList(); for (int i 0; i 30; i) { FutureInteger future pool.submit(() - { latch.await(); try { return registerService.createOrder(doctorId, scheduleId, userId, childId); } catch (Exception e) { return 0; } }); results.add(future); } latch.countDown(); int success 0; for (FutureInteger f : results) { success f.get(); } Schedule schedule scheduleMapper.selectById(scheduleId); Assertions.assertEquals(schedule.getRemainCount(), 10 - success); Assertions.assertTrue(success 10); }这个测试跑一次就能直观看到是否有超卖。我在真实项目里还把它写进了CI流水线每次提交代码都能自动回归防止有人改SQL时把原子扣减改坏。5.4 数据库连接池与性能调优系统运行一段时间后出现过接口偶发变慢的情况。排查发现是MySQL连接池默认配置太保守高峰期连接被占满新请求排队等待。Spring Boot默认使用HikariCP连接池我在配置里调了三个参数spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000另外挂号订单表的数据增长很快我给order_no、schedule_id、user_id和status分别建了索引并定期清理超过一年的历史订单到归档表。列表查询如果每次都走全表扫描再好的硬件也会被拖垮。热词里问“Spring Boot项目中对数据库用户密码加密”“SM4加密数据库密码”之类的问题我只补充一句生产环境数据库密码一定不要明文写在配置文件里用Jasypt或者配置中心统一管理这个属于上线前的必改项。结尾这套系统做下来我个人最大的体会是Spring Boot本身不难难的是把业务流程吃透并把并发边界想清楚。儿童医院挂号管理的核心不在代码量而在号源模型、状态流转和事务边界这几处关键设计上。如果你正在做同类项目我建议先把排班表、订单表、号源扣减SQL这三块打扎实再去研究JWT、Redis、定时任务这些外围能力。另外再分享一个小技巧开发时把Swagger和前端Mock接口一起打通后端接口自测效率会提升很多很多联调时的问题其实在后端这侧就能提前发现。希望这篇文章能帮你把这个项目顺顺利利做出来。