news 2026/10/23 19:25:23

SpringBoot+Vue汽车票预订系统:前后端分离实战与防超卖设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Vue汽车票预订系统:前后端分离实战与防超卖设计

汽车票网上预订系统,应该是前后端分离项目里最"亲民"的一类选题了。业务不复杂但完整度够高——用户查票、下单、退票,管理员维护车次、查看订单,角色和流程都清晰,适合拿来做毕业设计,也适合想完整走通一套Java全栈项目的朋友练手。我最近帮人把这么一套基于SpringBoot+Vue、数据层用MySQL+MyBatis的系统从头过了一遍,从建表到联调再到现在改得能稳定跑起来,整理了不少可以直接照搬的做法和容易忽略的细节。

这篇文章不是把源码从头贴到尾,而是把技术选型逻辑、数据库设计、核心接口实现、前端环境问题和部署步骤都讲清楚。不管你是Java基础还不错但没碰过SpringBoot的,还是前端只写过静态页面的,看完都应该能把这套系统在自己电脑上跑起来,并且明白每个环节为什么这么设计。

1. 定位与选型:这套系统到底解决什么问题

1.1 业务角色与核心流程

汽车票预订系统不需要去对标12306那种量级,但业务角色至少要理清楚。常规设计是两个角色:普通乘客和管理员。

乘客端要能做的动作很明确:注册登录、按出发地/目的地/日期查车次、查看车次余票和票价、选择车次下单、模拟支付、查看我的订单、发起退票。管理员端则是:维护车次信息(新增、修改、停运)、查看所有乘客订单、简单的用户管理。

核心业务流程是一条线:查票→选车次→下单→扣减余票→支付→出票。这看起来简单,但"扣减余票"这一步如果处理不好,就会出现超卖——两个人同时订最后一趟车的最后一张票,数据库里余票却变成负数,这种问题在面试和实际开发中都特别容易被问到。

1.2 技术选型的取舍逻辑

先说为什么不是 JSP + Servlet。老一代Java Web项目里确实有大量汽车票、机票预订系统的JSP写法,页面和服务端逻辑耦合在一起,改一次需求要动一堆页面。前后端分离之后,前端Vue负责交互渲染,后端SpringBoot只提供JSON接口,两边可以并行开发,维护成本低很多。

再一个是SpringBoot对比传统SSM。SpringBoot最核心的价值是"约定大于配置"——内嵌Tomcat、自动装配数据源、Starter机制统一管理依赖。你不需要再去写web.xml、Spring配置文件的扫描配置,一个@SpringBootApplication启动类加几个注解就能把项目跑起来。对于练手和毕业设计来说,省下来的时间可以放在业务逻辑上。

数据访问层我选MyBatis而不是JPA,主要原因是:汽车票查询场景里有大量动态SQL,比如根据出发地、目的地、日期、价格区间组合查询车次,MyBatis的XML动态SQL对这种场景非常友好,SQL完全在自己手里;JPA虽然写起来省,但遇到复杂查询排查成本高,对新人反而更难掌握。

这里单独提醒一下版本匹配问题。网上搜索"springboot版本太高"能搜出一堆报错,核心原因是SpringBoot 3.x最低要求JDK 17,而且javax.servlet包名改成了jakarta.servlet,很多老教程的代码直接搬过去会报类找不到。我建议做主稳妥的组合:JDK 8 + SpringBoot 2.7.x + MyBatis Spring Boot Starter 2.x,这套组合资料最多、兼容性最好。如果非要用SpringBoot 3.x,记得所有依赖坐标都换成3.x对应版本,并且确认自己的JDK是17以上。

2. 数据库设计:用户、车次、订单三张表怎么扛住业务

2.1 核心表结构与字段规划

麻雀虽小五脏俱全,汽车票系统最少需要三张核心表:用户表、车次表、订单表。字段设计直接决定后面代码好不好写,我建议按下面这个思路来。

用户表 sys_user:

