SpringBoot+Vue3医院挂号系统实战:并发控制、状态机与部署全解析

发布时间:2026/10/7 16:45:18
SpringBoot+Vue3医院挂号系统实战:并发控制、状态机与部署全解析 做医院挂号系统这类项目最忌讳的不是技术难而是业务逻辑理不清。我之前用Java SpringBoot Vue3 MyBatis MySQL做过一套前后端分离的挂号就诊系统代码开源放在Gitee上不少同学和同行拿过去改改就直接当毕业设计或者小医院的信息化demo了。今天不吹不黑把整套系统的设计思路、表结构、核心代码片段、线上部署踩坑全捋一遍尤其是挂号流程里的并发控制和状态机设计这些是教程里很难讲透的东西。这套系统适合谁如果你刚学完SpringBoot和Vue3想找一个完整的实战项目来打通前后端或者你正在做医院类管理系统、预约类平台甚至只是想看看MyBatis怎么优雅地处理多表关联查询那这篇内容能帮你省下至少两周的摸索时间。我尽量用“做项目”的视角来讲而不是照着教科书念概念。1. 项目整体设计与技术选型思路1.1 医院挂号系统的真实业务场景别急着写代码先搞清楚医院挂号到底在解决什么问题。患者到线下医院挂号流程是先查科室再查医生看医生某个时间段还有没有号源然后缴费拿到一个就诊序号。整个过程里有三个核心角色患者、科室/医生、号源。而号源是最容易出问题的环节——同一时间多个患者抢最后一个号怎么保证不超卖医生临时停诊已经挂出去的号怎么处理患者爽约没来号源要不要释放所以这个系统的核心不是CRUD而是号源的管理和状态流转。挂了号之后订单状态要从“待支付”变“已支付”再去生成“就诊记录”如果用户取消号源要回滚。这些状态必须在数据库层面和代码层面同时做约束否则上线第一天就会出现超卖事故。1.2 为什么选SpringBoot Vue3 MyBatis这套组合技术选型不是越新越好而是越“稳”越好。SpringBoot 2.x/3.x是目前Java后端绝对的主流招人也好、找参考也好资料最多Vue3组合式API加上Element Plus做后台管理界面开发效率确实比JQuery时代高一大截MyBatis是老牌ORM胜在SQL可控复杂查询能自己写不像JPA在某些场景下容易帮倒忙。MySQL则没什么好争议的中小型业务系统的最优解。这套组合的另一层优势是前后端彻底分离。后端只出JSON接口前端用Vite起服务调接口两边可以并行开发。后端同学不用管页面长什么样前端同学用Mock数据也能先跑起来。我见过很多“假分离”项目——thymeleaf里嵌Vue页面路由还是后端控制的那种项目维护起来简直灾难。我们这套系统用Nginx部署前端静态资源后端单独跑SpringBoot接口走CORS或者网关转发才能真正体验到前后端分离的收益。1.3 系统模块划分与功能清单我把系统拆成了四个端患者端小程序/H5适配、医生端、管理员端、以及公共接口。实际源码里为了方便演示前端做了三个角色入口的登录切换后端用JWT做认证一个token搞定所有角色。核心功能如下用户注册/登录支持手机号验证码demo里可用固定验证码科室管理科室列表、科室详情、科室下的医生列表医生排班每天生成若干个时段号源比如上午30个号、下午40个号号源管理可预约号源、剩余号数、号源状态在线挂号选择科室-选择医生-选择时段-确认就诊人-提交订单-支付订单管理待支付、已支付、已取消、已就诊、自动释放后台管理科室增删改查、医生出诊排班、挂号记录查询、统计报表这些功能看起来多但核心表其实不超过八张。下面详细拆数据库设计。2. 数据库设计与核心表结构解析2.1 用户与就诊人表的设计细节先聊用户表。患者既能作为登录账号也能同时维护多个就诊人比如帮爸妈挂号。所以我把user和patient拆成两张表。user存登录信息id、手机号、密码BCrypt加密、昵称、角色类型patient存就诊人信息id、user_id、姓名、身份证号、性别、出生日期、手机号、医保类型等。这里有个容易踩的坑不要把身份证号直接做成唯一索引。因为测试数据经常重复而且非实名认证场景下身份证号允许为空。我后来只做了普通索引业务层面去重就行。医生的信息也挂在用户体系下但医生有额外的职称、科室、简介等字段所以我建了一张doctor表通过user_id关联。管理员则不用单独建表直接在user表里用role字段区分。这样一个登录接口走统一逻辑授权时根据角色拿到不同菜单和权限。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, phone varchar(20) NOT NULL COMMENT 登录手机号, password varchar(100) NOT NULL COMMENT BCrypt加密后的密码, role tinyint NOT NULL DEFAULT 0 COMMENT 0-患者 1-医生 2-管理员, nickname varchar(50) DEFAULT NULL, status tinyint NOT NULL DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;就诊人表需要加一个is_default字段标识默认就诊人挂号页默认选中。这个字段在业务层处理不要建联合唯一索引因为同一个user下的默认就诊人只能有一个但MySQL对“部分唯一”约束支持不好更别用触发器直接在service层写逻辑判断即可。2.2 科室、医生、排班三张表如何关联科室表department最简单id、科室名称、科室编码、介绍、位置、排序号。医生表doctorid、user_id、department_id、姓名、职称、擅长领域、头像URL、是否排班状态。排班表schedule是业务核心我用来表示“某医生在某天某时段放了多少号”。设计排班表的时候要注意你是按天排还是按时间段排我建议按时间段排。上午和下午各算一个时段用start_time和end_time存时间点。另外加一个total_number表示总号源数remain_number为剩余号数。这里有个关键点剩余号数不要等着算要在每次挂号时原子扣减。CREATE TABLE schedule ( id bigint NOT NULL AUTO_INCREMENT, doctor_id bigint NOT NULL, dept_id bigint NOT NULL, schedule_date date NOT NULL COMMENT 出诊日期, period tinyint NOT NULL COMMENT 1-上午 2-下午, start_time time NOT NULL, end_time time NOT NULL, total_number int NOT NULL DEFAULT 30, remain_number int NOT NULL DEFAULT 30, status tinyint NOT NULL DEFAULT 1 COMMENT 1-正常 0-停诊, PRIMARY KEY (id), KEY idx_date_doctor (schedule_date,doctor_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么要把科室ID冗余到排班表因为查询“某科室某天还有哪些号源”时如果先联医生表再联科室表SQL复杂不说索引也不容易命中。冗余一个科室ID查询时直接对排班表做等值过滤性能提升很明显。这是典型的“以空间换查询效率”做法在业务里完全值得。2.3 挂号订单表的状态机设计订单表是整套系统的重点也是事务的载体。字段包括id、订单号、user_id、patient_id、doctor_id、schedule_id、dept_id、period_date、period、就诊序号排队号、挂号费、支付状态、订单状态、创建时间、支付时间、取消时间。状态字段我用了两个pay_status0未支付 1已支付 2已退款和order_status0待支付 1已预约 2已完成 3已取消 4超时释放。有人觉得一个status就够了但实际业务里“已支付但未就诊”和“未支付但已锁定号源”是必须能区分的。所以状态分离更稳。就诊序号visit_number怎么生成不能简单用自增ID因为同一天同一时段同一医生的号需要形成可见的“第几号”。我的处理是当天该时段首个号从1开始通过schedule_id和order_status统计已挂号人数再加1。但这里有个并发问题统计之后再加1两个请求同时拿到同一结果就会生成重复的号。后面在并发控制部分细说。CREATE TABLE order_info ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号, user_id bigint NOT NULL, patient_id bigint NOT NULL, schedule_id bigint NOT NULL, doctor_id bigint NOT NULL, dept_id bigint NOT NULL, visit_date date NOT NULL, visit_period tinyint NOT NULL, visit_number int NOT NULL COMMENT 就诊序号, amount decimal(10,2) NOT NULL DEFAULT 0.00, pay_status tinyint NOT NULL DEFAULT 0, order_status tinyint NOT NULL DEFAULT 0, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_user (user_id), KEY idx_schedule (schedule_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单号我建议用时间戳 随机数 用户ID后四位不要依赖数据库自增ID直接展示给用户因为用户可感知的ID应该看不出业务量也更安全。3. SpringBoot后端接口开发与核心逻辑3.1 工程分层与统一返回格式后端工程我按照标准的Controller-Service-Mapper三层来写没有引入复杂的DDD。包结构com.hospital.registration ├── controller ├── service ├── mapper ├── entity ├── dto ├── config └── commoncommon包里放了统一返回体ResultT、全局异常处理器、业务异常类。统一返回体的格式是{code: 0, message: success, data: ...}。code为0表示成功非0是错误码。这样前端能统一拦截和提示不需要每个接口单独写try-catch。全局异常处理器特别有用。业务上抛BusinessException(该时段号源已约满)接口返回错误码和提示避免直接把500堆栈打到前端。这点做不好联调时光是“为什么前端看到的是白屏”就能吵半天。3.2 MyBatis动态SQL与联表查询实战MyBatis最核心的就是写Mapper XML。我在项目里用Mapper注解配合XML文件复杂的SQL放在XML里简单的注解直接写。动态查询科室列表的时候where标签和if标签简直是神器。比如后台管理端按名称模糊查询、按状态过滤select idqueryDeptList resultTypecom.hospital.registration.entity.Department select * from department where if testname ! null and name ! name like concat(%, #{name}, %) /if if teststatus ! null and status #{status} /if /where order by sort_no asc /select联表查询要注意实体类字段映射。别图省事在SELECT里写select *等表结构加字段后接口返回的JSON就乱套了。我习惯显式列出列名并起别名或者用resultMap。下面这个是查询医生列表并带上科室名称的典型写法resultMap idDoctorVoMap typecom.hospital.registration.dto.DoctorVO id propertyid columnid/ result propertyname columnname/ result propertytitle columntitle/ result propertydeptName columndept_name/ /resultMap select idselectDoctorVO resultMapDoctorVoMap select d.id, d.name, d.title, dp.name as dept_name from doctor d left join department dp on d.dept_id dp.id where if testdeptId ! null and d.dept_id #{deptId} /if /where /select这里有个小细节MyBatis的#{deptId}如果传入null会直接报错吗不会。但如果你的动态SQL里用了deptId ! null判断在传0的时候也会进这个条件因为0不等于null作用域不同。如果你需要区分“未传”和“传0”可以用_parameter来判断不过一般医院科室ID不会传0默认没问题。3.3 挂号接口的事务控制与防超卖方案这是整套系统含金量最高的地方。挂号接口的逻辑校验用户登录 - 查询排班 - 判断剩余号数 - 扣减号源 - 创建订单。如果不用并发控制两个请求同时查到剩余号数为1同时通过校验同时扣减结果就是超卖了。我提供三种方案按推荐程度排序。第一种是乐观锁在schedule表加一个version字段。更新时只更新remain_number和versionupdate schedule set remain_number remain_number - 1, version version 1 where id #{scheduleId} and version #{oldVersion} and remain_number 0;如果update返回的影响行数为0说明有其他人先改了数据要么号源没了要么版本冲突这时候直接抛异常“号源被抢”。这种方式最适合挂号场景因为冲突概率不算特别高而且不需要引入额外的中间件。第二种是悲观锁也就是在查询排班时加FOR UPDATEselect * from schedule where id #{scheduleId} for update;在事务里先锁住这行记录其他事务只能等待等当前事务提交后才继续。这种方式最保险但性能差高并发下会大量线程排队而且必须保持事务尽快提交不然会把整个表锁死。第三种是数据库原子更新甚至不查剩余号数直接执行上面的update语句把remain_number 0作为条件。影响行数为1才插入订单否则说明没号了。但这种方式的问题是你要在更新之前就生成订单号等数据如果事务失败要回滚。其实可以把“更新排班表”和“插入订单表”放在同一个事务方法里谁先谁后都行。我个人最终选的是第三种 唯一索引兜底。因为乐观锁需要先查版本等于多一次查询而原子更新一行代码搞定。关键是得给order_info表加一个schedule_id user_id的约束防止同一个患者在同一排班下重复挂号。虽然业务上可以允许患者挂完一个号再挂一个比如帮家人挂号但那是不同就诊人如果同一就诊人重复挂同一时段必须拦截。兜底方案加一个唯一索引比如uk_patient_schedule。不过注意如果你要允许一个患者挂多个号比如上午一个号下午一个号那唯一索引应该加在(patient_id, schedule_id, visit_period)上保证同一人同一时段不重复。我实际上是把visit_date和visit_period也加进去了防止上午和下午互相影响。挂号的核心Service方法大致长这样Transactional(rollbackFor Exception.class) public OrderInfo createOrder(CreateOrderDTO dto) { // 1. 校验就诊人归属 Patient patient patientMapper.selectById(dto.getPatientId()); if (patient null || !patient.getUserId().equals(dto.getUserId())) { throw new BusinessException(就诊人信息不存在); } // 2. 幂等校验防止重复提交 int count orderMapper.checkUnique(dto.getPatientId(), dto.getScheduleId()); if (count 0) { throw new BusinessException(您已挂号该时段请勿重复提交); } // 3. 原子扣减号源 Schedule schedule scheduleMapper.selectById(dto.getScheduleId()); if (schedule null || schedule.getStatus() ! 1) { throw new BusinessException(该医生排班已停诊); } int updated scheduleMapper.deductRemain(schedule.getId()); if (updated 0) { throw new BusinessException(号源已约满或已被锁定); } // 4. 生成就诊序号当前已挂号人数 1 int visitNumber orderMapper.countVisited(schedule.getScheduleDate(), schedule.getPeriod(), schedule.getDoctorId()) 1; // 5. 插入订单 OrderInfo order new OrderInfo(); order.setOrderNo(generateOrderNo()); // ... 设置其他字段 orderMapper.insert(order); return order; }这里有个问题visitNumber的计算在并发下也可能重复因为第4步是先查再插。要彻底解决就必须在扣减号源的同时把就诊序号也确定下来。更好的方案是给schedule表增加一个start_number字段比如上午1号开始然后每次更新时visit_number start_number (total_number - remain_number)。也就是在扣减后用当前已消耗的号数推算出就诊序号不要额外查询。这个细节我后面在“常见问题”里再展开讲。另外提醒一下Transactional不一定会回滚所有异常。只有RuntimeException和Error会触发回滚Checked Exception不会。所以Service里不要捕获异常吞掉。若你用try-catch包裹并打印日志事务就失效了。正确做法是让异常抛出去由全局异常处理器统一响应。4. Vue3前端开发与联调实战4.1 Vite工程结构和路由权限设计前端用Vite构建Node版本最好16以上。项目结构是frontend/ ├── src │ ├── api │ ├── assets │ ├── components │ ├── router │ ├── store │ ├── views │ ├── utils │ ├── App.vue │ └── main.js路由设计采用动态路由。登录后根据角色返回菜单列表前端用router.addRoute动态注册。静态路由只有登录页和首页框架动态路由模块按角色分组比如patientRoutes、doctorRoutes、adminRoutes。Vue3里我推荐用组合式API也就是script setup语法。这比Options API代码量少逻辑复用方便。比如用户登录后把token存在Pinia里// store/user.js import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: {} }), actions: { setToken(token) { this.token token localStorage.setItem(token, token) } } })axios封装里最重要的是请求拦截器。每次请求自动带Authorization: Bearer token响应拦截器统一处理401跳转登录页。这里有一个容易被忽略的问题401跳转时要避免循环。如果多个请求同时失败会被拦截多次跳转形成栈堆。所以跳转前要判断当前路由是否已经在登录页。4.2 医院首页与科室选择页实现首页我做了一个搜索栏 科室快捷入口 推荐医生列表。搜索接口用/api/dept/query前端输入科室名称后端模糊匹配返回科室列表点击科室跳转到科室详情页。科室详情页展示医生列表医生卡片上有姓名、职称、擅长领域、剩余号数。剩余号数是实时数据我通过接口在每次进入页面时拉取避免使用缓存。因为号源数据是强一致的缓存哪怕延迟几秒也会造成“明明显示有号提交却失败”的坏体验。前端列表页我用了Element Plus的el-card和el-row/el-col做栅格布局移动端也能自适应。要注意的是el-col的:span会随屏幕宽度变化最好结合xs、sm等响应式断点属性不然手机上显示会很挤。4.3 挂号页面表单校验与订单确认逻辑用户从医生列表点击“挂号”后进入挂号确认页。页面顶部显示当前医生、出诊日期、时段、号源剩余量中间选择就诊人底部显示挂号费用点击提交后调后端接口。既然分成了几步我用的是Element Plus的el-steps组件做步骤条确认信息 - 提交订单 - 支付。支付在demo里是模拟的直接调支付接口后把订单状态改成已支付。实际生产环境会接入微信支付或支付宝做一个支付回调接口来更新订单状态即可。表单这里有个坑就诊人列表的数据结构要和后端DTO字段严格对齐。我前端传的字段是patientId、scheduleId后端接收的DTO也是这两字段命名保持一致能省掉前端字段映射的麻烦。如果非要前端转字段就用mapState统一处理别散落在各个页面。提交按钮的防重复点击也很关键。我给按钮加了loading状态提交期间禁用按钮后端也有幂等校验双保险。实际测试中如果不做按钮防重复用户快速双击会生成两个订单虽然后端有唯一索引拦截但体验很差会提示“操作频繁”。5. 部署上线与高频问题排查5.1 本地运行和打包发布全流程本地跑起来很简单后端改一下application.yml里的数据库账号密码执行sql目录下的初始化脚本然后mvn spring-boot:run前端进入frontend目录npm install npm run dev开发环境用Vite代理解决跨域在vite.config.js里配置server.proxy把/api代理到http://localhost:8080server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }生产环境打包前端npm run build会生成dist目录。后端打包mvn clean package -DskipTests生成jar包。把dist里的静态文件放到Nginx的html目录下Nginx配置把/api请求转发到后端服务server { listen 80; server_name yourdomain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }关键点是try_files $uri $uri/ /index.html这句不加刷新子路由页面就会404因为前端是SPA应用路由跳转是浏览器端行为Nginx不知道/doctor/schedule这个路径该返回什么文件只能让它回到index.html让Vue Router自己解析。5.2 实际开发中碰到的四个高频问题先说说数据库时区问题。MySQL 8默认时区是UTC如果你在JDBC连接串没配置serverTimezoneAsia/Shanghai查询日期时间字段会差8小时。挂号系统的日期错位直接导致患者能看到“明天的排班但挂不了号”或者挂号成功但日期错误。配置如下spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai再说一个MyBatis的经典坑当查询结果为空时返回值是空List而不是null。所以Service判断用list null就是无效的必须用list.isEmpty()。这个bug非常隐蔽很多新手在判断时都会写错。前端联调时最常见的报错是跨域CORS。如果你不用代理直接请求后端地址需要在后端加跨域配置。SpringBoot里我写了WebMvcConfigurer允许所有来源和常用方法。注意不要用CrossOrigin(*)加在Controller类上因为新版SpringBoot对预检请求处理不完全配置全局CORS更稳。最后说说部署时的内存问题。医院系统跑在2G内存的云服务器上SpringBoot默认堆内存可能太大导致启动失败。我一般会在启jar包时指定java -jar -Xms256m -Xmx512m hospital-registration.jar如果服务器内存再小就用-XX:UseSerialGC减少线程开销不过一般512M也够用。前端打包后的文件体积也是个问题。Element Plus全量引入的包接近900KB首屏加载缓慢。我在vite.config.js里配置了按需自动导入用unplugin-vue-components插件自动解析组件体积能减到400KB以内。5.3 就诊序号并发生成与支付超时释放就诊序号的问题前面提过这里给一个可落地的方案。在schedule表增加一个consumed_number字段初始为0。每次挂号时在同一个事务里先执行原子扣减update schedule set remain_number remain_number - 1, consumed_number consumed_number 1 where id #{scheduleId} and remain_number 0;然后查出consumed_number作为就诊序号。由于是在事务内且已经更新了行锁查出来的值一定是当前事务修改后的值不会和其他事务冲突。这也解释了为什么查询要在update之后做。如果先查后更必然会出现重复。支付超时释放是另一个容易忽略的点。用户挂了号但不支付号源一直被占用后面的患者就挂不了。我的做法是创建订单时把order_status置为待支付并设置expire_time now() 15分钟。用一个定时任务每5分钟扫描一次把超时未支付的订单状态改为“已取消”同时把对应排班的remain_number加回去。注意释放号源时要同步操作不然会出现“订单已取消号源却未释放”的数据不一致。定时任务方法上要加Transactional。Scheduled(fixedDelay 300000) Transactional(rollbackFor Exception.class) public void releaseExpiredOrders() { ListOrderInfo expiredOrders orderMapper.selectExpiredOrders(); for (OrderInfo order : expiredOrders) { orderMapper.updateStatus(order.getId(), 3); scheduleMapper.increaseRemain(order.getScheduleId()); } }这里又有一个小坑定时任务和用户取消操作并发执行。用户刚好在超时边缘点击取消两个事务同时处理同一订单可能造成状态错乱。我给订单更新语句加了条件where id #{id} and order_status 0保证只有待支付状态才能被更新为取消。个人在实际做的时候还发现如果定时任务运行频率太高会对数据库造成无谓压力。15分钟超时的话扫描间隔设置成2~3分钟就够了没必要用1分钟。另外扫描条件里加上create_time now() - interval 15 minute不要只靠expire_time判断因为所有订单的expire_time如果都设置为15分钟后那每次扫描都要全表查数据量大了性能会下降。这套系统后续如果要扩展优先加一个消息队列来做号源释放和通知比如用RabbitMQ延迟队列处理超时订单。或者把号源秒杀场景单独抽一个服务用Redis预扣库存。不过在学校、小医院或课程设计阶段上面的方案已经完全够用了。我写这篇的时候已经把这套代码放在开源仓库好几个月陆陆续续回答了几十个问题发现大家问的最多的还是事务回滚和并发扣减希望这篇能把这些关键点讲透。