上一段时间帮一个师弟改代码,他毕业设计选了个医院预约挂号系统的题目,拿来的是网上常见的Spring Boot单体项目,结果一跑起来全是问题:数据库脚本导不进去、前端配好了接口不通、抢号逻辑在高并发下一测就超卖。我帮他整整调了两天,过程中把这类项目里最容易踩的坑都过了一遍。今天这篇就把这个“基于Spring Boot医院预约挂号系统”从头到脚拆开讲透,从架构设计、核心表结构、关键业务流程到部署上线,每一步都给你说明白“为什么这么做”,而不是只贴代码。这篇内容无论你是拿它做毕业设计、课设,还是想真正落地成一个可用的挂号平台,都能直接照着做。
1. 从需求到设计:医院预约挂号系统的整体架构怎么搭
1.1 最常见的单体架构还是微服务?
先说结论:绝大多数中小型医院预约挂号系统,单体架构完全够用,SaaS化的、集团化的才需要认真考虑微服务。很多人一上来就想着拆用户服务、订单服务、支付服务、排班服务,结果光搭基础设施就耗掉一大半时间,服务拆了但没拆出价值,还要承担分布式事务、链路追踪的复杂度。
这个项目既然是基于Spring Boot的,那架构核心就是“一个应用跑全部业务”,对外提供RESTful API,前端分两个端:用户端(H5/小程序/App)和管理端(Vue后台管理)。用户端负责注册登录、搜索医生、选择科室、预约挂号、支付、退号退费;管理端负责医院科室管理、医生排班、号源池配置、停诊处理、订单查询、数据统计。
我实际验证下来,这个架构最舒服的点在于:Spring Boot的自动装配把配置简化到极致,一个应用启动就包办了所有业务模块;虽然代码层面是单体,但包结构上一定要按模块分清楚。我习惯按这种包结构组织:
text复制com.hospital.registration
├── controller # 接口层
├── service # 业务逻辑层
├── mapper # MyBatis数据访问层
├── entity # 数据库实体
├── dto # 前端交互数据对象
├── vo # 视图对象
├── config # 配置类(跨域、拦截器、Redis、Swagger等)
├── common # 工具类、统一返回、异常处理
└── task # 定时任务
这样做的目的只有一个:以后真要拆微服务时,按照包边界直接抽模块,改造成本低,而且排查问题时能迅速定位到对应层次,不会出现一个Controller里堆几百行业务逻辑这种“贫血+混乱”的情况。
1.2 技术栈选型的关键判断
Spring Boot项目最核心的选型点有三个:建木框架、持久层框架、缓存组件。建木框架不用多说,Spring Boot默认SSM(Spring Boot + Spring MVC + MyBatis)是这套系统的标准答案,因为网上90%的资料和现成源码都基于这个组合。
持久层我用的是MyBatis-Plus,原因很朴素:单表CRUD不用写XML,自带分页插件,代码生成器也能直接生成entity、mapper、service。对于预约挂号这种大量单表操作的场景,效率提升非常明显。复杂查询(比如多条件联合搜索医生排班)再手写XML,两者结合刚刚好。
缓存组件用Redis,作用非常大:验证码存储、Token会话保持、号源预扣减。后面讲并发时你会看到Redis在防超卖里是绝对主角。
还有一个很多初学者忽略了,但生产环境必须处理的:接口文档。我推荐用Springfox或springdoc自动生成Swagger文档,前后端联调时不用再手动写接口说明文档,直接在网页上就能调试每个接口。这个项目里配置完Swagger后,整个调试效率能提升一半以上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库设计与核心表结构:预约挂号最关键的表
2.1 围绕一次预约,需要哪些核心表
很多同学设计这类系统的数据库时,上来就设计一个“医院表”然后里面塞科室、医生、排班、价格,恨不得一张表搞定一切,这是典型的“不会拆表”。医院预约挂号系统的核心数据域可以拆成以下几个模块:用户与就诊人、科室与医生、排班与号源、订单与支付流水、日志与配置。
用户与就诊人为什么要单独拆表?因为实际挂号的人不一定是账号本人,比如给父母挂号、给孩子挂号,一个账号可以绑定多个就诊人。用户表存的是登录凭证,就诊人表存的是实名信息和医保信息。
科室与医生拆开是医院业务的常识:一个科室有多个医生,一个医生属于一个科室。排班表是预约系统的灵魂,它决定了哪一天、哪个时间段的哪个医生可被预约,以及该时段剩余多少号源。
订单表和支付流水表要严格区分:订单表管业务状态(待支付、已支付、已取消、已完成),支付流水表管资金状态(支付中、支付成功、退款中、退款成功)。业务状态和资金状态混在一起的话,后面对账、退款、异常处理会异常痛苦。
我实际用的核心表结构大致如下:
sql复制CREATE TABLE `user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`openid` varchar(64) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`password` varchar(64) DEFAULT NULL,
`nickname` varchar(64) DEFAULT NULL,
`status` tinyint(4) DEFAULT '1',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `patient` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) DEFAULT NULL,
`name` varchar(32) DEFAULT NULL,
`id_card` varchar(18) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`gender` tinyint(4) DEFAULT NULL,
`birthday` date DEFAULT NULL,
`relation` varchar(16) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `schedule` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) DEFAULT NULL,
`doctor_id` bigint(20) DEFAULT NULL,
`dept_id` bigint(20) DEFAULT NULL,
`schedule_date` date DEFAULT NULL,
`period_type` tinyint(4) DEFAULT NULL COMMENT '1上午 2下午',
`total_count` int(11) DEFAULT NULL,
`remain_count` int(11) DEFAULT NULL,
`fee` decimal(10,2) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `registration_order` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) DEFAULT NULL,
`user_id` bigint(20) DEFAULT NULL,
`patient_id` bigint(20) DEFAULT NULL,
`schedule_id` bigint(20) DEFAULT NULL,
`doctor_id` bigint(20) DEFAULT NULL,
`dept_id` bigint(20) DEFAULT NULL,
`visit_date` date DEFAULT NULL,
`period_type` tinyint(4) DEFAULT NULL,
`serial_no` varchar(16) DEFAULT NULL,
`status` tinyint(4) DEFAULT NULL COMMENT '0待支付 1已支付 2已取消 3已完成 4已退号',
`create_time` datetime DEFAULT NULL,
`pay_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.2 号源与排班模型:怎么避免“挂不上号”和“超卖”
这是整个预约系统里最容易做错、也最值得多花时间设计的地方。号源的核心是“排班模板 + 放号规则”:管理员配置好一个医生的周排班模板(比如周一上午出诊、周五下午出诊),系统定时生成未来一周或两周的具体排班记录,并初始化总号数和剩余号数。
很多人直接把“剩余号数”做成一个简单的数字字段,预约成功就减1。单机低并发没问题,一旦多人同时抢同一个医生同一个时间段的号,就会出问题。两个请求同时读到remain_count=1,同时执行减1操作,都认为预约成功,数据库最终变成0甚至负数——这就是经典的超卖问题。
防超卖我在这个项目里用了两层方案。第一层:Redis预扣减,预约接口先操作Redis里维护的号源库存,用Redis的原子自减操作,返回值大于等于0才允许继续创建订单,否则直接返回“号源不足”。第二层:数据库条件更新兜底,创建订单时执行一条带条件的UPDATE语句:
sql复制UPDATE schedule SET remain_count = remain_count - 1
WHERE id = ? AND remain_count > 0
返回影响行数为1才说明真的扣减成功,否则回滚事务。两层都过了,订单才真正落库。这套方案实测在几百并发下依然稳定,完全满足预约挂号场景。
3. 核心业务实现:从登录到出号的完整链路
3.1 用户登录与就诊人管理
用户登录这块,小程序端我会优先接微信登录(openid),Web端/H5端用手机号+密码登录。登录成功后签发JWT Token,前端每次请求带上Token,后端通过拦截器统一鉴权,未登录的请求直接返回401。
JWT的好处是服务端无状态,但有一个地方要特别注意:Token过期和用户被禁用后Token仍可能有效。所以我在这套系统里用Redis做了二次校验:登录时把Token的key和用户ID存进Redis,每次请求拦截器检查Redis里是否存在这个Token,管理员强制下线时直接删Redis里的key即可。
就诊人管理的核心校验是身份证号格式校验和手机号格式校验。身份证号不只是看位数,还要做加权因子的校验,防止用户填了一个格式不合法但看起来像真的号码。保存就诊人信息后,还要做“实名认证”这一步:调用第三方的身份核验接口,名称和身份证号一致才算认证通过。个人开发者在真实商用环境中可能要依赖第三方服务,但只要做毕设或学习演示,做到格式校验层面就够了。
3.2 医生排班与号源扣减的实现细节
排班的展示是整个系统用户感知最强的地方,一定要分层查缓存:科室列表、医生列表这类低频变更数据放Redis缓存,设置合理过期时间(比如10分钟),减少数据库压力。但号源余量必须走实时查询,因为它是强实时数据,挂了Redis库存之后,余量直接从Redis读取,性能很高。
我写这段逻辑时顺手整理了一个完整的预约流程,给当时的师弟当参考:
- 前端选择科室 -> 展示医生列表
- 选择医生 -> 展示排班日期和时段
- 选择就诊人 -> 确认订单信息
- 创建订单(此时先锁号源/预扣库存)
- 唤起支付(微信支付/支付宝支付/模拟支付)
- 支付成功后更新订单状态为已支付
- 就诊当天到院取号,系统更新为已完成
第4步是核心,订单创建时号源锁定必须和库存扣减在同一个事务里,同时把库存写入Redis。这里务必要注意顺序:先预扣Redis库存,再创建订单并更新数据库,最后失败要回滚Redis库存。实际操作中,很多项目栽在“订单失败但Redis库存没恢复”这个点上。稳妥做法是把Redis回滚也放在事务的finally逻辑里,并且通过对比数据库中的实际剩余号数和Redis剩余号数做定期校准。
3.3 支付回调与取消退号
支付环节是最容易被轻视、但生产环境问题最多的模块。自己接微信支付或支付宝支付时,必须重视“异步通知”这个链路:支付平台会往你配置的回调地址发通知,你的回调接口必须做三件事——验签、判断订单状态、幂等处理。
验签是第一步:用支付平台的公钥验证回调消息的签名,防止伪造回调。第二步是幂等:同一个订单可能会收到多次支付成功通知,如果回调里无脑把订单改成已支付,就会出问题。正确做法是先查订单当前状态,只有当前状态是待支付时才更新成已支付,否则直接返回成功。
退号退费的规则也要提前想清楚,我在这个项目里做了这样的配置:就诊日前一天23点之前可以自助退号,全额退款;就诊日当天不可线上退号,需要去医院窗口处理。退号时不只是把订单状态改成已退号,还要把对应排班的剩余号数加回1个,同时更新Redis库存。这个“库存回补”也很容易漏写,漏了就会出现一个号被永久吞掉、其他患者永远挂不上该时段的号。
4. 项目实操:从源码到本地跑通
4.1 环境准备与版本选择
很多初学者第一次拿到源码就跑不起来,80%是因为Java版本和Spring Boot版本不匹配。Spring Boot 2.x系列基于Java 8,Spring Boot 3.x系列强制要求Java 17及以上,而且包名从javax迁移到jakarta,导致很多老代码直接编译不过。网上很多源码是Spring Boot 2.x的,你本地装了JDK 17,一跑就报错,就开始怀疑项目有问题。实际上就是版本匹配的事。
我的建议很直接:如果拿来的是Spring Boot 2.x项目,老老实实装JDK 8,配置好Maven 3.6+,再用IDEA打开;如果项目本身就是Spring Boot 3.x,那JDK 17是必须的。个人实际开发中,JDK 8 + Spring Boot 2.7.x是我最推荐的组合,稳定、资料多、各种中间件兼容性最好,网上现成接好的轮子也最丰富。
环境变量要检查的包括JAVA_HOME、MAVEN_HOME和PATH。IDEA打开项目后,Maven会自动读取pom.xml下载依赖,但因为国内网络访问Maven中央仓库慢,建议在Maven的settings.xml里配好阿里云镜像,下载速度能提升一个量级。
4.2 配置文件与数据库初始化
拿到项目后,先看resources目录下的application.yml。我用标准化模板整理了这套系统的关键配置项:
yaml复制server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/hospital_registration?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
redis:
host: localhost
port: 6379
database: 0
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
redis:
key:
token: 'user:token:'
schedule: 'schedule:stock:'
数据库初始化这里有个高发坑:网上流传的SQL脚本文件很多是老版本字符集,默认是latin1,导致中文全部变成问号。执行SQL文件前务必将脚本头部加上SET NAMES utf8mb4;建库时也指定字符集:
sql复制CREATE DATABASE hospital_registration DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
另外,MySQL 8.0的驱动是com.mysql.cj.jdbc.Driver,5.x是com.mysql.jdbc.Driver,如果项目用的Maven依赖是mysql-connector-java 8.0以上,driverClassName却写成老驱动,会直接连接失败。
4.3 前后端联调与本地部署
用户端和管理端一般是独立的前端项目,多半是Vue2或Vue3。开始联调前先确认后端接口的上下文路径和端口,再看前端配置文件里有没有后端API地址的代理设置。我习惯在vue.config.js里配置devServer代理,避免前端直接跨域:
javascript复制devServer: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
pathRewrite: { '^/api': '' }
}
}
}
如果后端允许跨域,也可以在Spring Boot里加全局配置,但代理方式更接近生产环境的表现。
把前端项目跑起来后,就用Swagger地址(比如http://localhost:8080/swagger-ui/index.html)逐个接口测试。先注册账号,再绑定就诊人,然后走一遍“查科室 -> 查医生 -> 查排班 -> 创建订单 -> 模拟支付”的链路,确认核心流程通了,再开始测退号、库存回补这些边界场景。测试时不建议用真支付,我在项目里封装了一个模拟支付接口,开发环境直接打这个接口把订单改成已支付,联调效率会高很多。
真正上线部署时,我建议这样拆分:后端打包成jar包,配合systemd或直接java -jar跑,数据库和Redis用Docker Compose统一管理,前端静态文件交给Nginx托管并代理/api请求到后端端口。很多人一上来就非要搞容器化全部塞进Docker,结果浪费时间折腾Dockerfile,其实先搞明白“一个应用一个端口”的传统部署,再上容器,循序渐进效果更好。
5. 常见问题与排查技巧实录
5.1 运行启动阶段:起不来的几种典型原因
问号、乱码、连接失败是启动阶段最常见的三座大山。数据库乱码基本就是字符集问题,检查库、表、连接串三处是否都用了utf8mb4;IntelliJ IDEA里控制台中文乱码,一般要在Help -> Edit Custom VM Options里加一条-Dfile.encoding=UTF-8,再重启IDEA。
Redis连不上的检错顺序是:Redis服务是否启动、端口是否放通、配置文件host和port是否写对、Redis是否有密码保护且配置里没填密码。一个很隐蔽的问题是Redis版本太高,默认配置不允许外部IP连接,只允许本机,这时候检查redis.conf里的bind和protected-mode配置。
5.2 部署上线阶段:接口404、静态资源加载不出来
前端页面能打开但接口404,第一反应是查代理配置的target是不是指向了后端实际端口。Spring Boot默认context-path是根路径,如果你在配置里加了context-path,前端代理的路径必须同步修改,否则全部404。
静态资源加载不出来的问题也很典型:如果你把前端上传的图片放到本地磁盘,Spring Boot默认是不会把这些外部目录映射成可访问URL的。需要自定义一个资源映射配置:
java复制@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/upload/**")
.addResourceLocations("file:" + uploadPath + "/");
}
}
这段配置是网上被问烂的问题:“图片传上去了但不显示”。原因就是没有做资源映射,值的一提的是JDK8版本的JDK内置的file协议写法是file: + 路径,注意结尾斜杠别丢。
5.3 高并发预约场景:超卖、库存不一致怎么确认
超卖问题用前面说的Redis预扣减+数据库条件更新两层就挡住了。但如果还是出现库存负数,排查思路是这样的:先看是不是没有加数据库条件更新兜底;再看Redis和数据库库存是否一致,因为Redis挂了或者重启后缓存没了,会导致库存凭空恢复成初始化值。
我在项目里写了一个定时任务,每5分钟校准一次库存:拿数据库的剩余号数和Redis库存做比对,不一致就以数据库为准覆盖Redis。另外所有扣减号源的操作,日志里必须记录scheduleId、剩余数、操作时间、来源请求ID,排查并发问题时,这些日志就是唯一的线索。
还有一个很容易忽略的Bug:退号回补库存时,如果用户重复提交退号请求,且接口没做幂等处理,会把库存额外加回去,导致号源被放多。所以退号接口必须校验订单当前状态,只有已支付/已完成的状态才能退,且退号成功后订单状态立刻改成已退号。两次请求第二个就会因为状态不对直接返回失败,库存自然不会重复增加。
6. 从会运行到上线:还能往哪些方向优化
6.1 安全与合规:不只是登录加个密那么简单
预约挂号系统涉及患者真实姓名、身份证号、手机号等个人敏感信息,数据安全这块必须有意识。基本底线是:数据库密码不要明文写在配置里,至少要用环境变量注入;患者身份证号、手机号在数据库中做加密存储或脱敏展示,返回前端时身份证号只显示前3位后4位;接口访问要加权限控制,用户只能查看自己名下的订单和就诊人。
HTTPS是商用的强制项,HTTP明文传输会将用户敏感信息完全暴露,尤其在小程序端,微信官方要求所有请求必须HTTPS。开发环境下可以不管,但一旦涉及真实商用,第一步就是套证书、配HTTPS。
6.2 性能与体验优化:缓存、索引、异步三条路
预约挂号系统并发峰值集中在放号那一刻(比如每天早上8点放7天后的号),这时短时间高并发访问是最大的性能压力。除了库存扣减用Redis,我还建了两个优化方向:
第一,给高频查询的字段建立复合索引,比如schedule表的doctor_id + schedule_date,registration_order表的user_id + status和order_no唯一索引。数据库一旦数据量上来,没有索引的全表扫描是灾难。
第二,支付成功后的通知类操作尽量走异步,比如发送短信通知、写入站内信,这些不影响主流程的耗时操作应该放到线程池或消息队列里执行,而不是让用户支付完一直等着。
6.3 从单体演进到微服务:什么情况下才值得拆
有些同学喜欢加分项,上来就把系统拆成一堆微服务,结果自己又驾驭不住。我的建议是:单体先跑通,再思考拆分。当你发现某个模块的团队协作冲突频繁、某个模块需要独立扩缩容、某个模块有明显的独立生命周期时,再考虑拆。预约挂号系统里最值得独立出去的模块是“支付中心”和“号源库存中心”,因为它们对其他模块的依赖最少、被复用频率最高。如果只是个人项目或课程设计,单体应用写完核心功能加一个缓存优化,已经能说明全部技术实力,硬拆反而暴露对微服务掌控力不足的短板。
从我自己帮人改这个项目的经验来看,一套预约挂号系统与其说是CRUD堆积,不如说是一次对并发控制、事务边界、缓存一致性、支付回调幂等这些基本功的综合检验。把这些模块吃透,举一反三去写任何“预约类”业务都能快速上手,这才是源码和部署文档之外真正值钱的东西。
