☰
基于SpringBoot2+Vue3的医院预约挂号系统设计与实现
2026/10/9 13:07:46 网站建设 项目流程

最近整理了一套基于SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0的前后端分离医院预约挂号系统,项目代号就是标题里那个“文理医院预约挂号系统”,代码完整、自带一套开发文档,拿来学习和改造都很顺手。预约挂号这类系统跟普通“增删改查”的管理后台不一样,它必须同时处理好时间、号源、用户状态三件事:医生的排班怎么产生、每个时段放多少号、患者预约之后号源怎么扣、取消预约后怎么回补,每一环都牵扯数据一致性。正因为这些业务约束的存在,这套系统才值得一篇详细拆解。这篇内容适合正做Java Web毕业设计的同学、想系统练一遍SpringBoot + Vue3全栈的开发者,也适合准备把“预约/库存”类业务逻辑研究明白的人。

1. 项目定位与技术选型:这套预约系统为什么这么搭

1.1 预约挂号系统真正复杂的地方

很多初学者会把预约挂号系统理解成“有几个页面、能登录、能下单”,真正动手做过就会发现,难点全在业务状态上。

先说最核心的排班-号源模型。一个医生不是每天都在,而是按星期几、上午还是下午出诊;一个出诊时段能看的病人数量是有限的,这个限制不能写死,得由管理员维护;患者选好时段后,系统必须先判断这个时段还有没有号,有号才能创建预约,创建预约的同时要扣减号源。这里的“判断”和“扣减”不能分开两步走,否则并发预约同一个号时就会出现超卖。

再说预约的整个生命周期。预约成功之后,患者可能取消,医生可能停诊,系统还要记录状态流转(待就诊、已完成、已取消、已停诊)。这些状态不是简单的枚举字段,而是和号源的增减、医生排班、用户列表实时联动。所以这套系统的价值不在于页面多好看,而在于状态机是不是完整、事务处理是不是严谨。

1.2 为什么选SpringBoot2、Vue3、MyBatis-Plus和MySQL8.0

这套方案是当下Java Web全栈项目里非常实用的一套组合,选型思路上没有为了炫技而堆新框架。

SpringBoot2选得比较稳。相比SpringBoot1.x,2.x内置了Spring 5、更好的异步支持、更强健的配置体系;相比SpringBoot3,2.x对JDK8和大量存量依赖兼容性更好,很多学校和企业环境仍以它为主。配合目前主流的JDK1.8/11,跑老项目、接老依赖都不折腾。实际开发中也验证了,网上能搜到的资料和解决方案,一多半都是基于SpringBoot2的,遇到问题查资料效率高很多。

Vue3搭配Element Plus是目前后台管理系统最常见的组合。Vue3的Composition API让逻辑复用更干净,用setup写业务代码比Vue2的Options API舒服太多,而且Vite构建速度明显快于Webpack,开发和调试体验都跟得上节奏。这套源码的前端部分把接口请求、路由守卫、状态管理都拆分了模块,结构非常清晰。

MyBatis-Plus在这个项目里承担的是“少写SQL”的任务。单表的增删改查、分页、条件查询全部内置,开发效率非常高。预约挂号这类系统真正复杂的是多表关联和事务,这部分仍需手写SQL,MyBatis-Plus刚好把琐碎的代码收掉,让精力集中在核心逻辑上。也可以说,这套组合是为了让项目本身更快落地,而不是为了展示冷门技术。

MySQL8.0相对5.7最大的变化是默认字符集utf8mb4、窗口函数、CTE、JSON增强,这套系统用到了utf8mb4存中文和用户备注,排序和统计也用了窗口函数的便利。实际开发中用8.0已经是共识,基本上不会有人新项目还去选5.7,从安装教程到驱动兼容性,8.0的资料也更全。

1.3 前后端分离的整体架构

这套系统采用标准的前后端分离架构,后端只提供RESTful API,前端用Vue3独立开发。

后端按controller、service、mapper、entity四层划分,Controller只做参数接收和结果包装,Service层承载业务逻辑,Mapper层负责数据访问。这样一个预约的完整链路就是:前端发请求到Controller,Controller调Service,Service里开事务操作多张表,最终返回统一格式的Result对象给前端。Result对象里带code、message、data三个字段,前端拿到code=200就认为是成功,其他code按照业务提示弹窗,处理逻辑非常统一。

前端按views、api、router、store四个维度组织。页面组件放在views,接口请求统一封装在api目录,路由负责页面跳转和访问控制,store用Pinia管理用户登录态和全局信息。页面组件不直接写axios请求,而是调用api目录里封装好的方法,这样后端接口路径一旦变更,只需要改一个文件。