字段类型说明
idbigint主键,自增
usernamevarchar(50)登录名,加唯一索引
passwordvarchar(100)加密后的密码
real_namevarchar(50)乘客姓名,出票用
phonevarchar(20)手机号
id_cardvarchar(20)身份证号
roletinyint角色,0乘客 1管理员
create_timedatetime注册时间

车次表 bus_schedule:

字段类型说明
idbigint主键
schedule_novarchar(30)车次编号,如 JD1024
start_cityvarchar(50)出发城市
end_cityvarchar(50)到达城市
start_datedate发车日期
start_timevarchar(10)发车时间,如 08:30
end_timevarchar(10)预计到达时间
pricedecimal(10,2)票价
total_seatsint总座位数
remain_seatsint剩余座位数
statustinyint0停运 1正常

订单表 bus_order:

字段类型说明
idbigint主键
order_novarchar(40)订单号,唯一索引
user_idbigint下单用户ID
schedule_idbigint车次ID
ticket_countint购票张数
total_amountdecimal(10,2)总金额
statustinyint0待支付 1已支付 2已出票 3已退票
create_timedatetime下单时间
pay_timedatetime支付时间

几个字段细节想多说两句。第一个是订单状态,建议存整数,代码里用常量或者枚举去对应,不要直接在数据库里存中文"已支付""已退票",否则统计和条件查询都很痛苦。第二个是车次编号,单独用一个字段而不是依赖自增主键,因为业务上要给乘客展示车次号,自增ID不适合直接暴露。第三个是金额,一定用 decimal 不要用 float,你永远不知道浮点数精度问题会在哪一刻咬你一口。

2.2 余票扣减与防超卖设计

这是整个数据库设计的重点。汽车票的"座位"本质是一个数量,乘客下单那张票,就相当于把车次表的 remain_seats 减掉对应的张数。最朴素的设计是先查询余票,Java代码里判断够不够,再执行更新。问题是:如果两个用户同时读到余票是1,同时下单,两个判断都通过,最后就卖出2张票,超卖了。

解决思路是把判断和扣减放到数据库层面一次完成。核心SQL就这么写:

UPDATE bus_schedule SET remain_seats = remain_seats - #{count} WHERE id = #{scheduleId} AND remain_seats >= #{count}

这条语句的特点是:只有当当前余票数量足够时,才会真的扣减;如果余票不足,影响行数为0。Java代码只需要检查update的返回值,返回0就直接抛异常提示"余票不足",不需要查询再判断这套两个步骤的操作。

更严谨一点的做法是用SELECT ... FOR UPDATE悲观锁,先把车次记录锁住再操作,但这会增加数据库锁竞争,对于练手级别完全没必要。条件更新 + 事务,已经能把超卖问题堵死了。另外,订单表的 order_no 一定要加唯一索引,防止用户重复点击下单按钮导致同一车次反复扣票,这在幂等设计里是最低成本的一道防线。

3. 后端落地:SpringBoot分层、MyBatis写法和下单防超卖

3.1 后端工程结构与分层规范

后端代码我习惯按下面的包结构组织,三层架构清晰,面试被问起也能说得明白:

com.example.busticket ├── BusticketApplication.java ├── config # CORS配置等 ├── controller # 接收请求,返回Result结果包 ├── service # 业务逻辑,事务边界 ├── mapper # MyBatis接口 ├── entity # 数据库实体类 └── common # 统一返回结果、全局异常

统一返回结果的类很关键。不要每个接口返回不同类型的数据,建议写一个Result<T>包装类,包含 code、message、data 三个字段,前后端约定好这个格式,联调的时候省心很多。全局异常处理器用@RestControllerAdvice实现,业务异常直接抛到最外层返回给前端,而不是在Controller里写一堆 try-catch。

配置文件 application.yml 的核心部分:

server: port: 8088 spring: datasource: url: jdbc:mysql://localhost:3306/bus_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true type-aliases-package: com.example.busticket.entity

map-underscore-to-camel-case: true这一行建议必开,数据库字段remain_seats自动映射成 Java 属性的remainSeats,不用在每一条SQL里写resultMap,能少写大量重复代码。

3.2 MyBatis的使用:XML动态SQL与高频坑

