首页 / 资讯中心 / 文章详情

SpringBoot+Vue医院挂号系统实战:从数据库设计到并发防超卖

SpringBoot+Vue医院挂号系统实战:从数据库设计到并发防超卖 ★ FEATURED ARTICLE
接到“SpringBootVue医院挂号系统”这个需求时我第一反应不是打开编辑器而是先找块白板把业务画了一遍。挂号系统看起来就是个预约平台但真做起来它同时踩了前后端分离、接口权限、事务并发、预约状态流转这些坑是一个很适合拿来练手的完整实战项目。这篇文章会从项目设计、数据库建模、后端接口、前端页面一路讲到联调和部署把我实际开发中遇到的坑和取舍逻辑都摊开来讲希望能给正在做类似系统、或者准备拿这个项目练手写进简历的同学一点参考。我做的这个版本是纯在线预约模式不涉及支付、医保结算只解决“查科室、看排班、选号源、提交订单、到院取号、取消挂号”这条主链路。第一版尽量把范围收敛避免在支付和发票上耗太多时间。后面的设计、代码、部署方案都是围绕这个边界展开的。1. 整体设计与选型先把业务捋清楚再动手1.1 角色、用例与业务边界医院挂号系统里主要有三类角色患者、医生、管理员。第一版我不打算做后台管理系统只需要给管理员留一个基础通道用来维护科室、医生和排班数据。患者注册登录、浏览科室、查看医生排班、选择号源、提交挂号、查看我的订单、取消预约。管理员维护医生信息、设置排班、生成号源、处理停诊。医生在系统里只作为排班和号源的展示对象不做接诊流程。我在业务边界上会主动砍掉一些需求不做在线支付不做候诊叫号不做多院区切换。有人可能会觉得这些功能锦上添花但如果不砍一个两周能交付的项目会膨胀成两个月都收不了尾的工程。在做任何系统之前先和需求方明确哪些“不做”这比急着设计表结构重要得多。1.2 为什么选择 SpringBoot Vue 这个组合技术选型听起来很普通但背后的考量值得说清楚。后端用 Spring Boot主要看中的是生态稳定、社区资料多而且团队里如果没人精通 Node 或 GoJava 是这个场景下最不容易翻车的选择。前端用 Vue是因为挂号系统的页面交互不算特别复杂但又要做移动端适配Vue 组件化开发效率高生态组件库也比较齐全。整个系统采用前后端分离部署的时候前端静态资源交给 Web 服务器处理后端单独跑 Java 服务API 走 HTTP 接口。这样做的直接好处是两端可以并行开发后端不用等前端把页面切完前端也不用关心后端用的是什么数据库。你可能会问直接用 SpringBoot 模板渲染不是更简单吗如果只有三五个页面确实更简单但挂号流程里涉及大量异步交互、动态表单校验、路由切换前后端分离后的扩展空间明显更大。需要提醒的是这个项目不需要一上来就上微服务、消息队列、Redis 缓存。很多真实系统里大量用到 Redis但第一版挂号系统完全可以只用 MySQL 配合条件更新解决并发问题。过度设计是新手最容易犯的错先把单体应用做扎实性能扛不住的时候再考虑分布式才是正确路径。1.3 工程结构与接口规范项目的物理结构建议拆成 backend、frontend、sql、docs 四个目录。backend 是 SpringBoot 工程frontend 是 Vue 工程sql 放建表语句和初始化数据docs 放接口文档和部署文档。后端工程内部再按业务模块分包不推荐把所有 Controller 堆在同一个包下。我习惯的模块划分是common统一返回值、异常处理、工具类auth登录注册、JWT 校验user患者信息管理hospital科室、医生、排班order挂号订单、取消挂号接口规范在开发前就要定好不然后端返回的字段和前端预期不一致联调阶段会非常痛苦。我用的约定是所有接口统一返回ResultT结构包含 code、message、data 三个字段。列表接口统一返回分页结构订单状态用数字枚举不要在前端页面写死语义不明的状态码。2. 数据库设计重点不在挂号单而在号源2.1 核心表一览这个系统的核心表我建议至少包含五张用户表、科室表、医生表、排期表号源表、挂号订单表。为什么把排期单独拆出来因为医生出诊不是持续性的而是按天、按时段排班每一个排班会生成一批号源挂号操作扣减的是号源库存。如果不拆表把号源数直接挂在医生表上就没法表达“周一上午有 30 个号、周三下午有 20 个号”这种维度。先看简化的建表思路CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), phone VARCHAR(20), role TINYINT NOT NULL DEFAULT 0 COMMENT 0-患者 1-管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, intro VARCHAR(255), status TINYINT NOT NULL DEFAULT 1 ); CREATE TABLE doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_id BIGINT NOT NULL, name VARCHAR(50) NOT NULL, title VARCHAR(20), intro VARCHAR(255), status TINYINT DEFAULT 1 ); CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, work_date DATE NOT NULL, period VARCHAR(20) NOT NULL COMMENT AM-上午 PM-下午, total_num INT NOT NULL, remain_num INT NOT NULL, version INT NOT NULL DEFAULT 0, work_status VARCHAR(20) DEFAULT ON, UNIQUE KEY uk_doctor_date_period (doctor_id, work_date, period) ); CREATE TABLE order_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL UNIQUE, user_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, dept_name VARCHAR(50), doctor_name VARCHAR(50), visit_date DATE, period VARCHAR(20), patient_name VARCHAR(50), patient_phone VARCHAR(20), status VARCHAR(20) NOT NULL DEFAULT BOOKED COMMENT BOOKED-已预约 CANCELLED-已取消 FINISHED-已取号, create_time DATETIME, cancel_time DATETIME );五张表之间的关系很清晰科室下挂医生医生有排期排期产生号源用户针对某一段排期下单。order_record里冗余了科室名、医生名、日期这是故意为之。因为订单属于业务流水应该把用户下单当时看到的信息固定下来哪怕之后医生改了排期或停诊订单记录也不能跟着变。冗余字段会带来同步问题但这里收益远大于成本。2.2 号源表设计防超卖的关键schedule表是整个系统最容易出问题的表。它既有基础信息又承担库存字段total_num是初始号源总数remain_num是剩余号源数。每一次成功挂号remain_num就要减一取消挂号remain_num就要加一。这里最关键的约束是唯一键(doctor_id, work_date, period)一个医生一个时段只能有一条排期记录。如果设计时漏掉这个约束代码里再小心也挡不住数据重复到时候同一个时段出现两条排期前端展示会混乱挂号扣减也会错乱。防超卖的经典写法不是“先查再改”而是用一条带条件的更新语句。很多新手习惯先select remain_num判断大于 0再update remain_num remain_num - 1这样在高并发场景下会出问题。两条请求同时查到剩余号数为 1同时执行更新就可能都认为挂号成功最后超卖。正确姿势是让数据库自己判断UPDATE schedule SET remain_num remain_num - 1, version version 1 WHERE id #{scheduleId} AND remain_num 0 AND work_status ON;这条 SQL 如果受影响行数是 1说明抢号成功如果是 0说明剩余号源不足或排期已停诊。数据库在更新时会锁住这一行后面的请求必须等前面的更新提交后才能继续天然避免了超卖。version字段在这里不是必须的但保留它可以做乐观锁扩展也方便排查问题。2.3 挂号订单表为什么要单独建有人会问直接在schedule表里加一个user_id表示这个号被谁占了不就行了吗不行。一个时间段有多个号源一个排期可以被很多人预约所以必须有一张独立的订单表来记录“谁在什么时间占了哪个排期的一个号”。订单表的状态流转也很重要。我设计了三种状态BOOKED已预约、CANCELLED已取消、FINISHED已取号。第一版不需要退款状态因为没有支付。但是状态不要用中文或者无规律的字符串统一用固定英文枚举接口返回给前端的时候再做展示层转换数据库里存的就是稳定值。订单号order_no我建议单独生成不要直接用自增主键。因为主键可能会暴露业务量而且联调时前端需要拿一个不会重复的编号做展示和后续查找。生成规则可以比较简单比如“时间戳 随机数”或者“日期 自增序列”保证唯一即可。3. 后端接口实现把并发安全的挂号逻辑写稳3.1 项目初始化与统一响应SpringBoot 工程初始化我用 Maven 管理依赖。核心依赖包括 spring-boot-starter-web、MyBatis-Plus、MySQL 驱动、JWT 工具类、参数校验依赖。这里不把具体版本号写死用你本地 SpringBoot 版本对应的兼容版本就行。application.yml配置关键是数据库时区和连接参数server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital_booking?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: ${DB_PASSWORD:123456} jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: mapper-locations: classpath:/mapper/**/*.xml configuration: map-underscore-to-camel-case: true数据库密码不直接写死在配置里而是用${DB_PASSWORD:123456}这种形式本地开发给默认值生产环境用环境变量覆盖。这个习惯很重要不然项目一传仓库数据库密码就泄露了。统一返回结构我写成泛型类public class ResultT { private int code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 0; r.message success; r.data data; return r; } public static T ResultT fail(int code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } }代码规范是 code 为 0 表示成功非 0 表示失败。HTTP 状态码我全部返回 200业务错误通过 body 里的 code 区分。有些人喜欢把 HTTP 状态码和业务码混用比如 401 就抛 401500 就抛 500这样前后端处理起来反而容易混乱。统一用 body 里的业务码前端拦截器只需要针对 HTTP 200 做一层判断再根据 code 做后续跳转逻辑简单得多。3.2 登录注册与 JWT 校验登录接口的流程是用户提交用户名密码后端根据用户名查用户比对密码哈希通过后生成 JWT 返回给前端。密码不存明文我用 BCrypt 加密同一个明文每次加密结果都不一样即使数据库泄露也不能从密文反推密码。JWT 我选择放在请求头里前端请求时带Authorization: Bearer token。后端用一个拦截器解析 token把当前用户信息放到线程变量里后续接口直接取当前登录用户即可。选 JWT 而不是 Session 的原因是这个系统前后端分离JWT 天然适合无状态接口Session 需要维护会话存储在前后端分离场景下还得处理跨域携带 Cookie 的问题麻烦。拦截器校验逻辑大致是放行登录、注册、科室列表、医生列表等公开接口其他接口必须解析出合法的用户信息。解析失败统一返回业务码401前端收到后清理本地 token 并跳转登录页。后端不要去重定向页面前后端分离项目里页面跳转是前端路由的事。3.3 科室和医生查询接口科室列表和医生列表这两个接口看着简单但写起来有性能讲究。第一种写法是查所有科室然后循环每个科室查下属医生科室不多的时候没感觉科室一多就会产生 N1 查询问题。正确做法是一次性查完所有数据在 Java 内存里按deptId分组或者用 SQL 的IN子查询取关联数据。例如返回医生排班列表时需要同时返回医生所属科室名不要在循环里逐条select科室表。用 MyBatis 的关联查询或者先查科室列表做 Map一次加载。这个细节在并发量不大时看不出差异但能给接口省掉很多无谓查询属于性价比很高的优化。另外所有查询接口都要考虑数据脱敏问题。尤其是患者订单里涉及手机号、身份证号接口返回给前端展示时最好做掩码处理比如138****1234。这个操作可以在后端统一做不要指望前端自觉处理。3.4 挂号接口与事务控制核心中的核心挂号接口是后端服务里含金量最高的地方。我结合事务和条件更新完整逻辑如下Transactional(rollbackFor Exception.class) public OrderVO createOrder(CreateOrderRequest request) { Schedule schedule scheduleMapper.selectById(request.getScheduleId()); if (schedule null || !ON.equals(schedule.getWorkStatus())) { throw new BusinessException(排期不存在或已停诊); } if (schedule.getWorkDate().isBefore(LocalDate.now())) { throw new BusinessException(该排期已过期); } int rows scheduleMapper.deductRemain(schedule.getId()); if (rows 0) { throw new BusinessException(该时段号源已挂完); } OrderRecord order new OrderRecord(); order.setOrderNo(generateOrderNo()); order.setUserId(currentUserId()); order.setScheduleId(schedule.getId()); order.setDeptName(schedule.getDeptName()); order.setDoctorName(schedule.getDoctorName()); order.setVisitDate(schedule.getWorkDate()); order.setPeriod(schedule.getPeriod()); order.setPatientName(request.getPatientName()); order.setPatientPhone(request.getPatientPhone()); order.setStatus(BOOKED); orderMapper.insert(order); OrderVO vo new OrderVO(); vo.setOrderNo(order.getOrderNo()); vo.setVisitDate(order.getVisitDate()); vo.setPeriod(order.getPeriod()); return vo; }整个流程在一个事务里先检查排期再扣减号源最后插入订单。如果插入订单失败事务会回滚号源也会恢复不会出现扣了号但没生成订单的情况。deductRemain对应的 XML 就是前面写的那条条件更新 SQL。这里要特别理解一个点事务保证的是要么全部成功要么全部回滚但Transactional本身不能解决超卖。真正防止超卖的是那条带remain_num 0的更新语句它把“判断”和“扣减”合并成了数据库层面的原子操作。如果没有这个条件即使事务包裹得再严并发下仍然可能超卖。实际并发验证也很简单。我用一个测试脚本模拟 100 个线程同时调用挂号接口排期剩余号源设为 10最终订单数必须是 10remain_num必须是 0。如果出现 11 张订单或remain_num变成负数说明扣减逻辑写错了需要马上排查。3.5 取消挂号归还号源的时机取消挂号逻辑比想象中容易出错。要先查订单状态只有BOOKED状态才能取消FINISHED已经取号的就不该轻易取消需要走线下人工处理。取消时要更新订单状态为CANCELLED同时把schedule.remain_num加回来。这个操作也必须在一个事务里避免订单取消了但号源没归还。归还号源的 SQL 也要注意带上状态条件确保排期仍然是ON如果排期已经停诊就不需要归还号源因为停诊排期的号源本身就不再有效。还有一个业务细节就诊前多久可以取消不同医院规则不一样。我第一版设置的是当天号源不可线上取消避免临近就诊时号源被反复释放。这个规则写在 Service 层判断即可数据库层面不需要特殊约束。4. 前端 Vue 搭建把挂号流程做成顺畅的页面体验4.1 初始化与网络请求封装前端工程我用 Vite 初始化 Vue3 项目路由用 vue-router状态管理用 PiniaUI 组件库选 Element Plus。表单场景多、表格列表多Element Plus 能省下大量手写样式的时间。请求封装是前端项目里最值得先做的一件事。统一请求模块长这样import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 0) { if (res.code 401) { localStorage.removeItem(token) window.location.href /login } return Promise.reject(new Error(res.message)) } return res.data }, error { return Promise.reject(error) } ) export default request统一拦截的好处是每个页面调用接口时不用重复处理 token 和错误码代码会干净很多。业务请求只关心成功后的数据失败信息通过全局消息提示弹出即可。4.2 路由与登录状态管理路由设计上公开页面和登录后页面要清晰区分。我的路由表大概分几组登录页、注册页、首页、科室列表、医生排班、确认挂号、我的订单。需要登录才能访问的页面在路由 meta 里加requiresAuth: true路由守卫去判断本地有没有 token。路由守卫的简单写法router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ name: login, query: { redirect: to.fullPath } }) } else { next() } })注意一个实际问题token 在本地不代表一定有效可能已经过期。所以除了路由守卫还要靠请求拦截器里对 401 的处理兜底两个地方配合用户感知才完整。只做路由守卫不做接口 401 处理用户 token 过期后会出现页面能进、接口全报错的奇怪状态。Pinia 里我只需要管理两个核心状态当前用户信息和 token。token 优先考虑存在 localStorage刷新页面后还能保持登录态。用户信息可以在登录后调一次接口获取或者登录接口直接把用户信息一起返回存进 Pinia。4.3 挂号主流程页面的组织方式我是按“首页 → 科室列表 → 医生排班列表 → 确认订单 → 挂号结果”这条路径设计页面的。首页放热门科室入口用户点击科室进入医生列表。医生列表按照医生所属科室展示每个医生卡片上显示姓名、职称、简介。点击医生进入排班列表页排班以卡片形式展示每个卡片上有日期、上午/下午、剩余号源数。剩余号源为 0 的卡片直接置灰显示“已约满”不可点击。确认订单页需要用户填写就诊人姓名和手机号手机号前端做格式校验。提交按钮在点击后立即进入 loading 状态防止用户重复提交。因为挂号接口在极端并发下可能因为号源被抢完而失败提交完成后要有成功和失败两种结果反馈成功显示订单号和就诊提示失败弹窗说明原因。整个流程里最容易被忽略的是“返回”行为。用户在确认订单页按浏览器返回可能回到排班列表此时剩余号源可能已经变了所以列表页必须在激活时重新拉取数据不能全靠前端缓存。我习惯在任何涉及号源数量的页面都添加刷新逻辑这个习惯帮我避免过不少“前端显示有号提交却说已满”的尴尬情况。4.4 联调跨域与代理配置开发环境下前端跑在 5173 端口后端跑在 8080 端口直接请求会产生跨域问题。我的方案不是在后端加 CORS而是用 Vite 的 proxy 代理把前端请求转发到后端。export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })前端所有接口前缀都是/api开发环境下 Vite 会把/api开头的请求转发到http://localhost:8080。这样浏览器看到的请求域名一直是 5173没有跨域问题。生产环境下前端打包成静态文件由 Web 服务器转发/api思路一脉相承。使用代理而不是后端 CORS核心原因是生产环境也不希望接口域和页面域不同统一走代理更贴近真实部署形态而且不需要额外维护 CORS 白名单。5. 联调排查与常见问题速查5.1 高频问题速查表这个项目从开发到联调我遇到过不少问题挑几个典型的列成表现象根因处理方式前端请求接口返回 404前端代理路径和后端路由前缀不一致统一所有接口以/api开头代理和后端路由保持一致页面请求出现跨域错误前端直接请求了不同端口且未走代理开发环境用 Vite proxy生产环境用 Nginx 反向代理接口返回的时间比本地时间晚 8 小时数据库连接参数或 Jackson 没配时区在连接串加serverTimezoneAsia/ShanghaiJackson 也配同区域并发挂号测试出现超卖扣减号源时没有remain_num 0条件改为原子条件更新并验证受影响行数取消挂号和归还号源不同步两个操作没有放到同一事务取消逻辑整体加事务失败自动回滚排期表里同一医生同一时段出现多条记录缺少唯一约束表结构加上(doctor_id, work_date, period)唯一键前端拿到 code 401 但没有跳登录拦截器只处理了 HTTP 错误在响应拦截器里显式处理业务码 4015.2 按调用链排查定位问题遇到接口问题我一般按这个顺序排查先看前端请求是否真的发出参数对不对再用后端口日志看请求是否到达 Controller打印请求参数和执行结果最后定位到 SQL看是否按预期更新了数据。日志里最值得打印的是几个关键字段用户 id、排期 id、剩余号源数、扣减受影响行数。我把这些固定在挂号接口的日志里一旦用户反馈“明明选上号了实际没预约成功”或“订单生成了但号源没扣”马上能根据日志判断是哪个环节出了问题。在写业务代码之前就把日志点埋好是很多老程序员会提醒你的习惯。5.3 容易被忽略的业务边界除了技术问题业务边界也容易漏。比如排期日期跨天晚上 23:50 用户提交第二天的号源是否允许我选择的规则是只要排期在当天且号源充足就允许下单因为线上预约和就诊是两个时间点。但跨天后的号源如果已经开始就需要后端做“当前时间不能晚于排期开始时间”的判断具体看业务要求。再比如停诊处理。管理员把某个排期状态改成停诊之后已经预约的患者订单不能自动消失要在订单列表里标记异常或提醒患者取消。我第一版没有做停诊通知消息只做了状态标记这个取舍要在文档里写清楚不然上线后患者会投诉。另一个容易被忽略的是订单幂等。用户在前端快速点了两次提交虽然按钮有 loading 防重复但极端情况下两条请求还是会到达后端。严格的方案是在订单表加一个业务唯一键比如(user_id, schedule_id)唯一索引保证一个用户同一时段只能下一单。注意这可能会限制用户帮家人挂号的诉求实际业务中是否允许“一个用户一个时段挂多个号”要想清楚我按“同一用户同一时段只能下一单”处理如果需求允许一人多号再调整校验逻辑。6. 打包、部署与上线检查6.1 后端打包与环境隔离后端打包用 Maven 命令mvn clean package -DskipTests打包产物是可执行的 jar 包放到服务器后java -jar hospital-backend.jar --spring.profiles.activeprod生产环境不要和开发环境共用配置。我会把数据库连接、端口、日志级别拆到application-prod.yml里通过--spring.profiles.activeprod加载。生产配置里的数据库密码用环境变量注入不写在文件里。启动之后第一件事不是测业务接口而是看健康检查接口是否返回正常。Spring Boot 可以引入健康检查依赖暴露一个/actuator/health接口服务器层面定时探测这个接口就可以判断服务是否存活。6.2 前端构建与 Nginx 配置前端构建前确认生产环境的接口前缀还是/api这样不需要在代码里改接口域名。执行npm run build构建产物是dist目录。我用 Nginx 托管静态文件同时把/api请求反代到后端服务server { listen 80; server_name your-domain.com; root /opt/app/dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { try_files $uri $uri/ /index.html; } }注意location /里必须写try_files $uri $uri/ /index.html因为 Vue 路由用了 history 模式刷新子路径时如果直接找静态文件会 404需要把请求全部回退到index.html。这个配置是前端部署最常踩的坑漏了它首页能打开刷新内页就白屏。6.3 上线前检查清单上线前我会一件事一件事过数据库账号权限是否最小化生产库密码是否改掉默认值。是否做了全量备份是否把初始化 SQL 在空库上完整执行过一次。管理员账号初始密码是否已修改。排班数据是否已经生成号源总量和剩余号源是否一致。核心接口是否做了鉴权未登录用户直接调挂号接口会不会报错。患者手机号、身份证号是否在接口返回中做了脱敏。订单状态流转是否闭环预约、取消、取号三种状态都有对应操作。前端打包产物是否用 Nginx 完整测试过刷新、跳转、接口代理。这个清单看着繁琐但每次上线前花十分钟过一遍能省掉上线后加班的折腾。最后分享一个我踩了几次坑才改过来的习惯联调之前一定先把接口字段定义清楚尤其是状态枚举、时间格式、返回结构。前后端各写各的看着都对了真正连起来才发现前端拿的是orderNo后端返回的却是order_no前端要的是WORK_DATE后端返回的是workDate。这类问题不是技术难度问题是沟通成本问题但浪费的时间一点不比写代码少。做挂号系统这类业务链路完整的项目最大的收获不是学会了哪个框架而是明白了“先定规则再写代码”这句话的含金量。
阅读完成 · 觉得有帮助?
咨询建站