权限模型这块,系统做了三种角色:管理员、医生、患者。管理员维护科室、医生、排班和号源;医生查看自己的预约列表;患者是预约的操作主体。所有接口通过JWT做身份认证,配合Spring拦截器对接口权限做校验,简单直接,不引入Spring Security也能把权限控制住,对于这套规模的项目来说也更容易读懂。项目文档里也写了完整的角色权限对照表,部署后按文档分配账号即可。

2. 核心业务拆解:数据库表、角色权限与号源控制

2.1 角色权限与登录认证的实现逻辑

用户角色我用一张user表加一个role字段实现,没有单独建角色表和权限表。原因很简单:系统权限粒度不需要细到按钮级,区分三种角色足以满足业务。管理员、医生、患者三个角色的首页和可操作范围完全不同。比如患者端只能看到当前科室和号源,医生端看到的是自己名下的预约列表,管理员端则是全量的排班配置和统计信息。

登录认证采用JWT方案。用户登录成功后后端用秘钥生成token,前端把token存到localStorage里,Axios拦截器在每个请求头自动带上Authorization。后端写一个拦截器统一校验token,解析出当前用户身份后放到请求上下文。校验不通过直接返回401,前端路由守卫检测到没有token或token过期就跳回登录页。

这个方案的优点是前后端分离场景下天然无状态,后端不用维护Session会话,服务多实例扩展也没问题。缺点是token续期需要自己处理,这套系统里把有效期设得比较长,配合前端在收到401时清除用户信息重新登录,够用且容易理解。实际使用中,我建议token里只放userId和role两个信息,不要塞太多业务数据,避免token体积变大且过期后数据不新鲜。

2.2 核心表结构设计详拆

数据库是整个预约系统的地基,我列出了最核心的五张表,字段设计直接决定了业务逻辑的复杂度。

科室表department:id、科名称、科室描述、状态。结构最简单,但它作为医生表和排班表的外键源头,删改要谨慎,建议用逻辑删除字段state来标记停用而不物理删除。物理删掉一个科室,关联的医生和排班全都会变成孤儿数据,这是删数据的大忌。

医生表doctor:id、科室id、姓名、职称、简介、头像、状态。这里要注意的是医生和用户的关系:我采用doctor表里存userId字段,把医生的登录账号关联到user表,这样医生也能登录后台查看自己预约列表。如果不做这个关联,医生身份就只能由管理员在后台代查,体验差很多。

排班表schedule是核心表,字段设计直接影响预约流程的复杂度:

字段说明
id主键
doctor_id医生id
dept_id科室id(冗余存储,方便按科室查询)
schedule_date出诊日期
time_slot出诊时段(上午/下午)
total_count总号数
remain_count剩余号数
status排班状态(正常/停诊)

预约表appointment:id、userId、scheduleId、doctorId、deptId、appointmentDate、timeSlot、orderNo、status、createTime。orderNo用时间戳加随机数生成,方便查询和统计;status字段用tinyint表示,0待就诊、1已完成、2已取消、3已停诊。

用户表user:id、username、password、realName、phone、role、status。密码用BCrypt加密存储,不用明文,这个点新手容易忽略,但实际项目这是底线要求。BCrypt加密的特点是每次加密结果不同,但校验时能正确匹配,比MD5加盐更省事。

这几张表关系清晰:科室一对多医生,医生一对多排班,排班一对多预约记录。冗余字段如deptId、doctorId出现在预约表里,是为了列表查询时减少JOIN,属于典型的空间换时间思路。实测下来,预约列表页在数据量过万后依然流畅,这个设计功不可没。

2.3 号源扣减的并发控制方案

号源扣减是整个系统最核心的代码逻辑,也最容易写错。先说最笨的写法:先查询remain_count,判断大于0,然后执行update减少1。这个写法单用户没问题,一旦两个患者同时下单,两个请求都查到remain_count=1,然后都执行更新,最后结果就是超卖了一个号。

正确做法分两层处理:

第一层,将判断和扣减合并成一条SQL,用条件更新保证原子性:

UPDATE schedule SET remain_count = remain_count - 1, version = version + 1 WHERE id = #{scheduleId} AND remain_count > 0 AND status = 1

这条SQL的巧妙之处在于,remain_count > 0这个条件放进了where里。MySQL执行update时会对匹配行加行锁,两个并发事务同时执行,第二个会被阻塞,等第一个提交后再执行时,重新判断remain_count已经变成0,影响行数为0,业务侧就能判定预约失败。这就是数据库层面的乐观锁思想,不需要额外引入版本号也能解决超卖。

