SpringBoot+Vue医院挂号系统:表结构、并发扣号与部署实战 前阵子帮一位朋友把一套线上医院挂号系统从零整理成能直接运行的状态整个过程走下来最大的感受是这类项目听起来就是标准三件套SpringBoot 做后端、Vue 做前端、MySQL 存数据真动手时却发现难点全在业务细节上。三类角色看到的界面完全不同预约订单和排班号源之间的关系不能靠普通 CRUD 糊弄过去前端和后端的字段如果不事先对齐联调阶段能把人磨到怀疑人生。这篇文章就把这套系统的设计思路、表结构、后端主流程、前端页面串联方式以及让项目真正跑起来需要处理的坑完整拆开讲一遍。适合的人群很明确想拿一个完整项目练手全栈的同学、正在准备课程设计或简历项目的学生党以及已经在做类似预约挂号系统、想对照一下表设计和并发处理的同行。你能从里面看到的不只是代码怎么写更重要的是“为什么这样设计”。1. 先盘需求挂号系统要承接的三类人和一条主线很多人拿到这类项目第一反应是“先建表、先写接口”这是最容易走弯路的开头。线上医院挂号系统表面上是信息管理本质上是一个多角色协作流程系统。如果一开始不想清楚谁在用、每一步要干什么后面改起来非常痛苦。1.1 三类角色各自要什么这套系统里用户分为三类普通就诊用户、医院端医生或工作人员、平台管理员。每一类人关心的事情完全不一样。普通用户最关心的是能不能快速找到想看的科室和医生能不能看到某个医生某天还有没有号能不能顺利预约并取消。他们不会关心排班数据怎么维护、统计报表怎么出只关心页面好不好用、操作顺不顺手。医生或医院工作人员关心的是自己某天出了什么门诊、放了多少号、哪些患者预约了、什么时候停诊。这类操作往往集中在一个工作台界面里核心诉求是“一眼看清我的排班和患者名单”。平台管理员关心的是全局数据有哪些医院和科室、有哪些医生、号源分配是否合理、预约量怎么样。管理员的操作以基础数据维护和简单统计为主。角色核心诉求对应模块典型操作普通用户快速找医生、查询号源、挂号和取消医院/科室/医生查询、挂号、我的订单按日期查看号源提交预约取消订单医生/医院办公人员维护个人排班查看预约患者排班管理、预约患者列表排班发布/停诊查看当日患者管理员基础数据维护、全局数据统计用户管理、医生管理、科室管理、统计面板审核科室信息、维护医生信息、查看预约统计1.2 一条主线从查排班到就诊完成把三类人的诉求串起来看系统的核心主线只有一条用户查医生排班有号就提交预约支付费用然后按日期去医院报到就诊完成后订单归档。所有页面和接口设计都围绕这条主线展开。这条主线有一个非常明显的特点它是有状态的。预约订单不是录入一条记录就完事它需要经历待支付、已支付、已取消、已完成等多个状态。任何一步操作失败了系统都要保证数据不乱号源要能释放订单状态要能正确回退。这也就决定了技术选型不能太随意。需要一个能处理事务的后端框架需要一个能保证并发安全的关系型数据库还需要一个交互能力足够强的前端框架来承载多角色界面。SpringBoot、MySQL、Vue 的组合在这个场景下非常合理SpringBoot 的事务管理和生态成熟MySQL 满足数据一致性要求Vue 的组件化开发能把三种角色的界面复用做得很干净。2. 数据建模把预约场景的表关系搭稳这一节是整个项目的地基。很多同学建表喜欢照搬“用户表、订单表、医生表”这样极简结构等做到中途才发现缺少排班表、没有号源字段、状态字段缺失然后回头加字段加表污染一堆代码。我这里直接给出一套能跑通闭环的表结构。2.1 六张主表的最小闭环一套完整的挂号系统至少需要六张核心表用户表、医院表、科室表、医生表、排班表、预约订单表。前四张是基础信息表后面两张是业务表。用户表存放三类角色的账号信息用角色字段区分身份。医院表和科室表做基础维度数据医生表挂在科室和医院下面排班表负责承载“某医生某天某个时段有多少号源”这种动态数据预约订单表记录每次挂号的完整链路。以下是排班表和预约订单表的简化建表语句可以直接参考CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, hospital_id BIGINT NOT NULL, department_id BIGINT NOT NULL, visit_date DATE NOT NULL, session_type TINYINT NOT NULL COMMENT 1上午 2下午, total_number INT NOT NULL DEFAULT 20, remaining_number INT NOT NULL DEFAULT 20, price DECIMAL(10,2) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_doctor_date (doctor_id, visit_date), KEY idx_date_session (visit_date, session_type) ) COMMENT 医生排班表; CREATE TABLE registration_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 订单编号, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, hospital_id BIGINT NOT NULL, department_id BIGINT NOT NULL, patient_name VARCHAR(50) NOT NULL, patient_phone VARCHAR(20) NOT NULL, visit_date DATE NOT NULL, session_type TINYINT NOT NULL, fee DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_status (user_id, status), KEY idx_schedule (schedule_id), KEY idx_doctor_date (doctor_id, visit_date) ) COMMENT 预约订单表;这里有一个关键点需要理解排班表里同时维护 total_number 和 remaining_number 两个字段。total_number 表示这个排班总共放了多少号remaining_number 表示还剩多少号。用户每次预约成功remaining_number 就减一。这样设计的好处是查询号源时不需要实时关联订单表做 count 统计直接读取一个整数就行性能压力小很多。2.2 冗余字段、索引和时区容易被忽略的三个细节细心的读者会发现我在订单表里冗余了 doctor_id、hospital_id、department_id 这些字段。按数据库三范式来说订单表似乎只需要存一个 schedule_id 就能关联出这些信息为什么还要冗余因为订单是历史记录。医生可能调整科室排班数据也可能定期清理或归档。如果订单只存一个排班 ID等排班记录被清理后用户的历史订单就查不到医生姓名和就诊科室了。把关键维度字段冗余进订单表查询订单列表时就不用关联多张表而且要谁的信息都还在。这种冗余在线下医疗系统里是非常普遍的做法。索引设计也是容易被忽略的部分。业务查询规律大致是用户查自己的订单列表、医生查某天的排班、用户查某医生某天是否有号。这些查询条件就是索引的候选字段。我上面的建表语句里已经加了几个常用索引实际开发中还可以根据查询习惯增加联合索引。但索引不是越多越好每次插入和更新都要维护索引单表数据量没上来之前优先保证核心查询走索引即可。时区问题也值得单独说。MySQL 的 DATETIME 字段本身不存时区信息SpringBoot 连接数据库时如果 URL 里没有指定 serverTimezone很容易出现“本地时间和数据库时间相差 8 小时”的问题。后面部署章节我会再提到这里先有个印象连接串里统一写 Asia/Shanghai 能避开绝大多数时间问题。2.3 演示数据怎么造才像样这套系统要支持“直接运行”初始化数据质量非常关键。很多人图省事在数据库里塞几条“测试账号、张三李四”就完事结果前端页面打开后看着特别假用户体验极差也不好展示给面试官或导师看。演示数据至少要覆盖这么几个维度两到三家不同等级和地域的医院每家医院有四到六个常见科室每个科室配两到三名不同职称的医生每个医生未来一周每天都有排班且号源数合理。预约订单也要有几种不同状态既要有已完成的历史订单也要有待支付的新订单这样前端各种状态标签才有内容可展示。造演示数据时有个小技巧不用手工逐条 INSERT可以写一个简单的数据初始化脚本用循环批量生成医生未来七天的排班记录。比如每个医生每天生成上午下午两条排班总号源随机在 15 到 30 之间剩余号源随机减掉一部分。这样打开页面后每天都能看到有号没号的情况比固定写死的几条数据真实得多。3. SpringBoot 后端挂号主链路的并发和安全设计后端是整个系统最考验逻辑严谨性的部分。表面上看要写的接口不多无非是登录注册、医生查询、排班查询、创建订单、取消订单但每个接口背后都有值得展开处理的问题。3.1 登录状态与菜单权限JWT 和拦截器各管一段登录认证我建议直接用 JWT。用户登录成功后后端生成一个包含用户 ID、角色信息、过期时间的 token 返回给前端。前端把 token 存在 localStorage 里每次请求时塞进请求头后端通过拦截器统一校验。这里的关键设计是所有接口按角色做权限控制拦截器只负责校验 token 是否合法角色权限需要再进一步判断。比如管理员接口可以要求请求头里解析出来的角色必须是管理员普通用户接口则只要求登录即可。把这两个逻辑分开维护起来会清晰很多。一个务实的建议是提前规划好哪些 URL 属于哪类权限做成白名单走拦截器。比如/api/auth/login、/api/auth/register完全放行/api/admin/**必须管理员/api/doctor/**必须是医生或管理员/api/order/**只要是登录用户就行。这样不会出现“登录了但不知道能干嘛”“管理员也挂不了号”之类的混乱。3.2 排班数据是怎么维护的排班表是整个业务的核心数据源。医生或管理员创建排班时需要同时提供医生 ID、日期、上午或下午时段、总号源数、挂号费用这几个信息。入库之后用户端的号源查询和订单创建都依赖这些数据。查询号源是访问频率最高的接口。用户选择一个日期前端请求接口返回该日期所有有排班的医生列表列表里包括医生姓名、职称、所属科室、剩余号源数和费用。这个接口要把 schedule 表和 doctor 表做关联查询但不要一次性把所有字段都查出来能用多少查多少。排班管理还有一个容易被忽略的操作停诊。医生临时有事需要停诊这不只是删掉一条排班记录那么简单。如果该排班下已经有用户支付成功的订单必须先处理这些订单要么改约到其他时段要么原路退款并通知用户。直接删排班会导致用户订单变成无源之水属于严重的业务事故。做这块逻辑时宁可提醒到位也不要让状态留在半空中。3.3 创建订单一次扣号源就是一次原子操作创建订单是这套系统最核心的接口也是并发问题最集中的地方。设想一个热门专家门诊放出 20 个号几百人同时打开页面刷新然后同一秒点提交预约后台如果只是“查一下剩余号源 0 就插入订单”那最终一定会超卖。解决方法是把扣号源和查号源合成一条原子 SQL利用数据库行锁保证同一时刻只有一个请求能成功扣减Transactional public Result createOrder(CreateOrderDTO dto) { // 1. 校验排班是否存在 Schedule schedule scheduleMapper.selectById(dto.getScheduleId()); if (schedule null) { return Result.error(排班不存在); } // 2. 原子扣减号源 int rows scheduleMapper.decreaseRemaining(dto.getScheduleId()); if (rows 0) { return Result.error(该时段号源已约满请选择其他时间); } // 3. 生成订单并落库 RegistrationOrder order new RegistrationOrder(); order.setOrderNo(generateOrderNo()); // ... 设置患者信息、医生信息、费用等 orderMapper.insert(order); return Result.success(order); }对应的 MyBatis XML 里扣减号源的 SQL 长这样update iddecreaseRemaining UPDATE schedule SET remaining_number remaining_number - 1 WHERE id #{scheduleId} AND remaining_number 0 /update这条 SQL 的关键在于最后的remaining_number 0条件。数据库在更新时会锁住这行记录第二个并发请求只能等第一个请求完成后再判断而此时剩余数已经变成 0更新影响行数就是 0服务端据此返回“已约满”。整个操作放在一个事务里扣号源失败就不会插入订单号源也不会莫名消失。用这个方法能解决 95% 以上的并发超卖问题。如果未来要支撑更大流量可以再引入分布式锁或消息队列削峰但作为一套单体架构的挂号系统数据库原子更新已经足够稳定可靠。3.4 订单状态流转与模拟支付挂号订单的状态流转是一张明确的状态图待支付可以变成已支付或已取消已支付可以变成已完成已取消是终态。每个状态变更操作都必须校验当前状态是否合法不允许从已取消直接跳到已完成也不允许待支付订单直接关闭。取消订单的逻辑比创建订单更复杂一些。用户点击取消后除了把订单状态改成已取消还要把排班表的 remaining_number 加回来把号源释放给其他用户。这两个操作必须在同一个事务里完成否则会出现用户取消成功了但号源没有恢复的 bug。实际开发时也建议判断订单是否已支付如果已支付需要联动模拟退款流程把支付状态和订单状态保持一致。支付功能在演示项目里通常做成模拟支付。前端调起一个“确认支付”弹窗后端直接生成支付成功回调把订单状态从待支付改成已支付。这样既能展示完整业务闭环又不依赖真实第三方支付渠道。如果后续要接微信或支付宝只需要把模拟支付接口替换成真实支付下单和回调验签逻辑订单表的结构几乎不用改动。4. Vue 前端把三种角色的操作路径真正串起来前端的难点不在页面多而在三种角色的操作路径完全不同。用户进来自助挂号医生进来管理排班管理员进来维护数据。如果每个模块各写各的页面不做好登录态和路由控制很容易变成一座迷宫。4.1 路由守卫和登录态处理Vue Router 的全局前置守卫是前端权限控制的起点。每次路由跳转前都检查一下本地有没有 token没有就跳到登录页。但这只是第一层第二层是页面级角色控制——管理员页面要加 meta 标记守卫里校验当前用户角色能不能进入。router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (!token to.path ! /login) { next(/login); return; } if (token to.path /login) { next(/); return; } const role localStorage.getItem(role); if (to.meta.roles !to.meta.roles.includes(role)) { next(/403); return; } next(); });这里的核心思想是路由只做前端展示控制真正的数据权限还是要靠后端接口把关。前端隐藏管理入口只是体验优化不是安全措施。一个懂接口的人完全可以绕过前端直接请求管理接口后端权限校验没做到位才是真的危险。4.2 Axios 拦截器统一处理 token 和异常前端调用后端接口时最繁琐的操作是每个请求都要带 token、每次接口报错都要弹提示、登录过期要把用户踢回登录页。这些重复逻辑最适合收敛到 Axios 拦截器里。请求拦截器负责从 localStorage 取 token 并塞进请求头。响应拦截器负责统一判断业务状态码如果后端返回 401 或 403跳转登录页如果是业务错误直接弹出提示消息正常数据则剥离开返回给具体页面。这样业务代码里只需要关心成功路径异常路径全部被拦截器兜住了。我实际开发中还有一个建议后端返回结构统一固定为{ code, message, data }三层结构前端拦截器只认这个结构。很多联调问题都出在“有的接口返回字符串、有的返回对象、有的直接抛异常”这种不规范的响应格式上。定死格式前后端各自处理起来都会轻松很多。4.3 用户挂号页面和医生端工作台的关键交互用户挂号页面的主线交互是选择日期查看号源列表点击挂号填写就诊人信息提交订单模拟支付。这五个步骤对应五个不同的页面或弹窗每一步之间传递的数据要提前设计好。比如用户选择 7 月 20 日之后前端请求当天排班列表接口需要把日期作为参数传给后端。用户点击某个医生时这个医生的排班 ID、费用、科室名称都要带进下一步。提交订单时后端只需要排班 ID 和患者信息其他冗余字段由后端根据排班 ID 自行填充。这样的接口设计是合理的前端不需要知道后端内部数据怎么组装只要把必要参数传足。医生端工作台相对简单一些主要是排班管理和预约患者列表。医生新建排班时前端弹一个表单选择日期、时段、放号数量和费用。查看预约患者时按日期筛选出该医生当天的所有订单展示患者姓名、联系电话、时段和支付状态。这两块页面难度不高但需要注意和权限控制配合医生只能操作自己的数据不能看到其他医生的排班和患者。5. 从“代码在仓库里”到“浏览器里能点”部署运行实录很多人项目写完压在本地仓库里看的时候完全没问题换一台机器或者让别人跑就是起不来。这一节把我实际跑通这套系统时的完整步骤以及遇到的坑逐一列出来按照这个顺序操作基本能保证一次成功。5.1 按照这个顺序启动稳稳当当整个项目建议按照数据库、后端、前端的顺序依次启动。第一步用 Navicat 或命令行执行项目里的初始化 SQL 脚本创建数据库和所有表。注意先确认数据库服务的编码格式是 utf8mb4不然中文容易变乱码。第二步打开后端的 application.yml 配置文件把数据库地址、用户名、密码改成自己本地的实际值server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital_registration?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai第三步启动 SpringBoot 主类。看到控制台打印出启动成功的日志后端就绪。第四步进入前端项目目录执行npm install安装依赖然后执行npm run dev启动开发服务器。前端默认端口一般是 8081 或 5173打开浏览器访问前端地址能看到登录页面就说明整套环境搭建成功。初始化 SQL 里也会预置好管理员账号、医生账号和普通用户账号登录后就能分别体验三种角色。5.2 配置文件里最常见的三个坑第一个坑是 MySQL 8 和 SpringBoot 的驱动问题。新版 MySQL 的驱动类名从com.mysql.jdbc.Driver改成了com.mysql.cj.jdbc.DriverSpringBoot 2.x 之后如果手动配驱动类要确认写的是新的类名如果是通过启动器自动配置则要确保本地 Maven 仓库里拉下来的 MySQL Connector 版本别太老。第二个坑是时区错乱。连接串里不写serverTimezoneMySQL 8 默认会报警告查询出来的时间还可能比实际时间早或晚 8 小时。连接串里统一加上serverTimezoneAsia/Shanghai同时把 Spring Boot 的 Jackson 时区也配上前后端看到的时间就都是同一套了。第三个坑是内存和端口占用。后端默认 8080 端口很容易被本地其他服务占用前端端口也可能和现有项目冲突。启动之前先用命令行确认端口是否空闲有冲突就在配置里换一个端口。前后端端口不一致时要记得处理跨域不然页面能打开但请求全被浏览器拦截。5.3 CORS 和前端代理如何配合开发环境下的跨域问题几乎每个人都会遇到。前端页面地址是localhost:8081后端接口是localhost:8080浏览器会判定为跨域请求。解决方案有两种可以配合使用。后端加全局 CORS 配置允许指定来源访问所有接口。这种方式配置简单适合开发初期快速联调。前端也可以配置代理把/api开头的请求转发到后端地址。比如 Vue CLI 项目在vue.config.js里这样配置module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }这样前端代码里请求/api/doctor/list浏览器看到的还是同一个源代理在开发服务器层面把请求转发给后端从根上避免了跨域。上线部署时再用 Nginx 做同样的反向代理前后端就彻底解耦了。5.4 真跑完一套完整流程之后的几个心得整套系统跑通之后有几个体会是写代码阶段感受不到的。第一个是关于时间字段的。业务涉及日期的地方非常多排班要按日期查、订单要按日期归档、统计要按日期分组。前端传递日期时最好统一使用yyyy-MM-dd格式避免带时分秒否则后续查询很容易踩边界条件的坑。比如前端传了一个2025-07-20 00:00:00后端用等值查询时当天所有非零点的数据就匹配不上。第二个是关于删除操作。系统里很多基础数据不应该提供物理删除功能。医生如果被删了历史订单怎么展示科室如果被删了历史统计怎么归集更合理的做法是给基础表增加一个上下架或停用状态字段需要禁用时把状态改掉前端列表查询时过滤一下即可。保留历史数据系统会耐操很多。第三个是异常提示要写得有人情味。用户约满时提示“该时段号源已约满请选择其他时间”比单纯返回“系统错误”体验好得多。后端要习惯用业务性错误码区分“参数错误”“无权限”“业务不允许”等场景而不是一律抛 500 异常。这样前端拦截器才能给出准确的提示用户也知道下一步该怎么办。第四个是演示项目也要留一点拓展空间。比如订单表里加入一个预约来源字段用户可以区分是线上挂号还是现场窗口挂号排班表预留一个出诊类型字段可以标识普通门诊、专家门诊、特需门诊。这些字段眼下可能用不到但设计上留好后续扩展功能时不用大改表结构SQL 脚本也能向前兼容。做这种完整项目的过程中真正有价值的不只是“能跑”而是理解每个环节为什么这么设计、某个模块独自开发时很顺畅、联调起来却处处别扭的原因。把这套挂号系统的表关系、并发扣号、权限控制、前后端联调流程完整走一遍再回头看很多业务系统的源码会发现底层逻辑都是一通百通的。