多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于SpringBoot2+Vue3的医院预约挂号系统设计与实现

基于SpringBoot2+Vue3的医院预约挂号系统设计与实现 最近整理了一套基于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.x2.x内置了Spring 5、更好的异步支持、更强健的配置体系相比SpringBoot32.x对JDK8和大量存量依赖兼容性更好很多学校和企业环境仍以它为主。配合目前主流的JDK1.8/11跑老项目、接老依赖都不折腾。实际开发中也验证了网上能搜到的资料和解决方案一多半都是基于SpringBoot2的遇到问题查资料效率高很多。Vue3搭配Element Plus是目前后台管理系统最常见的组合。Vue3的Composition API让逻辑复用更干净用setup写业务代码比Vue2的Options API舒服太多而且Vite构建速度明显快于Webpack开发和调试体验都跟得上节奏。这套源码的前端部分把接口请求、路由守卫、状态管理都拆分了模块结构非常清晰。MyBatis-Plus在这个项目里承担的是“少写SQL”的任务。单表的增删改查、分页、条件查询全部内置开发效率非常高。预约挂号这类系统真正复杂的是多表关联和事务这部分仍需手写SQLMyBatis-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层负责数据访问。这样一个预约的完整链路就是前端发请求到ControllerController调ServiceService里开事务操作多张表最终返回统一格式的Result对象给前端。Result对象里带code、message、data三个字段前端拿到code200就认为是成功其他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 核心表结构设计详拆数据库是整个预约系统的地基我列出了最核心的五张表字段设计直接决定了业务逻辑的复杂度。科室表departmentid、科名称、科室描述、状态。结构最简单但它作为医生表和排班表的外键源头删改要谨慎建议用逻辑删除字段state来标记停用而不物理删除。物理删掉一个科室关联的医生和排班全都会变成孤儿数据这是删数据的大忌。医生表doctorid、科室id、姓名、职称、简介、头像、状态。这里要注意的是医生和用户的关系我采用doctor表里存userId字段把医生的登录账号关联到user表这样医生也能登录后台查看自己预约列表。如果不做这个关联医生身份就只能由管理员在后台代查体验差很多。排班表schedule是核心表字段设计直接影响预约流程的复杂度字段说明id主键doctor_id医生iddept_id科室id冗余存储方便按科室查询schedule_date出诊日期time_slot出诊时段上午/下午total_count总号数remain_count剩余号数status排班状态正常/停诊预约表appointmentid、userId、scheduleId、doctorId、deptId、appointmentDate、timeSlot、orderNo、status、createTime。orderNo用时间戳加随机数生成方便查询和统计status字段用tinyint表示0待就诊、1已完成、2已取消、3已停诊。用户表userid、username、password、realName、phone、role、status。密码用BCrypt加密存储不用明文这个点新手容易忽略但实际项目这是底线要求。BCrypt加密的特点是每次加密结果不同但校验时能正确匹配比MD5加盐更省事。这几张表关系清晰科室一对多医生医生一对多排班排班一对多预约记录。冗余字段如deptId、doctorId出现在预约表里是为了列表查询时减少JOIN属于典型的空间换时间思路。实测下来预约列表页在数据量过万后依然流畅这个设计功不可没。2.3 号源扣减的并发控制方案号源扣减是整个系统最核心的代码逻辑也最容易写错。先说最笨的写法先查询remain_count判断大于0然后执行update减少1。这个写法单用户没问题一旦两个患者同时下单两个请求都查到remain_count1然后都执行更新最后结果就是超卖了一个号。正确做法分两层处理第一层将判断和扣减合并成一条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-serverutf8mb4和default-time-zone08: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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: yourpassword jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 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: 0driver-class-name必须用com.mysql.cj.jdbc.Driver这是MySQL8.0的驱动类老驱动在8.0下跑不起来。serverTimezone必须显式设置否则报时区错误。useSSLfalse是因为本地开发环境没有配置证书避免连接时告警甚至报错。allowPublicKeyRetrievaltrue这个参数在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 rootlocalhost这个别急着怀疑代码先检查密码是不是对的。MySQL8.0默认的认证插件是caching_sha2_password如果你的MySQL驱动是比较老的5.1.x版本驱动不支持这个插件就会认证失败。解决办法就是升级驱动依赖到8.0.x版本或者执行ALTER USER把认证插件改回mysql_native_password但后者不推荐直接升级驱动才是最干净的做法。另一个经典报错是时区导致的乱码服务器返回的时区名无法被JDBC识别。在JDBC连接串里显式加serverTimezoneAsia/Shanghai即可解决。还有一个容易忽略的如果连接报了SSL相关警告在本地开发环境下加useSSLfalse关闭即可。这里注意别在生产环境乱关SSL加密传输在生产环境仍然是必要的。我自己的习惯是在把MySQL跑通之后先做一个最简单的查询接口验证比如直接用浏览器访问一个返回科室列表的接口确认数据库通了再继续后面的开发这样能够把“环境问题”和“业务问题”隔离开排查范围小很多。如果你的接口一上来就是500八成是环境问题而不是业务代码问题。4.2 MyBatis-Plus分页查询失效的两种场景分页不生效是我见过频率最高的MyBatis-Plus问题而且有两种完全不同的表现。第一种是根本没注册分页插件调用selectPage返回所有数据日志里看不到LIMIT语句。解决方案就是前面提过的PaginationInnerInterceptor配置必须把它注册成一个Bean。这个错误很隐蔽因为程序不报错只是结果不对新手排查半天也找不到原因。第二种是分页插件加了但SQL和插件打架。比如自定义Mapper方法里自己写了limit同时又传了Page参数进去MyBatis-Plus会在SQL外层套一层COUNT计算和分页壳结果就变成两层limitSQL直接报错。解决办法是自定义复杂分页查询时不要在方法上写死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想明白代码写起来就是水到渠成的事。这套系统最大的价值不是技术栈有多新而是把业务约束处理得完整这正是很多同类项目中容易缺失的部分。照着源码动手敲一遍比看十篇技术教程都管用。
返回列表