☰
Spring Boot+Vue.js购票系统:从零实现防超卖与订单一致性
2026/9/26 2:34:08 网站建设 项目流程

简介:基于Spring Boot与Vue.js的购票系统毕业设计源码包,面向计算机、通信、人工智能、自动化等专业学生及开发者,适用于毕业设计、课程设计、课程实验或Java开发入门进阶。项目采用前后端分离结构,覆盖在线购票的常用业务场景,包含影片展示、座位选择等模块,可支撑完整的毕设展示与答辩。压缩包共186个文件,约290KB,主要包含51个Java后端源码、29个Vue前端页面以及84个XML配置,另有CSS、JS、HTML、YAML等辅助资源,便于理解前后端交互与项目组织方式。源码经过调试测试,答辩评审95分,具备较高参考价值;已有411人浏览学习。对于新手,可从分层代码中学习Spring Boot接口开发与Vue组件化写法,有基础者也可在此基础上扩展票务管理、订单处理等功能。

1. 为什么整个毕设的成败落在“最后一张票”上

只要毕业设计选中“基于 springboot+vue.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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.13</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> </dependencies>

application.yml里除了数据源,还要关注 MyBatis 的驼峰映射。MySQL 字段remain_tickets对应实体类属性remainTickets,少了下面这行配置,查出来的对象就全是null。

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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 LambdaQueryWrapper<Order>() .eq(Order::getBizToken, bizToken)) > 0) { throw new BizException("订单已提交,请勿重复操作"); }

实际并发极高的时候,两条请求可能都通过检查,但uk_biz_token唯一索引会让后插入的那条直接抛DuplicateKeyException。所以前端传bizToken进来时,后端要捕获这个异常并转换成“请勿重复提交”的提示,而不是把 500 直接抛给用户。

4. Vue.js 侧联调:接口封装、选票状态管理与 Long 精度问题

4.1 前后端分离联调的第一件事:把 /api 转发到 Java 进程

Spring Boot 跑在 8080,Vue 的 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 分钟仍未支付的订单 List<Order> expiredOrders = orderMapper.selectList( new LambdaQueryWrapper<Order>() .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.sql:

DELETE 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 毫秒,你就能在演示时先下一单不支付,去场次列表看到余票被扣减,五秒后刷新页面,余票恢复原样。这个现象比口头解释“超时订单会取消”直观得多,也是购票系统毕设里最值得录进演示视频的一段。

本文还有配套的精品资源,点击获取

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

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

立即咨询