第二层,在insert预约记录的方向加一道防线。scheduleId和userId组合做唯一索引,保证同一用户同一时段不会重复预约。这个约束在代码里检查容易漏,数据库唯一索引是兜底方案,双保险。实际测试时故意并发请求同一个号,最终只有一个能插入成功,另一个会触发唯一索引冲突并返回友好提示。

如果后续要支撑更大的并发量,还有很多优化空间,比如把update操作放进Redis用Lua脚本原子扣减,或者引入分布式锁,但作为一套学习型和中小规模业务系统,上面这套方案已经足够稳健。

3. 实操环节:环境准备、后端工程与前端联调

3.1 开发环境准备与MySQL8.0安装要点

我建议先统一环境,能避免大量“在我电脑上好好的”这类问题。

JDK用1.8或11都可以,SpringBoot2对这两个版本支持很好。Maven用3.6以上,Node建议16以上,18也可以。前端构建工具用Vite,创建Vue3项目时需要Node环境,这个前置条件必须满足。

MySQL8.0的安装有几点要提醒。Windows下安装时选Server only,编码默认utf8mb4即可,但要注意root密码的复杂度要求,MySQL8.0默认开了validate_password组件,密码必须带大写字母、小写字母、数字和特殊字符中的三类以上,设置简单密码会直接报错。Linux下用tar.gz包解压初始化时,记得在my.cnf里配置character-set-server=utf8mb4和default-time-zone='+08:00'。如果图省事用Docker跑MySQL8.0,端口映射和时区一定要挂载好,否则容器一重启时间就乱。

初始化项目数据库时,直接执行项目提供的sql脚本即可。注意脚本里如果有CREATE DATABASE语句,字符集要确认是utf8mb4,否则后面存中文数据容易出现乱码问题。我在拿到这套源码时,特意先看了sql脚本头部,确认字符集和库名没问题之后才执行,省了一次推倒重来的时间。

3.2 后端工程搭建与核心接口实现

后端我用Spring Initializr生成基础工程,引入依赖:spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java、lombok、jwt相关库。版本均为当前主流稳定版,不要刻意追求最新。

application.yml里最关键的配置如下:

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/hospital?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: yourpassword jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

driver-class-name必须用com.mysql.cj.jdbc.Driver,这是MySQL8.0的驱动类,老驱动在8.0下跑不起来。serverTimezone必须显式设置,否则报时区错误。useSSL=false是因为本地开发环境没有配置证书,避免连接时告警甚至报错。allowPublicKeyRetrieval=true这个参数在MySQL8.0下有些情况必须加,否则会报Public Key Retrieval is not allowed,新手很容易在这里卡住。

分页插件必须手动注册,这是MyBatis-Plus新手最常漏的一步。需要单独写一个MybatisPlusConfig配置类:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

没有这段配置,直接调用selectPage方法会发现分页参数失效,查回来的是全量数据。原因是MyBatis-Plus的分页是通过拦截器改写SQL实现的,拦截器不注册,插件等于不存在。

预约的核心Service方法,我简化一下关键逻辑:

@Transactional(rollbackFor = Exception.class) public AppointmentResult createAppointment(AppointmentRequest request) { // 1. 条件扣减号源 int updated = scheduleMapper.decreaseRemainCount(request.getScheduleId()); if (updated == 0) { return AppointmentResult.fail("该时段号源已约满或排班已停诊"); } // 2. 生成订单号 String orderNo = generateOrderNo(); // 3. 插入预约记录 Appointment appointment = new Appointment(); appointment.setUserId(request.getUserId()); appointment.setScheduleId(request.getScheduleId()); appointment.setDoctorId(request.getDoctorId()); appointment.setDeptId(request.getDeptId()); appointment.setAppointmentDate(request.getAppointmentDate()); appointment.setTimeSlot(request.getTimeSlot()); appointment.setOrderNo(orderNo); appointment.setStatus(0); appointmentMapper.insert(appointment); return AppointmentResult.success(orderNo); }

注意@Transactional注解必须加,因为扣减号源和插入预约记录是两步操作,任何一步失败都要整体回滚,否则会出现号扣了但记录没生成,或者记录生成但号没扣的脏数据。我最初调试时故意在insert处抛异常,发现号源确实被回滚了,说明事务生效。

3.3 Vue3前端页面与接口联调过程

前端用Vite创建项目后,我第一时间装了三样东西:vue-router、pinia、element-plus。然后按功能模块划分页面,核心页面是登录注册、首页科室导航、医生列表、排班选择、预约确认、我的预约。

路由和状态管理的组织方式:登录页和注册页在路由上不设守卫,其余页面统一走一个路由守卫,每次跳转前检查pinia里的token是否存在,不存在就重定向到登录页。所有接口请求封装在api目录下的模块文件里,避免页面中散落大量重复的请求代码。

Axios封装的核心在于拦截器:

import axios from 'axios' import { useUserStore } from '@/stores/user' import router from '@/router' const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = userStore.token } return config }) service.interceptors.response.use( response => { return response.data }, error => { if (error.response?.status === 401) { const userStore = useUserStore() userStore.clear() router.push('/login') } return Promise.reject(error) } )

