Spring Boot儿童医院挂号系统实战:从号源扣减到Docker部署
2026/9/9 15:55:32 网站建设 项目流程

给孩子挂过号的家长应该都有体会:热门专家号一放出来,手机上转个圈就没了;窗口排队半天,被告知上午号源已满。这种痛点放到开发视角来看,就是一个很典型的“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 SQL,MyBatis的掌控感更强。

数据库用MySQL 5.7或8.0都行,8.0更推荐,毕竟已经是主流。缓存用Redis,主要扛住热门科室、医生列表、排班余号这类高频读数据。登录状态用JWT做无状态令牌,适配前后端分离和微信端小程序调用。整体技术栈就是:Spring Boot + MyBatis-Plus + MySQL + Redis + JWT + Vue,这套组合在真实项目里也特别常见。

1.3 模块化分包与目录规划

分包我建议按业务模块划分,而不是按技术层划分。什么意思?不要搞成一个controller包下面塞几十个类,而是按module来:

  • common:统一返回体、全局异常处理、工具类
  • config:RedisConfig、WebMvcConfig、CorsConfig等
  • security:JWT工具、拦截器、登录上下文
  • 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(剩余号数)、status(1启用/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捕获业务异常和系统异常,这样就不会把堆栈信息直接抛给前端了。

这里有一个很实用的建议:不要为了省事把所有错误都返回code=500,业务异常应该语义化。例如“号源不足”“登录过期”“儿童档案不存在”各自对应不同的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.active=prod"]

构建命令分两步:

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.active=prod指定环境,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.7
IDEA中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); List<Future<Integer>> results = new ArrayList<>(); for (int i = 0; i < 30; i++) { Future<Integer> 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 (Future<Integer> 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接口一起打通,后端接口自测效率会提升很多,很多联调时的问题其实在后端这侧就能提前发现。希望这篇文章能帮你把这个项目顺顺利利做出来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询