MyBatis有两种写法,注解和XML。简单查询用注解没问题,比如根据用户名查用户:

@Select("SELECT * FROM sys_user WHERE username = #{username}") User selectByUsername(String username);

但车次组合查询这种动态SQL,我强烈推荐用XML。比如前端传几个可选的查询条件(出发地、目的地、日期),XML里写<where>加<if>标签就能灵活拼SQL:

<select id="selectByCondition" resultType="com.example.busticket.entity.BusSchedule"> SELECT * FROM bus_schedule <where> <if test="startCity != null and startCity != ''"> AND start_city = #{startCity} </if> <if test="endCity != null and endCity != ''"> AND end_city = #{endCity} </if> <if test="startDate != null"> AND start_date = #{startDate} </if> AND status = 1 </where> ORDER BY start_date, start_time </select>

<where>标签会自动处理掉第一个条件前面多余的那个 AND,这个细节如果自己拼字符串很容易踩。还有一个高频坑是启动时报Invalid bound statement (not found),十有八九是 Mapper 接口和 XML 的 namespace 不匹配,或者mapper-locations没指向classpath:mapper/*.xml。另外接口方法名必须和XML里的id完全一致,包括包名和大小写。

顺带说一句缓存问题。MyBatis默认带一级缓存和二级缓存,但在SpringBoot工程里,如果用MyBatis-Spring的整合方式,每个Service方法调用时SqlSession的生命周期短,一级缓存基本感知不到;二级缓存默认不开,别指望缓存帮你解决性能问题,概念面试会问,实战里把SQL写对才是重点。

3.3 下单接口:事务边界与并发处理

下单接口是后端最核心的一段逻辑,我的代码大致是:

@Override @Transactional(rollbackFor = Exception.class) public OrderResult createOrder(OrderCreateRequest request) { int rows = scheduleMapper.deductSeats(request.getScheduleId(), request.getTicketCount()); if (rows == 0) { throw new BusinessException("余票不足,请刷新后重试"); } BusOrder order = new BusOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setScheduleId(request.getScheduleId()); order.setTicketCount(request.getTicketCount()); order.setTotalAmount(request.getTotalAmount()); order.setStatus(0); // 待支付 order.setCreateTime(new Date()); orderMapper.insert(order); return OrderResult.create(order); }

事务一定加在Service层,用@Transactional标注,这样扣减余票和创建订单两步操作要么同时成功要么同时回滚。如果我把这段逻辑放在Controller层,事务控制会非常混乱,这也是为什么三层架构里事务边界永远在Service。rollbackFor 指定Exception.class,因为Spring默认只回滚运行时异常,受检异常不会自动触发回滚,这个细节很多人不知道。

订单号生成建议用时间戳加随机数或者UUID,但数据库里要保证唯一。我常用的格式是yyyyMMddHHmmss加四位随机数再加用户ID后四位,基本不会撞,而且看订单号就能知道下单时间。

4. 前端Vue:从环境版本匹配到接口联调

4.1 环境准备:版本不匹配才是最大的坑

前端部分如果你搜"vue安装及环境配置",会看到一堆报错帖。这个领域最大的问题不是不会写代码,而是版本不匹配。Node.js版本太老跑不起新版Vue CLI,版本太新有些老依赖又编译失败。

我的建议是二选一,别纠结:

  • Vue 2 + Element UI + Vue CLI。配SpringBoot 2.x,资料最多、最稳,适合毕业设计和新手。
  • Vue 3 + Vite + Element Plus。工程启动快,写法更现代,但要求Node.js 16以上,部分搭建过程遇到TS相关的报错概率更高。

如果是克隆别人的前端源码,第一步先看 package.json 里的依赖版本,再对照自己的Node版本。网上那个高频报错Failed to load tsconfig '@vue/tsconfig/tsconfig.web.json',通常就是项目用了TypeScript模板但@vue/tsconfig依赖没装对版本,要么按报错提示安装匹配版本,要么干脆用JS模板省事。

安装依赖时如果npm install很慢,建议配置国内镜像源:

npm config set registry https://registry.npmmirror.com

装完依赖跑npm run serve或者npm run dev,看到 Local 地址正常弹出就算是环境通了。

4.2 页面结构与路由守卫

前端页面按角色拆开,乘客端是登录/注册、首页查票、车次列表、订单页、退票操作;管理员端是车次管理、订单管理。页面拆成组件之后,大概有:Login、Register、Home、ScheduleList、OrderList、AdminSchedule、AdminOrder。

路由用Vue Router,页面一多就必须做路由守卫。核心逻辑是:用户登录后把token存到localStorage,vuex或pinia里存用户信息;路由配置里给需要登录的页面加meta: { requiresAuth: true },然后写一个全局前置守卫:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else { next() } })

这一步能挡住没登录就访问订单页的请求,也是整个系统权限控制的前端入口。管理员页面再加一层角色判断,普通用户直接跳回首页。

4.3 接口联调:axios拦截器与跨域处理

接口请求统一用axios,我习惯建一个 request.js 封装,这样拦截器可以统一处理token加头和错误提示:

const service = axios.create({ baseURL: '/api', timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = token } return config })

后端接口如果写成/api/user/login,前端请求路径直接写/api/...,开发环境下跨域最好用代理解决,而不是在后端开CORS。Vue CLI工程在 vue.config.js 里配:

devServer: { proxy: { '/api': { target: 'http://localhost:8088', changeOrigin: true } } }

这样前端8080端口请求/api前缀会自动转发到后端8088,浏览器层面不存在跨域,是最干净的方案。如果选择在后端加CORS配置也能跑通,但生产环境部署到同源域名下其实用不上CORS,代理或Nginx才是更贴近真实的做法。

还有一个联调时必定会遇到的坑:后端返回值里如果有 LocalDateTime 类型字段,默认序列化出来是一长串数字(时间戳),前端显示很别扭。解决方法是SpringBoot里加Jackson配置,统一输出yyyy-MM-dd HH:mm:ss格式,这个一定要提前处理,否则每个日期字段都要前端手动格式化,非常痛苦。

5. 跑通源码的完整步骤与实测踩坑记录

5.1 从装MySQL到前后端启动的完整流程

拿到完整源码之后,按下面顺序走一遍,基本不会卡壳:

  1. 安装MySQL。5.7或者8.0都行,注意8.0安装时设置好root密码,安装教程网上很多。安装完成后确认MySQL服务已经启动。

  2. 初始化数据库。用命令行或Navicat执行CREATE DATABASE bus_ticket DEFAULT CHARACTER SET utf8mb4;,然后把项目里的bus_ticket.sql导入。sql文件里如果已经建好库,看清楚再执行,避免重复创建报错。

  3. 修改后端配置。打开 backend 模块的application.yml,把数据库用户名密码改成你自己的,确认端口号没被占用。

  4. 启动后端。IDEA里直接运行BusticketApplication.java,或者命令行mvn spring-boot:run。看到 "Started BusticketApplication" 就说明起来了,可以用Postman测一个车次查询接口验证。

  5. 启动前端。切换到 frontend 目录,依次执行npm install(如果已有 node_modules 可以跳过)、npm run serve。等编译完成,浏览器访问 http://localhost:8080。

  6. 登录验证。用管理员账号登录,去车次管理里新增一条车次数据,再到前端首页查这条车次,然后注册一个普通账号下单,看订单列表数据是否正常。

5.2 实测高频问题与解决思路

我把这套系统跑通过程中遇到的实际问题整理成一个表,方便你对照排查:

问题现象原因分析解决办法
启动报 Server returns invalid timezoneMySQL 8驱动要求指定时区JDBC URL加serverTimezone=Asia/Shanghai&useSSL=false
Invalid bound statement (not found)Mapper XML没被扫描到检查mapper-locations: classpath:mapper/*.xml,确认XML文件名和接口名一致
前端请求到不了后端跨域或代理没配好开发环境用 devServer.proxy,生产用Nginx同源转发
8080端口被占用其他程序占用了端口改server.port或npm run dev -- --port 4000
LocalDateTime显示乱码/时间戳缺少Jackson配置加配置类统一yyyy-MM-dd HH:mm:ss格式
余票可以扣成负数没有做数据库层防超卖用条件更新remain_seats >= #{count}并检查返回行数
SpringBoot启动报JDK版本错误SpringBoot版本和JDK不匹配要么JDK8配SpringBoot2.x,要么JDK17配SpringBoot3.x

最后再说一点我个人的体会

这套系统真正值得花时间的不是跑通,而是把里面一两个环节做深。如果你有余力,可以在现有基础上加一个"座位图选座"功能,把余票从数量升级成座位状态,那超卖问题和座位区间都会变成新的挑战;也可以对接支付宝沙箱或者微信支付测试环境,把模拟支付换成真实支付流程。数据库字段统一命名、订单号生成策略这些细节,看起来不起眼,但等你做真正项目的时候,它们才是决定代码好不好维护的地方。反正我自己的经验是,先把这个小系统的数据流彻底吃透,SpringBoot和Vue之间那层"接口契约"你就再也不会觉得玄乎了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 6:35:32

KDD-CUP99网络攻击检测实战:Python数据预处理与Django+Vue全栈实现

简介&#xff1a;这是一份面向高校学生与入门开发者的网络攻击检测实战项目资料&#xff0c;基于经典的KDD-CUP99数据集&#xff0c;用Python完成从数据预处理、特征分析到攻击类型识别的完整流程&#xff0c;适合用作课程设计、期末大作业或网络安全方向的练手项目。压缩包共7…

作者头像 李华
网站建设 2026/10/23 16:17:25

SpringBoot+Vue文旅管理系统开发实战与数据库设计复盘

这两年我前前后后帮人改了不下十套管理系统代码&#xff0c;发现一个很有意思的现象&#xff1a;凡是涉及"文化旅游"的项目&#xff0c;大家第一时间想到的都是SpringBoot&#xff0c;第二时间想到的就是Vue。单纯做页面展示其实谁都会&#xff0c;但一旦牵扯到后台管…

作者头像 李华
网站建设 2026/10/19 20:55:09

大模型API成本优化实战:上下文管理与缓存机制省钱指南

1. 先算一笔账&#xff1a;为什么你的AI账单总比预期高很多人第一次接触大模型API的时候&#xff0c;都会有一种错觉&#xff1a;按token计费&#xff0c;用多少付多少&#xff0c;应该很便宜才对。结果月底一看账单&#xff0c;傻眼了。我身边不止一个朋友跟我吐槽过&#xff…

作者头像 李华
网站建设 2026/10/19 22:49:40

力扣Hot 100矩阵题二刷复盘:从坐标建模到边界控制一次讲透

力扣Hot 100这个题单&#xff0c;刷过的人都懂&#xff0c;它基本就是算法面试的"主战场"。矩阵题在里面的地位有点特别&#xff1a;占比不算最高&#xff0c;但几乎每次大厂面试都会撞上那么一两道——旋转图像、螺旋矩阵、矩阵置零、搜索二维矩阵II、岛屿数量&…

作者头像 李华
网站建设 2026/10/19 14:02:29

SpringBoot+Vue校园视频平台:从零搭建到答辩全指南

1. 校园视频平台&#xff0c;毕设选它到底值不值每年毕业设计选题季&#xff0c;我都会被问到同一个问题&#xff1a;“学长&#xff0c;我想做个视频平台&#xff0c;用Spring Boot加Vue行不行&#xff1f;”我的回答通常很直接&#xff1a;行&#xff0c;而且这个组合非常稳。…

作者头像 李华
网站建设 2026/10/19 16:10:32

Java实现PDF表格提取:Tabula+POI无缝转Excel/CSV实战

1. 需求拆解&#xff1a;为什么“PDF 转表格”总是翻车先说个现实&#xff1a;很多 Java 工程师一碰到 PDF 表格提取就头大。PDF 这格式天生就不是给人编辑的&#xff0c;它保存的是“坐标和图形”&#xff0c;不是“行和列”。你用鼠标在 PDF 里看着是整整齐齐的表格&#xff…

作者头像 李华