Spring Boot+Vue.js购票系统:从零实现防超卖与订单一致性

发布时间:2026/9/11 17:54:52
Spring Boot+Vue.js购票系统:从零实现防超卖与订单一致性 简介基于Spring Boot与Vue.js的购票系统毕业设计源码包面向计算机、通信、人工智能、自动化等专业学生及开发者适用于毕业设计、课程设计、课程实验或Java开发入门进阶。项目采用前后端分离结构覆盖在线购票的常用业务场景包含影片展示、座位选择等模块可支撑完整的毕设展示与答辩。压缩包共186个文件约290KB主要包含51个Java后端源码、29个Vue前端页面以及84个XML配置另有CSS、JS、HTML、YAML等辅助资源便于理解前后端交互与项目组织方式。源码经过调试测试答辩评审95分具备较高参考价值已有411人浏览学习。对于新手可从分层代码中学习Spring Boot接口开发与Vue组件化写法有基础者也可在此基础上扩展票务管理、订单处理等功能。1. 为什么整个毕设的成败落在“最后一张票”上只要毕业设计选中“基于 springbootvue.js 的购票系统”很多人会把它当成一组增删改查来做真正到答辩或演示时暴露出来的问题几乎都集中在“最后一张票怎么卖”上。页面显示有余票用户同时下单后端如果处理不好库存就会变成负数。这个反直觉的点恰恰是这个题目最值得写进论文的工程细节也是面试官和答辩评委百问不厌的位置。在这套前后端分离的系统里Vue.js 负责选场次、选票、提交订单的交互Spring Boot 负责库存、订单和支付状态的流转。本文不打算去复述某个现成源码包的目录结构而是从数据建模、下单事务、前端联调、定时任务四个层面给出可以自己复现的实现路径。适合正在做毕设的在校生也适合需要快速接手购票类需求的开发。2. 数据模型先行购票系统的三张核心表怎么设计才不返工2.1 从“卖票”这个动作反推表结构购票系统的最简闭环是用户登录、查看场次、提交订单、支付后出票。因此三张核心表是最小集合用户表、场次库存表、订单表。很多初次做毕设的人会一上来就建十几张表把座位表、票档表、优惠券表、日志表全部铺开结果到了写 SQL 时自己先被外键关系绕晕。建议反向设计先画“一次购买涉及哪几个主体”再决定表要不要拆。一次购买一定涉及user、session哪个演出的哪一场和order。票价可以直接挂在场次上也可以拆成独立的票档表对毕设而言单个场次只有一个默认票价已经足够演示如果导师要求“不同区域不同价”再补一张t_ticket_type也不会改动核心逻辑。这里有一个命名坑不要用show做表名或字段名show在 MySQL 里是保留字跑 DDL 时会报语法错也不要直接用event、order这类容易被保留字命中的名字。统一加t_前缀并用t_session表示“场次”这是国内毕设代码里最常见的约定。2.2 核心 DDL 与字段取舍下面这套 DDL 可以直接作为classpath:sql/schema.sql使用所有建表语句都写成IF NOT EXISTS方便 Spring Boot 启动时自动建表。CREATE TABLE IF NOT EXISTS t_user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT BCrypt 加密后的密码, phone VARCHAR(20) DEFAULT NULL COMMENT 联系手机号, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT前台用户; CREATE TABLE IF NOT EXISTS t_session ( id BIGINT NOT NULL AUTO_INCREMENT, title VARCHAR(200) NOT NULL COMMENT 演出名称, session_time DATETIME NOT NULL COMMENT 场次时间, venue VARCHAR(100) NOT NULL COMMENT 场馆, price DECIMAL(10,2) NOT NULL COMMENT 默认票价, total_tickets INT NOT NULL COMMENT 总票数, remain_tickets INT NOT NULL COMMENT 剩余票数, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_session_time (session_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT场次/库存; CREATE TABLE IF NOT EXISTS t_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, biz_token VARCHAR(36) NOT NULL COMMENT 幂等令牌由前端生成, user_id BIGINT NOT NULL COMMENT 下单用户, session_id BIGINT NOT NULL COMMENT 场次, ticket_count INT NOT NULL COMMENT 购买张数, total_amount DECIMAL(10,2) NOT NULL COMMENT 冗余金额快照, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, pay_time DATETIME DEFAULT NULL, cancel_time DATETIME DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_biz_token (biz_token), KEY idx_user_id (user_id), KEY idx_session_id (session_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表;这是静态定义也是后续所有并发控制的基础。total_amount是“冗余金额”下单时把当时的票价快照写入订单而不是等报表查询时再去连t_session取价格。这样做的直接好处是票价后续发生调整已经生成的订单金额不受影响答辩被问“为什么订单表里要冗余价格”时答案就是“保留交易快照”。biz_token字段同样关键它对应前端点击“提交订单”时生成的一个 UUID。网络慢时用户连点两次提交两个请求的biz_token相同后端靠唯一索引挡住第二条重复请求。这个机制比单纯禁用按钮更可靠因为禁用按钮只能防同一页面不能防网络重放。2.3 预留并发控制字段与自动建表t_session里没有version字段是因为我在第 3 章选择的是数据库行级锁方案而不是乐观锁方案。乐观锁要在表上加version INT每次更新时SET version version 1 WHERE version #{oldVersion}实现上也不难。二选一即可不要两个都上否则事务逻辑里很容易出现“版本号扣了库存但没生成订单”的中间状态。Spring Boot 启动时自动建表的配置是这样spring: sql: init: mode: always schema-locations: classpath:sql/schema.sql如果平时用 MyBatis-Plus 或 JPA 自动建表这里就用不到但建议保留这份schema.sql因为论文里“数据库设计”一章需要贴完整的 DDL。mode: always在每次启动都会执行所以 DDL 必须全部写成IF NOT EXISTS否则第二次启动会直接报错。3. Spring Boot 后端用事务和行级锁把“超卖”挡在下单接口外3.1 依赖配置与 yml 里的关键参数Spring Boot 版本选择直接影响后续排错。现在的 Spring Boot 3.x 要求 JDK 17如果你的电脑装的是 JDK 8就用 Spring Boot 2.7.x用 JDK 17 就上 3.x。最怕的是照着教程复制了 Spring Boot 3 的配置本地却是 JDK 8启动时报一系列UnsupportedClassVersionError这类问题在毕设调试里非常耗时。一个可用的pom.xml片段parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.13/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependenciesapplication.yml里除了数据源还要关注 MyBatis 的驼峰映射。MySQL 字段remain_tickets对应实体类属性remainTickets少了下面这行配置查出来的对象就全是null。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root mybatis-plus: configuration: map-underscore-to-camel-case: true如果你的 IDE 创建 Spring Boot 项目时反复超时多半是 Maven 中央仓库访问慢。在settings.xml里配置阿里云镜像就好这和 Vue 的 npm 镜像属于同一个思路。密码写在 yml 里虽然能在演示时跑通但答辩如果聊到安全至少要知道生产环境不能这么写可以用环境变量password: ${DB_PASSWORD}或 jasypt 做密文配置。3.2 原子扣库存不让“查询余票”和“扣减库存”中间插进并发请求最常见的错误写法是Session session sessionMapper.selectById(sessionId); if (session.getRemainTickets() count) { // 这里被第二个线程插入 sessionMapper.deduct(sessionId, count); }两个线程同时读到remainTickets 1都判断可以买最后都执行扣减库存变成负数。解决办法是把判断和扣减合成一条 SQL让数据库的行锁来保护临界区。Mapper 方法Update(UPDATE t_session SET remain_tickets remain_tickets - #{count} WHERE id #{sessionId} AND remain_tickets #{count}) int deductRemain(Param(sessionId) Long sessionId, Param(count) Integer count);Service 方法Service public class OrderServiceImpl { Resource private SessionMapper sessionMapper; Resource private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public String createOrder(Long userId, Long sessionId, Integer count) { // 1. 原子扣减受影响行数为 0 说明余票不足 int rows sessionMapper.deductRemain(sessionId, count); if (rows 0) { throw new BizException(余票不足); } // 2. 扣减成功后再取场次信息拿到价格快照 Session session sessionMapper.selectById(sessionId); // 3. 生成订单 String orderNo generateOrderNo(); Order order new Order(); order.setOrderNo(orderNo); order.setUser matterId(userId); order.setSessionId(sessionId); order.setTicketCount(count); order.setTotalAmount(session.getPrice().multiply(BigDecimal.valueOf(count))); order.setStatus(0); orderMapper.insert(order); return orderNo; } }Transactional(rollbackFor Exception.class)要显式声明因为 Spring 默认只在碰到RuntimeException时回滚而业务里抛出的自定义BizException如果继承自Exception不写rollbackFor就不会回滚。扣库存的 SQL 和插入订单的 SQL 必须在同一个事务里库存扣了但订单没插进去要回滚订单插进去了但库存没扣也要回滚。remain_tickets #{count}这个条件保证了行锁生效的范围。UPDATE语句执行时会对t_session表里id对应的行加排他锁第二个事务的UPDATE会阻塞直到第一个事务提交或回滚。这样“最后一张票”只会卖给第一个提交成功的请求。3.3 订单状态机与幂等令牌的兜底订单表里的status字段定义了三态0 待支付、1 已支付、2 已取消。后端接口在做“支付回调”时要限定只有0才能改成1否则会出现“一张订单被支付两次库存被恢复两次”的连锁问题。Update(UPDATE t_order SET status 1, pay_time NOW() WHERE id #{orderId} AND status 0) int markPaid(Long orderId);把状态变更也写成原子条件更新是这套系统里和扣库存同样重要的一件事。订单取消同理从 0 到 2 的变更必须带上WHERE status 0防止超时任务和用户手动取消同时操作一笔订单时恢复双份库存。biz_token的兜底逻辑在插入前检查if (orderMapper.selectCount(new LambdaQueryWrapperOrder() .eq(Order::getBizToken, bizToken)) 0) { throw new BizException(订单已提交请勿重复操作); }实际并发极高的时候两条请求可能都通过检查但uk_biz_token唯一索引会让后插入的那条直接抛DuplicateKeyException。所以前端传bizToken进来时后端要捕获这个异常并转换成“请勿重复提交”的提示而不是把 500 直接抛给用户。4. Vue.js 侧联调接口封装、选票状态管理与 Long 精度问题4.1 前后端分离联调的第一件事把 /api 转发到 Java 进程Spring Boot 跑在 8080Vue 的 Vite 开发服务器跑在 5173浏览器直接访问 8080 接口会触发跨域。常见做法是让 Vite devServer 接管以/api开头的请求转发到后端这样前端代码里只需要写相对路径不用感知后端地址。// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } })rewrite的作用是把/api/order/create去掉/api前缀之后转发到8080/order/create所以 Spring Boot 的RequestMapping里不需要额外加/api前缀。这样部署到生产环境时只需要在 Nginx 层面做同样的路径转发前端代码一行都不用改。npm install慢的问题建议先执行npm config set registry https://registry.npmmirror.com再创建项目。调试 Vue 3 项目时浏览器插件要装对应版本 6 的 Vue Devtools它会把组件的reactive状态和事件调用时间线展示出来后续排查“点击按钮没反应”会比看控制台快很多。4.2 Axios 封装统一处理后端返回结构和 401后端接口要约定一个统一返回体比如{ code: 0, data: {...}, msg: 成功 }。前端在 Axios 拦截器里统一解包业务代码就不用每个页面都判断一次code。// src/api/request.js import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 10000 }) // 请求拦截器注入 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer token } return config }) // 响应拦截器统一处理业务码和 HTTP 错误 request.interceptors.response.use( response { const res response.data if (res.code ! 0) { ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) window.location.href /login } else { ElMessage.error(error.message || 网络异常) } return Promise.reject(error) } ) export default request这里baseURL: /api配合第 4.1 节的转发配置请求地址写成request.post(/order/create, data)就能命中后端接口。401 的统一处理很重要否则用户在登录过期以后点击“提交订单”看到的只是一个难看的是否登录接口报错。4.3 选票与提交按钮的状态设计购票页的核心状态是选中哪个场次、买几张票、是否正在提交、提交后是否有倒计时。用 Vue 3 的组合式 API 组织成一段可复用的逻辑// src/composables/useOrder.js import { reactive } from vue import request from /api/request export function useOrder() { const state reactive({ sessionId: , ticketCount: 1, submitting: false, countdown: 0, // 支付倒计时单位秒 timer: null }) function generateBizToken() { // 优先使用原生 crypto.randomUUID老浏览器再退回时间戳方案 return globalThis.crypto?.randomUUID() ? crypto.randomUUID() : biz_ Date.now() _ Math.floor(Math.random() * 10000) } async function submitOrder() { if (state.submitting) return state.submitting true try { const orderNo await request.post(/order/create, { sessionId: state.sessionId, ticketCount: state.ticketCount, bizToken: generateBizToken() }) startCountdown(900) // 假设后端规定 15 分钟内不支付则取消 return orderNo } finally { state.submitting false } } function startCountdown(seconds) { if (state.timer) clearInterval(state.timer) state.countdown seconds state.timer setInterval(() { state.countdown-- if (state.countdown 0) clearInterval(state.timer) }, 1000) } return { state, submitOrder } }submitting这个标志位是对bizToken幂等逻辑的补充它的作用是避免用户在等待响应期间发起第二次请求真正防止重复提交的仍然是后端唯一索引因为按钮状态在页面刷新后会丢失。countdown字段是给页面显示“支付剩余时间”用的提交成功后保存订单号等待用户跳转到模拟支付页。4.4 长整型溢出后端 Long 和前端 JS 的精度边界MySQL 自增主键在 MyBatis-Plus 中默认映射成Long简单数据量没问题但一旦将来分库分表、改用雪花算法生成订单号20 位长度的数字会超过 JavaScript 的Number.MAX_SAFE_INTEGER前端拿到的 id 最后几位会变成 0。最稳的处理方式是让后端把所有Long类型统一序列化成字符串Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer longToStringCustomizer() { return builder - builder .serializerByType(Long.class, ToStringSerializer.instance) .serializerByType(Long.TYPE, ToStringSerializer.instance); } }加上这段之后t_order.id返回给前端的就是1788888888888888888这样的字符串Vue 在v-for循环里把它当作 key 使用时不会丢精度。这个细节在答辩现场不一定能演示出来但被问到为什么前端要传字符串 id 时能答出“数字精度”这四个字印象分会明显不一样。5. 让购票演示更稳的 springboot 定时任务超时订单自动取消5.1 用 Scheduled 扫单并回补库存用户提交订单后如果不支付库存会一直被占用。毕设答辩现场最常见的操作是演示人员下了好几单但每一单都停留在支付页最后真正想买票时发现余票不够了。解决方式是用 Spring Boot 定时任务扫描超时未支付订单把订单取消并回补库存。EnableScheduling Configuration public class OrderTimeoutTask { Resource private OrderMapper orderMapper; Resource private SessionMapper sessionMapper; Scheduled(fixedDelay 30000, initialDelay 10000) public void cancelExpiredOrders() { // 查询所有超过 30 分钟仍未支付的订单 ListOrder expiredOrders orderMapper.selectList( new LambdaQueryWrapperOrder() .eq(Order::getStatus, 0) .lt(Order::getCreateTime, LocalDateTime.now().minusMinutes(30)) .last(LIMIT 100) ); for (Order order : expiredOrders) { // 只有从 0 改成 2 成功才允许回补库存 int updated orderMapper.markCancelled(order.getId()); if (updated 0) { sessionMapper.increaseRemain( order.getSessionId(), order.getTicketCount() ); } } } }Scheduled(fixedDelay 30000)表示上一次任务执行结束 30 秒后再执行下一次和fixedRate的区别是它不会出现“任务还没跑完又启动新一轮”的问题。initialDelay 10000让应用启动 10 秒后再跑第一轮避免数据库连接池还没完全就绪时定时任务先执行报错。LIMIT 100是防止某次积压的单子太多导致单次任务执行时间过长。markCancelled的 SQL 要写成条件更新UPDATE t_order SET status 2, cancel_time NOW() WHERE id #{orderId} AND status 0这一步配合updated 0的判断确保即使两个定时任务实例同时执行同一笔订单也只会被取消一次库存只会回补一次。多实例部署时要保证这一点单实例跑毕设时可用性也足够。5.2 演示前的一键重置脚本与剩余票数自检定时任务解决了演示中的脏订单问题但展示完一轮“购买、支付、退款”流程后订单表和场次余票还是残留的。建议在项目根目录放一个demo-reset.sqlDELETE FROM t_order; UPDATE t_session SET remain_tickets total_tickets;演示开始前执行mysql -h127.0.0.1 -uroot -p ticket demo-reset.sql如果你担心每次手敲命令太啰嗦也可以在 Spring Boot 里加一个只对本地开放的CommandLineRunner启动时检查t_order里是否有超过 10 天的测试数据有就自动清理。最后用curl验证一下接口链路是否完整curl -s http://localhost:8080/api/session/list | head -c 500把定时任务的fixedDelay在开发环境临时调成 5000 毫秒你就能在演示时先下一单不支付去场次列表看到余票被扣减五秒后刷新页面余票恢复原样。这个现象比口头解释“超时订单会取消”直观得多也是购票系统毕设里最值得录进演示视频的一段。本文还有配套的精品资源点击获取