尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

SpringBoot+Vue疫苗预约系统:从架构到并发防超卖

SpringBoot+Vue疫苗预约系统:从架构到并发防超卖 前阵子我把一套疫苗发布和接种预约系统从零到一完整搭了起来后端用的SpringBoot前端用的Vue数据库MySQL整套源码整理完之后可以直接拉下来跑不需要再改业务逻辑。这段时间一直有做毕业设计或者接外包的朋友问我这套东西的细节今天就把它掰开揉碎讲一遍——不只是告诉你怎么运行更重要的是聊聊这个系统里的业务设计、并发控制、权限模型和部署过程中实测踩到的坑。无论你是想拿这套源码做二次开发还是单纯想参考一个前后端分离项目的完整实现这篇都值得看到最后。1. 疫苗预约系统到底在解决什么问题1.1 三个真实痛点排队、信息差、记录断档这套系统最开始的出发点其实特别朴素。社区医院每天的接种门诊早上七点就有人搬着小马扎来排队队伍从药房门口一直甩到医院大门外。过去那种“到点来拿号”的模式对工作人员来说是每天重复的发号、劝退、解释对老百姓来说是请假跑一趟还不一定排得上。疫苗到货了没有、一共多少支、哪个接种点还有库存这些信息基本靠电话和口头通知传播效率极低。再往深了看还有一个更隐蔽的问题——接种记录断档。纸质登记簿记录的信息换一个接种点就查不到了用户换地方生活之后历史接种情况完全变成一笔糊涂账。这三个痛点叠加在一起就是这套系统的立项目标让疫苗发布信息公开透明让预约流程线上化让接种记录电子化、可追溯。1.2 系统边界与三种角色视角整套系统划分为三个角色每个角色的操作范围和关注点完全不一样。普通用户看到的是最前端的部分疫苗公告列表可以查接种点有地图式的列表展示疫苗批次按名称、生产企业、适用人群筛选选定接种点和时间段之后提交预约预约结果在个人中心里随时可查接种完成之后能查看自己的电子接种记录。疫苗管理员是业务的实际运营者负责发布疫苗批次和库存维护接种点的基本信息审核用户的预约申请到现场确认接种完成后把状态从“已预约”改为“已完成”。系统管理员管的是“元层面”的东西用户账号管理、角色分配、接种点的增删改查以及最基础的数据统计——每天预约量、各接种点的负载情况、各疫苗批次的剩余库存。这个边界划分很重要。做这类业务系统最容易犯的错误就是把所有功能塞给所有人角色不清会导致权限代码混乱。三种角色对应三套菜单、三套接口权限这是整个系统骨架。1.3 业务闭环从发布到接种的完整流转梳理一下这条主线的全流程理解了这条链路后面看数据库设计和代码实现都会顺畅很多。疫苗管理员在后台发布一个疫苗批次比如“某某流感疫苗生产企业XX本批次入库500支有效期为2026年6月”。用户登录后看到这条公告筛选距离自己最近的接种点选择日期和上午/下午时段提交预约。系统收到请求后要做三件事校验该疫苗批次还有没有库存、校验该用户在这个时段没有重复预约、校验接种点剩余容量是否充足。校验通过预约单生成状态是待确认。管理员在后台审核通过后状态变为已确认。用户按预约时段到场工作人员核对身份并完成接种系统将该预约单置为已完成同时生成一条接种记录——记录里包含了疫苗批次号、接种日期、接种点、第几剂次这些核心信息写进独立的接种记录表。这条链路看起来简单但每一个环节都有对应的技术难点。库存校验要防并发超卖重复预约校验要依赖数据库唯一索引状态流转要设计清楚的状态机角色权限要用JWT和路由守卫双重控制。这些正是我要重点展开的部分。2. 为什么是SpringBootVueMySQL这套组合2.1 前后端分离架构的取舍这套系统采用前后端分离架构后端只提供RESTful API前端做页面渲染和交互。直接的好处是前端开发和后端开发可以并行推进互不阻塞部署时可以分别扩展前端静态页面扔到Nginx后端Java服务独立跑在Tomcat容器里接口层和页面层通过JSON交换数据后续如果要做小程序或者App后端API可以直接复用。对于一个疫苗预约系统来说交互逻辑主要集中在用户端——筛选条件、日期选择、状态展示、表单校验这些用Vue做组件化开发非常顺手。而业务的严谨性——库存扣减、状态流转、权限校验——交给SpringBoot的Service层统一处理两边边界清晰。2.2 后端选型理由与核心依赖后端选择SpringBoot理由很直接社区活跃、资料全、出问题好排查。SpringBoot把Spring繁琐的XML配置全部变成了自动配置内嵌Tomcat一个java -jar就能把服务拉起来这对中小型项目太友好了。这套系统的后端核心依赖是这些SpringBoot 2.x Web 组件提供RESTful接口支持MyBatis-PlusORM框架单表CRUD可以直接用封装好的方法复杂查询写XML效率和可控性平衡得很好MySQL 8.0驱动JWTJava JWT库生成和校验JSON Web TokenKnife4j接口文档自动生成联调阶段省了大量写文档的时间后端项目结构按业务分层组织com.example.vaccine ├── controller // 接口层只做参数接收和结果封装 ├── service // 业务逻辑层 │ └── impl ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体类 ├── config // 拦截器、跨域、静态资源配置 ├── common // 统一返回结果、异常处理、工具类 └── VaccinesystemApplication.java2.3 前端选型理由与目录设计前端用的Vue 2 Element UI组合。Element UI的表格、表单、日期选择器、弹窗组件对管理后台和用户端页面来说足够成熟稳定不需要自己造轮子。前端目录结构这样组织src ├── api // 所有接口请求封装按模块拆文件 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由表包含动态路由和守卫 ├── store // Vuex状态管理存储用户信息和Token ├── utils // 请求封装、工具函数 └── views // 页面组件按角色分目录为什么选这套组合而不是更花哨的方案因为疫苗预约系统本质上是一个数据密集型业务系统核心是CRUD的准确性、权限的严谨性、并发的可控性而不是炫酷的视觉呈现。把合适的技术用在合适的位置比追逐新技术栈更重要这是这类项目应该有的选型态度。3. 数据库建模与预约并发控制3.1 核心表的设计逻辑整套系统的数据模型围绕“人、苗、点、约、记”五个字展开。数据库里最主要的几张表表名作用核心字段sys_user用户表username, password, real_name, id_card, role_typevaccine_info疫苗批次表vaccine_name, manufacturer, batch_no, quantity, remaining_quantity, expiry_datevaccination_site接种点表site_name, address, contact_phone, daily_capacityappointment预约表user_id, vaccine_id, site_id, appoint_date, time_slot, statusvaccination_record接种记录表user_id, vaccine_id, site_id, vaccinated_date, batch_no, dose_number疫苗批次表里remaining_quantity是关键字段它代表当前可预约的剩余库存。预约表里的status字段PENDING待确认、CONFIRMED已确认、COMPLETED已完成、CANCELLED已取消则是业务流转的核心。这里有个设计细节值得说明预约表为什么不直接冗余疫苗名称和接种点名称而只存外键ID因为疫苗批次或接种点的信息一旦变更冗余字段会导致数据不一致而用外键关联可以保证查询时始终拿到最新数据。管理端页面展示时需要名称直接用JOIN查询或者在前端用字典映射即可。3.2 预约防超卖的两道防线这是这套系统最需要严肃对待的问题。想象一下某批次流感疫苗剩余10支200个人同时在线点击预约如果代码写成“先查一下剩余量大于0就插入预约单”那这10支疫苗会被超卖至少几十支。我在设计预约接口时加了两道防线缺一不可。第一道防线是数据库层面的原子更新。扣减库存时用条件更新只有剩余量大于0才允许扣减UPDATE vaccine_info SET remaining_quantity remaining_quantity - 1 WHERE id #{vaccineId} AND remaining_quantity 0这条SQL是原子的InnoDB的行锁保证了同一时刻只有一个事务能成功执行。如果影响行数为0说明库存已被抢完直接返回“疫苗库存不足”的提示。这种方式比乐观锁version字段更直接因为它把“检查剩余量”和“扣减库存”合并成了一条语句天然避免竞态条件。第二道防线是唯一索引防止同一个用户在同一接种点、同一预约日期、同一时段重复提交ALTER TABLE appointment ADD UNIQUE KEY uk_user_site_time (user_id, site_id, appoint_date, time_slot);配合这段逻辑即使前端按钮被疯狂点击或者用户用脚本并发刷接口数据库层的唯一约束也会拦下第二次插入程序捕获到DuplicateKeyException后统一返回“该时段已有预约记录”。3.3 状态机设计预约单的完整生命周期预约单的状态流转我建议一开始就明确画出来否则写代码的时候会在Service层乱加if else。完整的流转路径是用户提交预约状态为PENDING待确认管理员审核通过PENDING → CONFIRMED已确认工作人员完成接种CONFIRMED → COMPLETED已完成用户或管理员取消PENDING → CANCELLED、CONFIRMED → CANCELLED只有这三条合法路径。不允许出现PENDING直接跳COMPLETED也不允许COMPLETED之后再取消。在代码实现上Service层每次更新状态前都校验当前状态是否符合预期。比如取消预约时只允许PENDING和CONFIRMED状态取消失效已完成的不允许撤销。同时取消预约时要回补疫苗库存——CONFIRMED变CANCELLED时需要把之前扣减的remaining_quantity加回来。这个联动逻辑容易漏但漏掉的后果很严重库存会越变越少最后明明没人预约也提示无库存。3.4 权限JWT鉴权与三种角色的路由控制权限这块后端用JWT做无状态鉴权前端用路由守卫控制页面访问两层配合。后端流程用户登录成功后接口返回一个JWT字符串Token里携带用户ID和角色标识。前端把Token存到Vuex并在每次请求时放入请求头Authorization: Bearer token。后端写一个拦截器拦截所有/api/**请求校验Token合法性和有效期然后把用户信息放入ThreadLocal供后续业务方法使用。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); } Long userId JwtUtil.parseToken(token); if (userId null) { response.setStatus(401); return false; } UserContext.setUserId(userId); return true; } }前端路由守卫做的事情是对应维度的控制未登录用户只能访问登录页和公告列表页普通用户无法进入管理员后台管理员后台的路由统一挂在meta: { roles: [ADMIN] }下面由router.beforeEach统一判断。router.beforeEach((to, from, next) { const token store.state.token if (to.meta.requiresAuth !token) { next(/login) return } if (to.meta.roles !to.meta.roles.includes(store.state.role)) { next(/403) return } next() })需要注意的是前端路由守卫只是用户体验层面的控制真正的权限校验必须依赖后端接口的拦截逻辑。疫苗管理员可以直接发POST请求调用预约审核接口吗后端接口必须再次校验当前登录用户的角色是否有权操作。前端的隐藏只是障眼法后端防御才是真实的安全边界。4. 从零跑通项目分步启动指南4.1 环境准备清单拿到源码之后第一步是准备环境。我建议按下面这张表核对版本版本不匹配是启动报错的第一大来源。工具版本建议说明JDK1.8或11SpringBoot 2.x完全兼容Maven3.6及以上依赖管理IDEA自带可用MySQL8.05.7也能跑但驱动配置不同Node.js14或16Vue CLI要求的最低版本npm6.14及以上前端依赖安装IDEA / VSCode任意后端建议IDEA4.2 后端启动改配置、建库、跑起来后端启动分三步走。第一步在MySQL里建库并导入SQL脚本。项目里会带一个vaccine_system.sql里面包含建库、建表、初始数据。执行完之后数据库里会有一个vaccine_system库自带管理员账号admin、演示的疫苗批次数据和两个接种点记录。第二步修改application.yml的数据源配置这是唯一必须要改的地方。数据库用户名和密码替换成本地的spring: datasource: url: jdbc:mysql://localhost:3306/vaccine_system?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8 username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 server: port: 8080 jwt: secret: vaccine-system-secret-key expiration: 86400000第三步在IDEA里打开后端项目等待Maven下载依赖然后运行VaccinesystemApplication.java的main方法。控制台出现Started VaccinesystemApplication就算启动成功。这时候可以在浏览器访问http://localhost:8080/doc.html看到Knife4j生成的接口文档页面说明后端接口服务已经就绪。4.3 前端启动装依赖、配代理、联调前端启动也分三步。第一步命令行进入前端项目目录执行npm install。这一步在首次执行时耗时最长依赖包比较多如果网络状况不佳可以切换国内镜像源。第二步在vue.config.js里配置开发环境的接口代理。Vue开发服务器跑在8081端口后端接口在8080端口浏览器直接请求8080会有跨域问题。最省事的方案不是在后端开CORS而是通过开发服务器代理转发把/api前缀的请求统一转发到后端服务module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }第三步执行npm run dev浏览器自动打开http://localhost:8081。用初始化账号admin和密码admin123登录应该能进入管理后台看到疫苗批次列表和预约审核菜单。到这一步前后端联调就算完全跑通了。5. 实测踩坑部署与并发场景下的真实问题5.1 MySQL 8.0驱动与时区最容易卡住的启动问题我拿这套系统在全新环境部署时第一个报错就出在数据库连接上。报错信息是Could not create connection to database server。第一次遇到的人会很慌以为是数据库密码错了但密码反复核对没问题。真正的坑有两个第一个是驱动类名变了。MySQL 5.7用的驱动类是com.mysql.jdbc.DriverMySQL 8.0必须用com.mysql.cj.jdbc.Driver。如果你的application.yml里写的还是旧驱动直接换掉。第二个是时区问题。MySQL 8.0默认时区设置和JDBC连接需要显式声明时区否则会报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized。这个报错信息因为编码问题看起来像乱码很容易被误判。解决办法是在连接URL后面加serverTimezoneAsia/Shanghai。实际上这两个问题可以提前规避安装MySQL 8.0之后初始化时执行SET GLOBAL time_zone 8:00同时保持JDBC URL里的serverTimezone参数。代码和数据库两侧都约定好时区才能避免后续日期时间字段奇怪偏移的问题。5.2 Vue打包进SpringBoot刷新404与静态资源路径开发环境跑通之后接下来是打包部署的问题。前端执行npm run build生成dist目录把dist里的静态文件复制到后端src/main/resources/static下然后重新打包后端一个JAR包就包含了前后端所有内容。这种部署方式很常用但会踩到一个非常经典的坑访问首页正常点击菜单跳转到子路由也正常一旦用户按F5刷新子路由页面就变成404。排查思路是这样的Vue Router默认使用History模式URL看起来是/vaccine/list这样的真实路径。刷新时浏览器会向后端发起对/vaccine/list的GET请求。后端并没有这个路径的Controller返回404。而首页/能正常显示是因为根路径被映射到了index.html。解决方案是在后端加一个全局转发规则把不匹配静态资源的路径统一转发到/index.htmlConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/).setViewName(forward:/index.html); registry.addViewController(/{path:[^\\.]*}).setViewName(forward:/index.html); } }{path:[^\\.]*}这段正则的意思是任何不带文件扩展名的路径都转发到/index.html。这样Vue Router接管路由之后刷新操作就会被重新引导到应用外壳再根据URL渲染正确的页面。如果接口路径也全部挂在/api下那静态资源转发规则不会和接口冲突这个方案在实操中很稳妥。5.3 并发预约超卖的竞态条件复现与修复这个坑我是在压测时发现的。用并发工具模拟200个线程同时提交预约疫苗库存只有10支最后数据库里出现了17条预约成功记录。复现的过程很清晰。原来的代码逻辑是1. SELECT remaining_quantity FROM vaccine_info WHERE id ? 2. if (remaining 0) { 执行插入预约单 } 3. UPDATE vaccine_info SET remaining_quantity remaining_quantity - 1问题就出在第1步和第2步之间的时间窗口。线程A查到剩余量是10线程B也查到10两个线程都认为有库存同时执行插入和更新超卖就发生了。这种“先查后改”的模式在并发场景下根本不安全。修复方案正如我在第3章所述核心是两点把库存扣减改成条件更新原子操作一条SQL同时完成检查和扣减插入预约单时依赖唯一索引拦截重复预约修复后重新压测200个线程同时打进来成功插入的预约单数量严格等于初始库存数多出的请求全部返回“库存不足”。这个案例说明了一个判断原则凡是涉及数量扣减的业务库存、余额、积分绝不能在应用层用“先查后改”的方式实现必须依赖数据库层的原子操作。5.4 Token过期与路由守卫的交互细节最后一个坑是关于登录态失效的。系统里的JWT过期时间设置的24小时但用户长时间停留在页面不操作Token到期后再次点击按钮前端Axios的请求会收到401状态码。如果不做统一处理用户会莫名奇妙发现页面没有任何反应或者弹一个红色的请求失败提示体验很差。正确做法是在Axios请求拦截器里统一处理401响应service.interceptors.response.use( response response.data, error { if (error.response error.response.status 401) { store.commit(clearUserInfo) router.push(/login) } return Promise.reject(error) } )这里需要注意细节后端返回401的时候response body里一定要包含清晰的提示信息前端拿到之后跳转登录页并提示“登录已过期请重新登录”。如果后端在Token缺失时返回的是200业务错误码那前端拦截器就无法优雅处理。另外一个容易被忽略的点后端在Token过期时要在响应头的WWW-Authenticate字段中声明认证方式这样前端才能正确识别401的场景。这个交互细节在前后端分离项目里非常关键值得你专门抽时间梳理一遍。我个人在这套系统上最大的体会是增删改查的编码占整个工作量的三成剩下七成精力全部花在了并发控制、状态流转、权限边界和联调细节上。疫苗预约这类业务系统真正的复杂度从来不在页面长什么样而在数据在任何极端情况下都是准确、一致、可追溯的。把这套源码跑通只是第一步你只有把每一张表为什么这样设计、每一个状态为什么只能这样流转、每一道并发防线为什么必须放在数据库层这些问题想明白才算是真正拿下了这个项目。后续如果想扩展功能可以沿着两个方向做一是加一个消息通知模块预约审核通过或者疫苗到货时推送微信或短信通知二是加一个数据统计面板按疫苗批次、时间段、接种点等维度做预约转化率和接种完成率的分析。这两个方向都有真实的业务价值值得动手。
返回列表