预约流程的前端实现重点在两步联动:选择医生后,通过接口拉取该医生未来7天的排班列表,按日期和时段展示号源余量;点击“预约”按钮时,要把排班id作为核心参数传给后端。页面上的余号展示要实时刷新,我实测里在预约成功后重新调用了一次排班接口,而不是简单扣掉页面上的数字,这样能保证和其他用户的操作保持一致。如果前端只是本地减一,另一个用户预约成功后,这个用户的页面余号就成假数据了。

本地开发联调一定会遇到跨域问题。前端端口5173,后端端口8080,两者不同源,浏览器默认拦截。最省事的办法是在vite.config.js里配置开发代理:

server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }

这样前端请求 /api/xxx 会被Vite转发到后端8080端口,浏览器看到的是同源请求,不涉及跨域。生产环境部署时,由Nginx做同样的反向代理即可。

4. 实战排雷:MySQL8.0连接、MyBatis-Plus分页与并发预约问题

4.1 MySQL8.0连接失败的经典报错与处理

这套系统开发过程中遇到的第一类坑基本都集中在数据库连接。最常见的报错是Access denied for user 'root'@'localhost',这个别急着怀疑代码,先检查密码是不是对的。MySQL8.0默认的认证插件是caching_sha2_password,如果你的MySQL驱动是比较老的5.1.x版本,驱动不支持这个插件,就会认证失败。解决办法就是升级驱动依赖到8.0.x版本,或者执行ALTER USER把认证插件改回mysql_native_password,但后者不推荐,直接升级驱动才是最干净的做法。

另一个经典报错是时区导致的乱码:服务器返回的时区名无法被JDBC识别。在JDBC连接串里显式加serverTimezone=Asia/Shanghai即可解决。还有一个容易忽略的:如果连接报了SSL相关警告,在本地开发环境下加useSSL=false关闭即可。这里注意别在生产环境乱关SSL,加密传输在生产环境仍然是必要的。

我自己的习惯是在把MySQL跑通之后,先做一个最简单的查询接口验证,比如直接用浏览器访问一个返回科室列表的接口,确认数据库通了再继续后面的开发,这样能够把“环境问题”和“业务问题”隔离开,排查范围小很多。如果你的接口一上来就是500,八成是环境问题而不是业务代码问题。

4.2 MyBatis-Plus分页查询失效的两种场景

分页不生效是我见过频率最高的MyBatis-Plus问题,而且有两种完全不同的表现。

第一种是根本没注册分页插件,调用selectPage返回所有数据,日志里看不到LIMIT语句。解决方案就是前面提过的PaginationInnerInterceptor配置,必须把它注册成一个Bean。这个错误很隐蔽,因为程序不报错,只是结果不对,新手排查半天也找不到原因。

第二种是分页插件加了但SQL和插件打架。比如自定义Mapper方法里自己写了limit,同时又传了Page参数进去,MyBatis-Plus会在SQL外层套一层COUNT计算和分页壳,结果就变成两层limit,SQL直接报错。解决办法是自定义复杂分页查询时,不要在方法上写死limit,而是把Page作为第一个参数传给Mapper方法,MyBatis-Plus自动拼装分页。

还有一个小细节:分页对象Page的current页码从1开始,千万别按0开始写。前端第一页传1,后端返回的数据结构里有total、pages、current、records这几个字段,前端表格分页组件直接消费即可。如果前端把页码从0开始传,后端每页数据都会错位一页,看起来就像是丢失了第一条数据。

4.3 跨域问题的三种排查思路

跨域报错在开发环境下误判率很高。有时候前端控制台报的其实是接口404,但因为浏览器层面先拦了跨域,就误以为是跨域问题。我总结的排查顺序是这样:

第一步,先直接在后端接口地址上放浏览器访问,比如http://localhost:8080/api/dept/list,能出数据说明后端正常。

第二步,确认前端发的请求路径是不是正确命中后端接口,重点看代理配置里有没有写错路径前缀,比如后端接口本来没有/api前缀,前端代理里又忘了rewrite。

