这段时间我一直在整理一套基于SpringBoot+Vue的铁路订票管理系统源码,后端技术栈是SpringBoot + MyBatis + MySQL,前端用Vue做前后端分离。说实话,火车票这类业务系统看起来只是个增删改查,但真正上手实现一遍才会发现:余票要防超卖、订单要防重复提交、状态流转要防乱跳,每一个环节都在考验基本功。这篇文章我会把整套项目的整体设计、核心代码、部署过程和踩坑记录完整拆一遍。项目不大,但作为全栈练手项目,含金量相当足。无论是做毕业设计、课程设计,还是想系统学一遍SpringBoot+Vue开发流程,这套代码都值得拿去看看。
1. 项目整体解读与技术选型分析
1.1 这套系统到底解决了什么问题
先从业务角度说清楚这套系统做了什么。普通用户端包括:注册、登录、车次查询(按出发城市、到达城市、日期筛选)、下单订票、模拟支付、退票、查看我的订单。后台管理端包括:管理员登录、车次管理(新增车次、修改时刻、上下架)、站点管理、用户管理、订单查询与统计。
功能不算多,但覆盖了一个业务系统从前端交互到后端数据落库的完整闭环。它模拟的是铁路售票系统里最核心的链路:选车次、下订单、扣票、支付、出票、退票。真实生产环境里还要牵扯余票池、实名制、区间限售、抢票排队,这套系统做了合理简化,但核心业务逻辑是完整的。对学习者来说,这个复杂程度刚好——太简单学不到东西,太复杂又容易劝退。
这套系统适合三类人:第一类是准备毕业设计或课程设计的学生,整体代码结构规范,拿来改改就能用;第二类是刚学完SpringBoot和Vue基础、想找一个完整项目串联知识点的开发者;第三类是准备面试、想复习业务系统设计要点的人,比如事务、并发扣减、状态流转这些经典问题,这个小项目里都有对应场景。
1.2 为什么是SpringBoot + Vue + MyBatis + MySQL
这个组合我用了很多年,它在国内Java全栈项目里的占有率极高,几乎成了“标配套餐”。选择它不是因为所谓的技术先进性,而是因为它务实、好维护、学习曲线平缓。
SpringBoot解决的是项目搭建和配置的问题。没有它的时候,搞一个Spring项目要写一堆XML配置,还要装Tomcat、配web.xml,光环境就能折腾半天。SpringBoot的“约定大于配置”和内置容器让这一切大幅简化,一个main方法就能启动Web服务。配合starter机制,想引入MyBatis只要加一个依赖,配置起来非常快。
Vue选择它的核心原因是组件化和响应式。页面拆成组件后,车次列表、查询表单、订单卡片这些部分可以独立开发和复用,数据变了视图自动更新,不用手动操作DOM。前后端分离的开发模式下,前端项目和后端项目可以并行推进,部署时也可以用Nginx托管静态文件、反向代理后端接口,整体链路非常清晰。
MyBatis在里面扮演持久层角色,最大的好处是SQL完全由开发者控制。票务系统里查询逻辑复杂,比如按城市、日期、座位类型组合筛选,SQL怎么优化、怎么加索引,MyBatis里都能直接调整。相比JPA的自动生成SQL,MyBatis的半自动模式更适合这种规则多变的业务。面试时问到的MyBatis缓存、typeHandler、XML映射原理,这套代码里也都能找到实际对应物。
MySQL就更不用说了,开源、免费、资料多,部署简单。配合InnoDB引擎的行锁和事务,正好能支撑票务系统并发扣减余票的需求。这套技术栈还有一个隐藏价值:学会它,等于掌握了国内Java全栈岗位最常见的工作技能栈,简历上写这个组合,面试官基本不用额外解释。
1.3 版本选择与兼容性避坑
版本选不对,项目跑不起来,这是初学者最容易踩的坑。我建议按下表组合搭配:
| 场景 | 推荐版本 | 说明 |
|---|---|---|
| 后端JDK | JDK 8 或 JDK 17 | SpringBoot 2.7用JDK8,SpringBoot 3.x必须JDK17+ |
| SpringBoot | 2.7.18(稳妥) / 3.2.x(新项目) | 3.x改了包名,老代码有迁移成本 |
| MyBatis Starter | 2.3.x(Boot2) / 3.0.x(Boot3) | 版本必须和SpringBoot大版本匹配 |
| MySQL | 8.0+ | 5.7也能跑,但8.0更主流 |
| Vue | Vue3 + Vite / Vue2 + vue-cli | 看仓库前端代码实际技术栈 |
| Node | 16 / 18 | 太高或太低都会遇到依赖安装问题 |
这里重点提醒一句:如果你看到一套SpringBoot 3.x的代码,里面所有 import javax.servlet.* 都要换成 jakarta.servlet.*,MyBatis starter也要用3.x版本,否则启动直接报 ClassNotFoundException。反过来,如果你还在用JDK8,就不要硬上SpringBoot 3.x。当前这套项目如果按2.7.18 + JDK8来跑,是最省心的方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 系统架构与核心模块设计
2.1 前后端分离架构怎么落地
项目的整体结构是典型的前后端分离,后端只提供RESTful接口,不渲染页面;前端用Vue管理路由和组件,通过axios请求后端接口拿JSON数据。
后端代码按标准分层结构组织:
code复制backend/
├── src/main/java/com/railway/
│ ├── controller/ # 接口层,接收请求、返回统一结果
│ ├── service/ # 业务逻辑层,事务控制在这里
│ ├── mapper/ # MyBatis数据访问接口
│ ├── entity/ # 实体类,对应数据库表
│ ├── config/ # 配置类,如CORS、拦截器、MyBatis配置
│ ├── common/ # 统一返回结果、异常处理、工具类
│ └── utils/ # JWT工具、订单号生成器等
├── src/main/resources/
│ ├── mapper/ # MyBatis的XML映射文件
│ └── application.yml # 数据源、MyBatis、端口等配置
前端的核心目录结构大致是:
code复制frontend/
├── src/
│ ├── api/ # axios请求封装
│ ├── router/ # 路由配置
│ ├── views/ # 页面组件,如登录、车次列表、订单
│ ├── components/ # 公共组件
│ └── utils/ # 前端工具,如token存储
一次完整的请求流程是这样的:用户在浏览器打开页面,Vue Router匹配到对应组件,页面调用 api/xxx.js 里的axios方法,axios把请求发到SpringBoot的Controller;Controller接收到参数后调用Service层处理业务逻辑,Service调用Mapper接口,Mapper对应的XML执行SQL操作MySQL数据库;数据层层返回,最终以JSON格式回到前端,Vue响应式更新页面。这个链路是整个项目的“主神经”,任何一次点击背后都在走这条线。
开发阶段最常见的两个问题:跨域和统一响应格式。跨域我一般用后端全局CORS配置解决,代码如下:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
统一响应格式也很重要,我习惯定义一个 Result<T> 类,包含 code、message、data 三个字段。成功时code为200,失败时code为500或自定义业务码。前端axios拦截器统一判断code,这样就避免了每个接口各写一套返回逻辑。
2.2 功能模块拆解与页面规划
整套系统可以拆成两大块:用户端和管理端。用户端面向普通用户,管理端面向系统管理员。各自对应的功能模块和前端页面如下表:
| 功能模块 | 所属端 | 核心功能 | 对应Vue页面 |
|---|---|---|---|
| 用户认证 | 双端 | 注册、登录、修改密码 | Login.vue / Register.vue |
| 车次查询 | 用户端 | 按城市、日期筛选车次,查看余票 | TrainList.vue |
| 订票下单 | 用户端 | 选择车次、座位类型,生成订单 | Booking.vue |
| 订单管理 | 用户端 | 查看订单、模拟支付、退票 | OrderList.vue / OrderDetail.vue |
| 个人信息 | 用户端 | 查看和修改个人资料 | Profile.vue |
| 车次管理 | 管理端 | 新增、编辑、上下架车次和时刻 | TrainManage.vue |
| 订单管理 | 管理端 | 查看所有订单、退票审核 | AdminOrder.vue |
| 用户管理 | 管理端 | 查看用户列表、禁用用户 | UserManage.vue |
| 数据统计 | 管理端 | 按日期统计售票量、热门车次 | Statistics.vue |
模块设计上有一个要点:用户端和管理端虽然功能不同,但底层依赖同一套用户表和订单表,只是接口权限不一样。比如用户端查订单只查当前用户的数据,管理端查订单不限制用户。这里的权限控制就是通过登录时返回的用户角色字段来实现的,管理员请求管理端接口时后端校验角色,角色不对直接拒绝。
2.3 数据库设计:表结构、索引、关系
数据库设计是这套系统的地基。我见过很多人上来就写代码,写到订单表才发现字段不够用,又回头改表结构,反复折腾。这里我直接把核心表设计思路讲清楚。
用户表 user:
sql复制CREATE TABLE `user` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NOT NULL COMMENT '登录用户名',
`password` VARCHAR(100) NOT NULL COMMENT '密码(建议BCrypt加密)',
`real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`id_card` VARCHAR(20) DEFAULT NULL COMMENT '身份证号',
`role` TINYINT DEFAULT 0 COMMENT '0普通用户 1管理员',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
车次表 train:
sql复制CREATE TABLE `train` (
`id` INT NOT NULL AUTO_INCREMENT,
`train_no` VARCHAR(20) NOT NULL COMMENT '车次号,如G1024',
`train_type` VARCHAR(10) DEFAULT 'G' COMMENT 'G高铁 D动车 K快速',
`start_station` VARCHAR(50) NOT NULL COMMENT '始发站',
`end_station` VARCHAR(50) NOT NULL COMMENT '终到站',
`start_time` TIME NOT NULL COMMENT '出发时刻',
`end_time` TIME DEFAULT NULL COMMENT '到达时刻',
`status` TINYINT DEFAULT 1 COMMENT '1启用 0停运',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_train_no` (`train_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次表';
余票库存表 train_seat_stock:
sql复制CREATE TABLE `train_seat_stock` (
`id` INT NOT NULL AUTO_INCREMENT,
`train_id` INT NOT NULL COMMENT '车次id',
`train_date` DATE NOT NULL COMMENT '乘车日期',
`seat_type` VARCHAR(10) NOT NULL COMMENT '座位类型:商务/一等/二等/硬座/硬卧',
`price` DECIMAL(10,2) NOT NULL COMMENT '该座型的票价',
`total_seats` INT NOT NULL COMMENT '总票数',
`remain_seats` INT NOT NULL COMMENT '剩余票数',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_train_date_type` (`train_id`, `train_date`, `seat_type`),
KEY `idx_train_date` (`train_id`, `train_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次坐席库存表';
订单表 ticket_order:
sql复制CREATE TABLE `ticket_order` (
`id` INT NOT NULL AUTO_INCREMENT,
`order_no` VARCHAR(32) NOT NULL COMMENT '订单号,业务唯一',
`user_id` INT NOT NULL COMMENT '下单用户',
`train_id` INT NOT NULL COMMENT '车次id',
`train_date` DATE NOT NULL COMMENT '乘车日期',
`seat_type` VARCHAR(10) NOT NULL COMMENT '座位类型',
`passenger_name` VARCHAR(50) NOT NULL COMMENT '乘车人姓名',
`id_card` VARCHAR(20) DEFAULT NULL COMMENT '乘车人身份证',
`depart_station` VARCHAR(50) NOT NULL COMMENT '出发站',
`arrive_station` VARCHAR(50) NOT NULL COMMENT '到达站',
`price` DECIMAL(10,2) NOT NULL COMMENT '票价',
`status` TINYINT DEFAULT 0 COMMENT '0待支付 1已支付 2已出票 3已退票 4已取消',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
`pay_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`),
KEY `idx_user_create` (`user_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
很多人会问:余票为什么不直接放在train表里?因为余票是跟着“车次+日期+座位类型”走的,同一个车次每天、每种座位类型的余票都不一样。如果直接在train表存一个余票数字,那要查“明天G1024二等座有几张”就没法精确表达了。拆一张库存表出来,按日期和座型分别存,查询和扣减都方便,也方便后续加日历库存。
表之间的关系也很清晰:user 一对多 ticket_order,train 一对多 train_seat_stock,ticket_order 通过 train_id 关连 train。设计时要注意给唯一键和常用查询条件加索引,比如订单表按用户查,建了 idx_user_create;库存表按车次+日期查,建了 uk_train_date_type。索引没建好,数据量一大查询就慢。
3. 核心业务流程与关键代码实现
3.1 车次查询:条件组合查询与余票联查
车次查询是用户进入系统后用的第一个功能。前端页面上有三个查询条件:出发城市、到达城市、乘车日期。后端接收到这三个参数后,从 train 表查出符合条件的车次,再关联 train_seat_stock 查出对应日期的余票和票价。
Mapper接口方法定义如下:
java复制public interface TrainMapper {
List<TrainVO> searchTrains(@Param("fromCity") String fromCity,
@Param("toCity") String toCity,
@Param("trainDate") String trainDate);
}
对应的XML映射文件:
xml复制<select id="searchTrains" resultType="com.railway.entity.vo.TrainVO">
SELECT
t.id, t.train_no, t.train_type,
t.start_station, t.end_station,
t.start_time, t.end_time,
ts.seat_type, ts.price, ts.remain_seats
FROM train t
JOIN train_seat_stock ts ON ts.train_id = t.id
WHERE t.status = 1
AND t.start_station = #{fromCity}
AND t.end_station = #{toCity}
AND ts.train_date = #{trainDate}
ORDER BY t.start_time
</select>
这里有三个细节容易踩坑。第一,MyBatis动态SQL里判断参数非空,要用 <if test="fromCity != null and fromCity != ''">,空字符串的判断一定要写,否则前端传空字符串时会查出错误结果。第二,查询条件里的城市如果支持“中途站”,就不能只用 start_station = ? 这种等值匹配,而是要建一张车次停靠站表来支持区间查询,这套系统里按始发终到设计,所以等值匹配就够用了。第三,车次和库存的关联查询会查出同一个车次多行数据,因为一个车次在 train_seat_stock 里有商务座、二等座等多条记录,前端展示时要按车次分组展示,座型可以折叠展开。
3.2 订票核心流程:事务与余票防超卖
订票是整个系统最核心的业务,也是技术含量最高的一环。用户选好车次、日期、座位类型后点击下单,后端要保证:订单号唯一、余票不能为负数、同一张票不能卖给两个人。
这里直接说核心代码逻辑:
java复制@Service
public class OrderService {
@Autowired
private TrainStockMapper stockMapper;
@Autowired
private OrderMapper orderMapper;
@Transactional(rollbackFor = Exception.class)
public String createOrder(OrderCreateDTO dto) {
// 1. 查询库存记录
TrainSeatStock stock = stockMapper.selectByTrainDateAndType(
dto.getTrainId(), dto.getTrainDate(), dto.getSeatType());
if (stock == null) {
throw new BusinessException("该车次暂无此座位类型");
}
// 2. 原子扣减库存,这里靠数据库行锁防超卖
int affected = stockMapper.deductSeats(stock.getId(), 1);
if (affected == 0) {
throw new BusinessException("余票不足,下单失败");
}
// 3. 生成订单号并插入订单
String orderNo = generateOrderNo(dto.getUserId());
TicketOrder order = new TicketOrder();
order.setOrderNo(orderNo);
order.setUserId(dto.getUserId());
order.setTrainId(dto.getTrainId());
order.setTrainDate(dto.getTrainDate());
order.setSeatType(dto.getSeatType());
order.setPassengerName(dto.getPassengerName());
order.setIdCard(dto.getIdCard());
order.setPrice(stock.getPrice());
order.setStatus(0); // 待支付
orderMapper.insert(order);
return orderNo;
}
}
扣减余票的SQL是关键:
xml复制<update id="deductSeats">
UPDATE train_seat_stock
SET remain_seats = remain_seats - #{count}
WHERE id = #{id}
AND remain_seats >= #{count}
</update>
理解这一步很重要。两个用户同时下单时,MySQL的InnoDB行锁会让后执行的update排队,第一个人的update把余票从1减到0,第二个人再执行时条件 remain_seats >= 1 不成立,影响行数为0,代码就抛异常返回“余票不足”,整个事务回滚,订单也不会生成。这就是靠数据库的原子操作来防超卖,比在Java代码里用 synchronized 锁对象可靠得多——因为多个后端实例部署时,每个实例的锁是独立的,锁不住其他实例。
反过来说,为什么不能用“先select查余票,再update扣减”?因为select和update之间有时间差,高并发下两个人同时查到余票是1,然后都去update,就会都成功,余票就变成-1了。面试时这也是经典问题:“如何防止库存超卖”,上面这个方案就是标准答案之一。
3.3 订单状态流转与退票回补
订单支付和退票的逻辑绕不开状态管理。这套系统里订单状态用数字表示,流转关系一定要理清楚,否则会出现“已退票的订单还能再退”这种低级Bug。
订单状态定义:
| 状态值 | 含义 | 可进行的操作 |
|---|---|---|
| 0 | 待支付 | 支付、取消 |
| 1 | 已支付 | 出票、退票 |
| 2 | 已出票 | 退票 |
| 3 | 已退票 | 无 |
| 4 | 已取消 | 无 |
模拟支付逻辑比较简单,就是把待支付订单改为已支付:
java复制@Transactional(rollbackFor = Exception.class)
public void payOrder(String orderNo, Long userId) {
TicketOrder order = orderMapper.selectByOrderNo(orderNo);
if (order == null || !order.getUserId().equals(userId)) {
throw new BusinessException("订单不存在");
}
if (order.getStatus() != 0) {
throw new BusinessException("订单状态不正确,无法支付");
}
orderMapper.updateStatus(orderNo, 1, new Date());
}
退票比支付多两步:第一步校验订单状态,必须是已支付或已出票;第二步在同一个事务里改订单状态为已退票,同时把库存加回去。这里最容易漏的是“库存回补”,如果只改订单状态不加库存,用户退了票但余票没有恢复,会造成可售票越来越少。
java复制@Transactional(rollbackFor = Exception.class)
public void refundOrder(String orderNo, Long userId) {
TicketOrder order = orderMapper.selectByOrderNo(orderNo);
if (order == null || !order.getUserId().equals(userId)) {
throw new BusinessException("订单不存在");
}
if (order.getStatus() != 1 && order.getStatus() != 2) {
throw new BusinessException("当前状态不支持退票");
}
// 回补库存
stockMapper.addSeats(order.getTrainId(), order.getTrainDate(),
order.getSeatType(), 1);
// 更新订单为已退票
orderMapper.updateStatus(orderNo, 3, null);
}
状态机设计的核心原则是:每一个状态变更都要有前置校验。写代码的时候不要只做“把状态从A改成B”这种操作,一定要先查当前状态,状态不对就直接抛异常。这个习惯在真实项目中会帮你避免大量线上事故。
3.4 登录鉴权:JWT与拦截器配合
系统分用户端和管理端,接口权限不能裸奔。这里我用的方案是JWT无状态鉴权:用户登录成功后,后端生成一个带用户ID和角色的token返回给前端;前端把token存在localStorage,后续每次请求在header里带上;后端通过拦截器统一校验token,解析出用户信息放ThreadLocal里供业务代码使用。
JWT生成和解析工具类核心代码:
java复制public class JwtUtil {
private static final String SECRET = "your-secret-key-change-me";
private static final long EXPIRE = 1000 * 60 * 60 * 24; // 24小时
public static String generateToken(Long userId, String role) {
return Jwts.builder()
.setSubject(String.valueOf(userId))
.claim("role", role)
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE))
.signWith(SignatureAlgorithm.HS256, SECRET)
.compact();
}
public static Claims parseToken(String token) {
return Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token)
.getBody();
}
}
拦截器在每个请求进来时先放行登录、注册接口,其他接口都校验token:
java复制@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if ("OPTIONS".equals(request.getMethod())) {
return true;
}
String token = request.getHeader("Authorization");
if (!StringUtils.hasText(token)) {
throw new BusinessException(401, "未登录");
}
try {
Claims claims = JwtUtil.parseToken(token);
request.setAttribute("userId", Long.valueOf(claims.getSubject()));
request.setAttribute("role", claims.get("role"));
} catch (Exception e) {
throw new BusinessException(401, "登录已过期,请重新登录");
}
return true;
}
}
管理端接口再额外校验角色,比如在Controller上加注解或者用拦截器判断URL前缀。登录和注册接口要列入放行白名单,这个在WebMvcConfigurer里配置拦截规则时注意排除。
顺便提一句,项目里密码不要明文存储。最基础也要用BCrypt加密(Spring Security里自带BCryptPasswordEncoder),哪怕只是自己练手项目,也应该养成这个习惯。
4. 部署运行与踩坑实录
4.1 从零到一跑通项目
跑通整个项目,环境准备是第一步。需要准备的工具和版本建议:
| 工具 | 版本建议 | 说明 |
|---|---|---|
| JDK | 8 或 17 | 与所选SpringBoot版本匹配 |
| Maven | 3.6+ | 后端依赖管理 |
| Node.js | 16 或 18 | 前端构建运行环境 |
| MySQL | 8.0 | 数据库 |
| IDE | IntelliJ IDEA + VSCode | 后端和前端开发 |
完整启动流程分五步:
第一步,创建数据库并导入初始数据。在MySQL里执行建库命令 CREATE DATABASE railway DEFAULT CHARACTER SET utf8mb4;,然后导入项目根目录 sql/railway.sql,里面有建表语句和初始车次、库存数据。导入后要重点检查几个核心表有没有数据,比如 train 和 train_seat_stock,没有初始数据,前端查询会一片空白。
第二步,修改后端配置。打开 application.yml,把数据库账号密码改成自己本机的,完整的数据源配置长这样:
yaml复制server:
port: 8080
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/railway?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: 123456
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.railway.entity
configuration:
map-underscore-to-camel-case: true
这里的坑主要在连接串。MySQL 8.0的驱动类必须写成 com.mysql.cj.jdbc.Driver,老配置里 com.mysql.jdbc.Driver 会直接报ClassNotFound。还有 serverTimezone=Asia/Shanghai 和 useSSL=false 这两个参数,缺了会报时区错误或SSL握手失败。
第三步,启动后端。用IDEA打开后端工程,等待Maven下载依赖完成后,运行主启动类。看到SpringBoot的启动日志且没有报错,说明后端起来了。
第四步,启动前端。用VSCode或命令行进入frontend目录,执行 npm install 安装依赖,再执行 npm run dev 启动开发服务器。默认端口一般是5173(Vite)或8081(vue-cli)。打开浏览器访问前端地址,能跳到登录页就成功了一大半。
第五步,全链路测试。注册一个普通用户账号,登录后台查车次、下单、模拟支付、再退票,把整个主流程走一遍。同时可以用管理员账号登录后台,看看车次管理和订单管理是否正常。到这里,整套项目就算完整跑通了。
4.2 高频问题排查速查表
做这类项目经常遇到环境问题,我把最常见的几个和解决方式整理成一张表,方便对照排查:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 后端启动端口被占用 | 8080端口被其他进程占用 | 改 server.port,或用命令查占用进程并结束 |
| MySQL连不上,报CommunicationsException | 连接串缺时区参数、SSL参数,或MySQL服务没启动 | 确认MySQL服务已启动,连接串加上 useSSL=false&serverTimezone=Asia/Shanghai |
| 启动报ClassNotFound: javax.servlet | SpringBoot 3.x的包名改动 | 把 javax.servlet 改成 jakarta.servlet,或改用SpringBoot 2.7 |
| MyBatis报Invalid bound statement | Mapper接口和XML的namespace/id不匹配,或XML没打进classpath | 检查 mapper-locations 路径,确认target目录里有没有XML文件 |
| 前端npm install报node-sass错误 | node版本和node-sass版本不兼容 | 升级node版本,或把node-sass替换成dart-sass |
| 前端访问接口跨域 | 端口不一致或后端没配CORS | 后端加CORS配置,或前端Vite配代理转发 |
| 数据库中文乱码 | 连接串缺characterEncoding,或建库时没用utf8mb4 | 连接串加 characterEncoding=utf8,建库用 utf8mb4 |
| 查询到旧数据,更新后不生效 | MyBatis一级缓存 | 事务内同一查询可能命中缓存,配置 localCacheScope=STATEMENT 或关闭一级缓存 |
这里重点展开一下MyBatis缓存的问题。MyBatis的一级缓存是SqlSession级别的,在Spring里默认开启。如果你在同一个事务里先查询订单,然后在别的地方手动改了数据库,再查一次发现还是旧数据,很可能就是一级缓存导致的。排查方法是在SQL上加上 flushCache=true 强制清缓存,或者全局设置 localCacheScope=STATEMENT。这个点同时也是MyBatis面试题的高频考点,能讲清楚一级缓存和二级缓存的生命周期和区别,面试官通常会比较认可。
4.3 拿到源码后怎么高效阅读和修改
很多同学拿到项目压缩包,第一反应是到处乱翻,看了半天不知道从哪里入手。还有人问我“怎么把SpringBoot的jar包反编译成项目”,我说这种情况完全没必要——你手上就是原始源码,反编译是给那些没有源码、只有部署包的人用的,有这个功夫不如直接看源码。
我的阅读顺序建议是这样的:先看README或项目说明文档,了解技术栈和启动方式;然后打开 application.yml,搞清楚端口、数据库配置;接着看实体类,了解表结构映射;再看Mapper接口和XML,理解数据库操作;然后是Service层,业务逻辑都在这层;最后看Controller和前端页面。这个顺序是从底层往上层走,先建立数据模型认知,再看业务逻辑,不容易乱。
如果想改功能,我建议先跑通再改,改的时候沿着“从前端页面 → API方法 → Service → Mapper → SQL”这条链路去找代码。比如想加一个“按车次号模糊查询”的功能,先在TrainList.vue里找到查询表单,看它调用了哪个api方法,再顺着找到Controller的接口路径,然后到Service和Mapper改SQL即可。
4.4 这套系统后续还能扩展什么
最后聊聊扩展方向。这套系统虽然完整,但离生产级还有不小差距,这恰恰是它的价值所在——你知道下一步该往哪走。
最直接的扩展是实名制购票,用户下单前校验身份证格式,一张身份证在同一车次只能购买一张票,这个可以加一个唯一索引 (train_id, train_date, id_card) 来实现。第二个方向是接真实支付,对接微信或支付宝时,重点要处理回调通知和验签,这比现在的模拟支付复杂得多,但也是真实项目必需的一环。第三个方向是性能优化,车次余票查询是热点操作,可以把余票数据缓存到Redis,扣减时用Redis的原子操作,再异步同步回MySQL。第四个方向是异步化,下单成功后通过消息队列异步出票、发通知,避免同步流程里外部依赖拖慢响应。
如果目标是挑战高并发场景,还可以研究Redisson分布式锁、库存分片、削峰填谷这些技术。这套订票系统就像脚手架,把这些能力一层层叠上去,每一层都能展开成一门技术专题,面试时讲出来也很有深度。
我个人实操中最大的体会是:做这种全栈项目,最容易卡住人的不是代码本身,而是环境。数据库连不上、前端依赖装不上、mapper扫不到,这三座大山能把人劝退。所以拿到源码第一步永远是照着文档把环境跑通,再开始动代码。另一个体会是,扣库存和退票补库存这两处必须用事务加数据库原子更新,别想着在Java层加锁一劳永逸,多实例部署的时候就会露馅。
最后再分享一个小技巧:把“查车次→下单→支付→退票”这条主链路完整走一遍,每个页面截图或者录屏存下来。后面不管是毕业答辩还是面试讲项目,直接把这套演示材料拿出来,比在简历上写一百句“精通XXX”都管用。
