想搞清楚Spring Boot + Vue怎么做预约平台的人,多半是遇到了同样的问题:业务看着简单,不就是“选日期、选时段、点预约”嘛,但真动起手来,疫苗库存怎么扣、号源怎么放、怎么防止同一个号被两个人抢到、前端日历怎么联动后端数据,这些细节一个比一个磨人。这篇博客就拿“Spring Boot疫苗接种预约平台-Vue”这个项目当例子,把从数据库设计到接口联调、从并发防超卖到定时放号的全过程拆开讲清楚。适合正在做课程设计、毕业设计,或者想在公司内部快速搭一个预约演示系统的开发者参考,前端后端都兼顾,照着做就能跑出一个能演示、能压测的完整版本。
1. 需求与设计拆解:预约平台背后的隐性逻辑
1.1 表面上做预约,实际上做的是“资源分配”
疫苗预约和普通电商下单有个本质区别:电商超卖最多赔个货,预约超卖会直接导致接种点现场排长队、有人白跑一趟。所以这个项目的第一优先级不是“功能好看”,而是“资源分配要准”。一个接种点的可预约数量由什么决定?疫苗库存、接种台数量、医生工作时间、每个时段的接待能力,几个因素叠在一起,才得到一个“可放号数”。
我见过很多新手直接把“疫苗库存”当“可预约数”用,结果疫苗明明还剩100支,一个时段放出去200个号,现场全乱套。正确的做法是把“库存(库存表)”和“可预约额度(号源表)”拆开:库存只管疫苗实物还有多少,号源表才管某个接种点在某个时段能约多少人。每天定时根据库存、人员排班、已预约量重新生成未来几天的号源,这才是预约平台的正确打开方式。
1.2 为什么选Spring Boot + Vue这套组合
用Spring Boot做后端接口、Vue做前端页面,本质上是在做前后端分离。你要是不分离,用Thymeleaf模板一把梭,确实也能做出预约功能,但到了后面加小程序端、加管理后台、加数据大屏的时候,每加一个端就要改一遍后端页面,维护成本直接翻倍。
Spring Boot的优势在于生态成熟:JWT做登录、MyBatis-Plus做数据操作、Spring Security做权限、Redis做分布式锁和缓存,都是现成的轮子。Vue这边我建议直接用Vue 3 + Vite + Element Plus,组件全、文档多,路由守卫做权限拦截也方便。这套组合在校园项目和企业内部系统里都非常主流,遇到问题随便一搜就是解决方案,比各种小众框架稳妥得多。
1.3 三种角色,三种完全不同的使用路径
这个平台至少有三种角色,权限必须分开:
| 角色 | 核心诉求 | 主要功能 |
|---|---|---|
| 普通用户 | 快速预约、查询记录 | 浏览接种点、选择日期时段、提交预约、取消预约、查看接种记录 |
| 接种点管理员 | 管理号源和现场核销 | 配置放号规则、查看当日预约名单、核销预约码、统计接种数据 |
| 系统管理员 | 运维和全局配置 | 管理接种点、管理疫苗信息、查看所有平台数据、处理异常订单 |
很多项目一开始不分角色,所有接口混在一起,后台管理页直接把用户表拉出来让人改。等到你要做接口权限控制的时候,发现每个接口都得重写,等于返工。我的建议是:先设计一张简单的角色表(用户表里加role字段也行),然后用拦截器或注解把管理接口保护起来,一开始麻烦,后面省事得多。
1.4 技术选型和环境版本建议
具体版本我建议这样选,比较稳:
- 后端:Spring Boot 2.7.18 + JDK 8或11 + MyBatis-Plus 3.5.x + MySQL 8.0 + Redis 6.x + Maven
- 前端:Vue 3.4 + Vite 5 + Element Plus 2.x + Axios + Pinia + Vue Router 4
- 鉴权:JWT(io.jsonwebtoken 0.11.5)
- 接口文档:SpringDoc OpenAPI(Swagger UI交互页)
Node.js版本要注意,Vite 5要求Node 18+,太老的Node版本启动会直接报错。JDK别上来就用21,有些依赖还没适配,JDK 8或者11配合Spring Boot 2.7.18是当前兼容性最好的组合。
2. 后端核心:预约锁号与防超卖设计
2.1 号源生成:让“放号”变成系统行为,而不是人工操作
号源是预约的基础,没有号源表,用户约什么?号源表的核心字段包括:接种点ID、疫苗ID、预约日期、开始时间、结束时间、总号源数、已预约数、状态。其中状态用于区分“可预约、已约满、已过期、已停用”。
放号策略我采用的是“每日定时生成未来N天号源”。比如每天凌晨0点生成未来7天的号源,这样既保证用户有充足时间预约,又不会一次性生成太多导致数据量膨胀。假设一个接种点每天开放3个时段:上午(8:30-11:00)、下午(14:00-16:30)、晚间(17:30-20:00),每个时段放50个号,那每天就是150个号源记录未来7天,也才1050条,数据量完全可控。
放号的定时任务要注意一个坑:如果项目以后部署了多个实例(多台服务器),@Scheduled会每台都执行一遍,导致号源重复生成。解决思路是用Redis分布式锁包裹整个生成逻辑,只有拿到锁的实例才执行任务。这个点在第6章问题排查里会详细讲。
2.2 防超卖:数据库唯一约束是底线,Redis锁是闸门
预约防超卖是这类系统最关键的技术点。一个时段只剩10个号,100个人同时抢,怎么保证只成功10个?
我采用的办法是“Redis预减 + 数据库唯一约束兜底”双保险:
第一层,Redis锁。用户点击“提交预约”时,先尝试获取某个时段锁,获取不到直接提示“当前预约人数过多”。伪代码如下:
String lockKey = "appointment:lock:" + slotId; boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, userId, 30, TimeUnit.SECONDS); if (!locked) { throw new BusinessException("预约人数过多,请稍后再试"); } try { // 检查号源余量 AppointmentSlot slot = slotMapper.selectById(slotId); if (slot.getAppointedCount() >= slot.getTotalCount()) { throw new BusinessException("该时段已约满"); } // 创建预约订单 appointmentService.createOrder(userId, slotId); } finally { redisTemplate.delete(lockKey); }第二层,数据库唯一索引。Redis锁在极端情况下可能因过期时间太短而失效,所以数据库层面必须加最后一道防线。预约订单表里加一个唯一索引:(user_id, slot_date, slot_time),意思是同一个用户同一天同一个时段只能有一条预约记录;另外还要加上版本号或乐观锁控制号源表的更新。
这里我特别想强调一个新手常犯的错误:select余量后再update,中间完全没有锁,并发一高就超卖。正确做法是直接把余量判断放进SQL里:
UPDATE appointment_slot SET appointed_count = appointed_count + 1 WHERE id = #{slotId} AND appointed_count < total_count;如果这条SQL影响行数为0,说明没抢到号,直接回滚。把判断交给数据库的原子性,比在Java代码里做判断可靠得多。
2.3 订单状态机:预约不是一步到位,要能取消、能过期
预约订单不能只有“已预约”和“已完成”两个状态,我把状态定义为:
| 状态值 | 含义 | 流转方向 |
|---|---|---|
| 0 | 待接种 | 初始状态 |
| 1 | 已接种 | 接种点管理员核销后 |
| 2 | 已取消 | 用户取消或超时未核销 |
| 3 | 已爽约 | 到预约时间未到现场 |
爽约状态怎么判定?最简单的方案是:当appointment_date + end_time小于当前时间,并且订单状态还是“待接种”时,在定时任务里统一改成“爽约”。这样能倒逼用户认真对待预约,也能让管理者看到真实的接种率,而不是所有预约都算“成功预约”。
取消预约的逻辑也要严谨:用户取消后,必须释放号源额度,也就是把appointed_count减回去。这里同样要用上面的原子SQL操作,只是把加号换成减号。
2.4 事务边界:不是所有方法都要加@Transactional
用Spring Boot做这类业务,事务用得好能省很多事,用不好会带来怪问题。我的原则是:只有跨表、跨实体的写操作才加事务。比如创建预约订单(写订单表 + 更新号源count + 写预约操作日志),这三步必须在一个事务里;但生成号源的定时任务就不需要长事务,一条条插入反而更安全。
还有一个细节:事务里查出来的对象,如果被修改了但没有调用更新方法,事务提交时MyBatis-Plus不会自动帮你更新,必须显式调用updateById。有些框架会自动管理脏数据,但MyBatis-Plus不会,别把习惯搞混。
3. 前端Vue:从路由守卫到预约页面的完整实现
3.1 初始化项目:Vite创建 + 目录结构规划
用Vite创建Vue3项目非常快:
npm create vite@latest vaccination-frontend -- --template vue cd vaccination-frontend npm install npm install element-plus axios pinia vue-router npm install echarts --save目录结构建议直接按功能划分,别把所有组件都堆在components里:
src/ api/ // 按模块封装的请求接口 assets/ // 静态资源 components/ // 公共组件 router/ // 路由配置与守卫 store/ // Pinia状态管理 views/ home/ // 首页、接种点列表 appointment/ // 预约流程页面 order/ // 记录与订单详情 admin/ // 后台管理 utils/ // axios实例、工具函数3.2 路由守卫:前端权限拦截的正确姿势
前后端分离项目里,前端路由守卫做的是“体验优化”,真正的安全校验永远在后端。前端拦截的意义在于——没有token的用户压根看不到预约页,哪怕他猜到了路由地址,也会被弹回登录页。
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) return } if (token && to.path === '/login') { next('/') return } next() })管理端的路由可以加meta: { roles: ['admin'] },再在守卫里校验一下用户角色是否匹配,这样用户看到的管理菜单和路由入口是一致的,不会出现“菜单藏着但能手动输URL进去”的问题。
3.3 预约首页:日历面板 + 时段余量
预约页面的核心交互是:左边一个日期选择器,右边显示这一天每个时段的余号。数据来自什么接口?我建议后端提供一个聚合接口,一次返回指定接种点未来7天每天每个时段的余量:
{ "code": 200, "data": [ { "date": "2025-05-20", "slots": [ { "id": 101, "timeRange": "08:30-11:00", "left": 23, "status": 1 }, { "id": 102, "timeRange": "14:00-16:30", "left": 0, "status": 2 } ] } ] }前端拿到数据后,余量为0或者状态为“已约满”的时段,按钮直接置灰。这个细节非常重要:让用户从一开始就无法点击约满的时段,而不是等他提交了再告诉他“约满了”,体验差距非常大。
3.4 axios封装:统一错误处理,别让每个页面都写catch
我见过不少项目的每个页面都单独写一遍“请求失败弹出提示”,代码重复严重,后来统一封装了个axios实例,几百行重复代码瞬间没了:
const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') if (res.code === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(new Error(res.message)) } return res }, error => { ElMessage.error(error.response?.data?.message || '网络异常,请稍后重试') return Promise.reject(error) } )这里要特别提醒:401的处理一定要放在响应拦截器里统一做,否则用户登录过期以后,每个接口都会弹一个错误框,非常烦人。统一跳转登录页,才是用户能接受的处理方式。
3.5 预约倒计时:别用本地时间,要用服务端时间差
预约页面常常要展示“订单保留15分钟,请在15分钟内确认信息”,这个倒计时不能直接用前端new Date(),因为用户电脑时间可能不准,甚至有人会为了占号故意改时间。正确做法是:后端在下单接口返回两个字段——expireTime和serverTime,前端用expireTime - serverTime计算出剩余毫秒数,再用setInterval每秒更新。这样即使本地时间错了,倒计时依然准确。
const remainMs = expireTime - serverTime const timer = setInterval(() => { remainMs -= 1000 if (remainMs <= 0) { clearInterval(timer) ElMessage.warning('预约已超时,请重新选择时段') location.reload() } }, 1000)4. 数据库设计:五张核心表与索引规划
4.1 核心表结构
整个平台的数据库核心是五张表。
用户表user:id主键、username、password(BCrypt加密后存储)、real_name、phone、id_card(接种需要实名)、role(0普通用户、1管理员、2接种点管理员)、create_time。
疫苗表vaccine:id、name(如“流感疫苗”)、manufacturer、batch_no、stock_count、inventory_warning(低库存预警阈值)、status。
接种点表vaccination_point:id、name、address、daily_capacity(每日最大接种量)、work_start_time、work_end_time、status。
号源表appointment_slot:id、point_id、vaccine_id、slot_date、start_time、end_time、total_count、appointed_count、status。其中slot_date字段类型必须用DATE而不是DATETIME,否则日期范围查询会出各种边界bug。
预约订单表appointment_order:id、order_no(业务流水号)、user_id、point_id、vaccine_id、slot_id、appointment_date、time_range、status、qr_code(预约凭证)、create_time、update_time。
4.2 索引怎么加:不能有索引就乱加
索引不是越多越好,每个索引都影响写入速度。我的建议是最少加这几个:
| 表 | 索引 | 用途 |
|---|---|---|
| appointment_order | uk_user_slot (user_id, slot_id) | 唯一约束,防止同一用户重复预约同一时段 |
| appointment_order | idx_user_id (user_id) | 查询“我的预约记录” |
| appointment_order | idx_slot_id (slot_id) | 管理员查某个时段的预约名单 |
| appointment_slot | idx_point_date (point_id, slot_date) | 前端查某接种点某天可约时段 |
| appointment_order | idx_status_date (status, appointment_date) | 定时任务查爽约订单 |
唯一索引uk_user_slot是这个系统防超卖的数据库底线。哪怕Redis锁因为极端原因失效,数据库这一层也会让第二个重复预约直接报“Duplicate entry”,配合业务代码的异常捕获,最终订单数不会超过号源数。
4.3 统计SQL:接种率与趋势分析
管理后台通常需要一个“近7天接种趋势”的折线图,SQL这样写比较直观:
SELECT appointment_date AS date, COUNT(*) AS total_orders, SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) AS vaccinated_count, ROUND(SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) / COUNT(*), 2) AS rate FROM appointment_order WHERE appointment_date BETWEEN #{startDate} AND #{endDate} GROUP BY appointment_date ORDER BY appointment_date;这种SQL在数据量小的时候直接查表完全没问题。如果以后预约量到了几十万条,再考虑加一层汇总表(比如每天凌晨算好前一天的数据存起来,查询时直接读汇总),不要一开始就上大宽表,过度设计没必要。
5. 实操过程:三天搭出可演示版本
5.1 第一天:后端脚手架与登录鉴权
先用Spring Initializr生成工程,核心依赖选这几个:Spring Web、MySQL Driver、Lombok、Spring Security(如果你不想被Security繁琐的配置折磨,可以先用Sa-Token或者手写拦截器,这里我选择手写JWT拦截器,更可控)。
application.yml的关键配置:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/vaccination?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl有一个细节几乎是必踩的坑:JDBC连接串一定要加serverTimezone=Asia/Shanghai,不然日期字段查出来会差8个小时,前端显示的时间跟你数据库里存的不一致,排查起来极其隐蔽。
登录取数流程:用户提交用户名密码,后端用BCrypt校验密码,通过后签发JWT,返回token和用户基础信息。我习惯把token有效期设成2小时,加上一个rememberMe参数,如果选了记住我,有效期延长到7天。
String token = Jwts.builder() .setSubject(userId.toString()) .claim("role", user.getRole()) .setExpiration(new Date(System.currentTimeMillis() + expireTime)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();5.2 第二天:预约核心流程与事务
预约接口的完整流程是这样的:
- 校验用户登录状态和接种点、疫苗参数;
- 加Redis锁(键为
appointment:lock:{slotId}); - 查询号源状态,如果已约满返回错误;
- 生成订单号,插入订单表(状态为“待接种”);
- 执行原子更新
UPDATE appointment_slot SET appointed_count = appointed_count + 1 WHERE id = ? AND appointed_count < total_count; - 如果影响行数为0,抛出异常回滚事务;
- 删除Redis锁,返回订单详情和预约二维码。
这里把第4步和第5步的顺序这么安排是有讲究的:先插入订单,再扣减号源。有人会反问,如果先扣号源再插订单,订单插入失败的时候回滚不也一样吗?逻辑上一样,但插入订单需要拿到订单号,而订单号可以通过Redis自增或雪花算法在事务外生成,顺序上不影响。我更倾向先插订单后扣号源,是因为订单是核心业务实体,扣号源只影响一个数字,出了问题人工修数据也容易定位。
5.3 第三天:前端联调、跨域与打包部署
前后端联调阶段最常见的就是跨域问题。本地开发时,前端跑在5173端口,后端跑在8080端口,直接fetch必然跨域。最简单的方案是在vite.config.js里配置代理:
export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })这样前端代码里请求/api/appointment/list,Vite会把请求转发到http://localhost:8080/api/appointment/list,浏览器视角下不存在跨域。
上线部署时同样的问题用Nginx解决:
server { listen 80; server_name your-domain.com; location / { root /var/www/vaccination-frontend/dist; 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配置是必须的。Vue Router开启了history模式后,刷新/appointment页面时Nginx会返回404,加上try_files保持前后端路由的一致性,让所有路径都回到index.html再由前端路由解析。
打包命令就是常规的npm run build,产物在dist目录。后端打包是mvn clean package -DskipTests,生成target目录下的jar包。部署时先确保MySQL和Redis起来了,再用java -jar启动后端,Nginx托管前端静态文件并反向代理API。
6. 常见问题与排查实录
6.1 明明扣了库存,前端却提示“余号不足”
这种情况八成不是因为号源真的不够,而是事务没提交。Spring的@Transactional默认只在抛出RuntimeException时回滚,如果你在事务方法里自己catch了异常并且没往外抛,事务照样会提交。而号源扣减和订单插入如果不在同一事务里,极可能出现“订单插入失败,库存已扣减”的脏数据。
排查方法:打开MyBatis-Plus的SQL日志,看提交顺序;或者在事务方法里加断点,确认异常是否真的抛出。修整方向是:把订单插入和号源扣减放在同一个事务方法里,并且自定义业务异常要继承RuntimeException。
6.2 预约成功但前端一直卡在加载中
这个问题九成是跨域或token问题。预约成功意味着后端接口确实返回了200,但前端axios封装的响应拦截器判断res.code !== 200时弹了个错误,然后再Promise.reject,页面代码忘记处理reject状态,loading就永远不消失。
另一个可能:预约接口返回的qr_code是个很长的字符串,JSON解析没问题,但如果接口返回了BigInt类型ID而前端JavaScript无法精确表示,会导致字段错乱。解决方案:给主键加@JsonSerialize(using = ToStringSerializer.class),把Long类型的ID序列化成字符串。
6.3 定时任务重复生成号源
生产环境多实例部署后,@Scheduled默认每台机器都会执行,导致号源翻倍。我当时踩过这个坑,排查了半小时才意识到是负载均衡后面挂了2台应用。
解决思路是给定时任务加分布式锁,用Redis的setIfAbsent实现:
String lockKey = "scheduler:generateSlot"; boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.MINUTES); if (locked) { try { generateNextSevenDaysSlots(); } finally { redisTemplate.delete(lockKey); } }锁的过期时间一定要大于任务最大执行时间,否则任务没执行完锁就过期了,第二个实例也会进来,又变成重复执行。
6.4 并发压测200线程涌入,超卖10个号
我在本地用JMeter模拟过一个号源80的时段,并发200个线程同时提交,结果产生了90个成功订单。排查过程是这样的:
第一,检查Redis锁的过期时间。我原来设置了10秒过期,但事务方法里查询和更新之间执行了外部API调用,耗时超过10秒,锁提前释放,第二个请求进来了。
第二,检查唯一索引。我那版的唯一索引只建在了(user_id, slot_id)上,但压测时我是用200个不同用户去抢同一个slot,唯一索引压根拦不住。
第三,检查号源扣减SQL。发现问题根本不是超卖,而是订单重复——同一个用户在极短时间内点了几次提交按钮,前端没做防重复提交。加上按钮loading态和redisTemplate.delete(lockKey)确保只删自己的锁之后,再压测,成功订单数精确等于80。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 成功订单数 > 号源数 | Redis锁提前失效 / 扣减SQL非原子 | 检查锁过期时间、验证SQL影响行数 |
| 同用户重复预约成功 | 前端未防重复提交 / 唯一索引缺失 | 加按钮disabled、补唯一索引 |
| 库存减了订单没生成 | 事务边界不对 / 异常被吞 | 检查@Transactional、抛异常方式 |
| 用户取消后号源没释放 | 取消逻辑忘记更新count | 取消接口里加原子更新语句 |
6.5 账号登录后马上跳回登录页
这个问题通常出在JWT的密钥或有效期配置上。确认启动配置文件里secret不是默认值、有效期不是0,前端axios请求头里Authorization前缀是Bearer(注意有个空格),后端解析时也是同样规则,两端不一致就会一直401。
还有一种情况是后端返回了401,响应拦截器清了token,但问题是接口路径本身写错了(比如多了一个斜杠),走了一个不存在的接口,拦截器把它当成鉴权失败处理。排查时先看浏览器Network面板,看请求路径是否真的是/api/user/info,别一上来就怀疑token逻辑。
写在最后的几点个人体会
真正把这个项目从零搭完跑了压测之后,我最大的体会是:预约平台的核心从来不是“能预约”,而是“预约得准”。一个看似简单的appointed_count + 1,背后是并发控制、事务边界、唯一约束三层保障叠起来才能不出问题。建议你自己动手的时候,先拿JMeter或Postman把并发测试跑通,亲眼看到“200个人抢80个号,最终80个成功”这个结果,再去做前端页面,心里会踏实很多。
另外还有个小经验想分享:开发阶段记得把所有默认密码、JWT密钥、数据库账号都换成随机生成的强密码,宁可麻烦一点,也别图省事用admin/admin——这类系统一旦上线,马上会被扫描器盯上,弱口令漏洞是安全事故里最高发的一种。项目后续还可以往几个方向扩展:对接微信公众号或小程序预约、增加疫苗库存消息推送、加一个爽约三次自动拉黑的规则,后端和前端都不用大改,只需要在现有表结构上增加字段和定时任务,整体架构完全撑得住。