第三步,确认请求头有没有触发CORS预检。携带Authorization请求头属于非简单请求,浏览器会先发OPTIONS预检,后端如果不处理OPTIONS请求,预检失败也会导致跨域报错。开发环境用代理方式可以完全绕开这个问题,生产环境用Nginx统一转发也是同理。

这套系统里我最终采用的是前端代理,后端不需要额外加CORS配置,部署上线时Nginx把 /api 转发到SpringBoot服务,前端仍然同源访问,一致性和安全性都更好。

4.4 预约并发与数据一致性问题的深度排查

有人可能问,一个医院预约系统真的需要处理高并发吗?真实生产环境里,热门专家号确实会在放号瞬间被抢,所以并发场景必须处理。前面讲过用条件更新扣减号源,这里补充几个实际测试中发现的细节。

第一个细节是事务隔离级别。MySQL默认的是可重复读(REPEATABLE READ),在扣减号源这条语句里不会有问题,但在“先查排班信息再扣减号源”的流程中,如果两次查询之间数据被其他事务修改,理论上可能读到旧数据。我的处理方式是:把排班信息查询和扣减操作放在同一个事务里,扣减时以条件更新结果为唯一判断标准,不依赖之前的查询结果做决策。

第二个细节是唯一索引的幂等性。同一个用户同一时段重复预约,代码里可以先查一次,但并发情况下查两次都为空,然后都走到insert,这时唯一索引会拦住一条,让另一个insert抛异常。这个异常需要在Service层捕获并转化成友好的提示信息,不能让异常直接抛到前端变成500。我在Controller里加了一个全局异常处理器,专门把DuplicateKeyException翻译成“您已预约该时段,请勿重复操作”。

第三个细节是取消预约和停诊处理的对称性。取消预约时,预约表状态改成已取消,同时更新schedule表的remain_count加1,这两步同样必须放在一个事务里。管理员停诊时,除了改排班状态,还要批量更新所有该排班下的预约记录为已停诊,同时由于号源不再放号,remain_count不需要回补,但这个批量更新必须加条件,只更新待就诊状态的预约,避免把已完成和已取消的也改掉。这里我附上常见问题速查表:

问题现象常见原因解决办法
连接MySQL报Access denied驱动版本过旧,不支持caching_sha2_password升级mysql-connector-java到8.0.x
查询中文乱码数据库或JDBC字符集未配置utf8mb4初始化脚本和连接串统一utf8mb4
分页返回全量数据未注册MyBatis-Plus分页插件添加PaginationInnerInterceptor
预约扣号超卖先查询后扣减的设计改为条件更新SQL
取消预约后余号不恢复两步操作不在同一事务加@Transactional注解

我个人测试时习惯用两个浏览器窗口分别登录不同账号,同时点同一个号的预约按钮,通过多次刷新确认只有一个能成功。这是验证超卖问题最简单直观的方式,比写并发测试脚本来得快,也更容易理解。

5. 文理医院预约挂号系统的扩展方向与个人心得

这套系统的设计和实现,从业务模型到代码落地,核心思路可以复用到很广的领域:凡是涉及“有限资源+时间窗口+用户预定”的业务场景,比如会议室预约、实训室预约、场馆预约,都可以把这套排班表和号源扣减逻辑直接迁移过去。

扩展方向上,有几个比较务实的方向可以考虑。第一,加入短信或站内信通知,预约成功、停诊通知都通过消息队列异步发送,避免阻塞主流程。第二,将排班生成做成按月批量生成,支持医生停诊时整日停用,再自动通知受影响的患者。第三,加入Redis缓存热点排班数据,把热门科室的排班查询压力从数据库剥离出来。第四,对前端做移动端适配,医院预约场景里患者绝大多数用手机访问,响应式布局或单独的小程序端是必然趋势。

从代码维护角度,这套项目保留了清晰的注释和规范的命名,配合附带的文档,一个新人大概一周左右就能把整套逻辑跑通。文档里除了环境部署说明,还建议每个刚上手的人按“用户注册→管理员排班→患者预约→取消预约”这条链路手动走一遍,把状态变化和号源变化对照着看,理解会快很多。

最后分享一点我自己的体会:做这类业务系统,最忌讳一上来就写代码。先把排班-号源-预约这张关系图画清楚,把状态流转表写出来,把并发扣减的SQL想明白,代码写起来就是水到渠成的事。这套系统最大的价值不是技术栈有多新,而是把业务约束处理得完整,这正是很多同类项目中容易缺失的部分。照着源码动手敲一遍,比看十篇技术教程都管用。

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

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

立即咨询