☰
Java学生火车票订票系统:学生资质校验与余票并发扣减
2026/11/6 2:07:45 网站建设 项目流程

简介:这份资源是一套基于 JavaEE 的学生火车票订票系统完整工程,面向做课程设计或毕业设计的计算机专业学生,也适合想练习 JavaWeb 全栈开发的学习者。系统覆盖后台车次维护、出发与途经站点管理、寒暑假学生半价优惠、团体购票,以及前台车次查询、选座、下单等流程;这些流程完整,结构清晰,可作为参考模板对照阅读。压缩包共 318 个文件,约 24.27MB,主体是 72 个 jar 依赖包、38 个 java 源码及其对应的 class 编译文件、33 个 jsp 页面,另有 js 与 css 构成的前端资源、图片素材,并附一份可直接导入的数据库脚本。已有 3253 人学习下载。读者可以沿 MVC 分层理清 Servlet、JSP 与 JavaBean 的协作关系,借鉴订单模块的增删改查写法,理解优惠规则在业务层如何实现,并借助数据库脚本还原表结构、快速跑通项目,减少从零搭建的返工。

1. 一张学生证引出的设计题:Java 学生火车票订票系统先要解决什么

学生票的五折从来不是促销折扣,它是被乘车区间、每年次数、资质核验三重条件锁死的资格票。我见过太多 Java 学生火车票订票系统,查车次、选座、下单、付款这条主干写得漂漂亮亮,可一旦被问「这个学生为什么能买这张半价票、他今年还能买几次」,代码里就只剩一个is_student布尔字段,直接卡住。这套订票系统真正要解决的不是「把票卖出去」,而是「把票卖给对的人,且卖得不多不少」。它适合两类人:正在做毕设或课程设计、需要一套结构完整可运行系统的学生;以及要交付一套中小型订票系统的开发者。后面按需求、选型、架构、实现、踩坑、验收六段推进,每一段都落到能改能跑的程度。

2. 需求与技术选型:三类角色的权限边界与九张核心表

动手写代码之前,需求边界没划清,后面每加一个功能都要回头改表。这个项目的需求梳理我一般按「谁在用、用他做什么、他不能做什么」三问展开,得到的角色和主线,直接决定表结构和接口划分。

2.1 三类角色与四条业务主线

角色分三类。学生旅客是主用户,能查车次、查余票、填乘客、下订单、看订单、退票、维护自己的学生资质信息;售票管理员负责车次与经停站维护、余票调整、订单查询与人工退票;系统管理员多一层账号管理与数据统计。三者共用一张用户表,靠角色字段区分,不在代码里搞三套登录。

业务主线归拢成四条:车次查询与余票展示、订单生成与支付状态流转、学生资质校验与优惠计算、后台车次与订单管理。这四条线之外的东西,比如积分、发票、改签多级规则,一律砍掉。毕设和课设最容易翻车的地方不是功能太少,是贪多导致每条线都半截。我通常的做法是先把这四条线做到能演示、能对账,再谈扩展。

需求里还有个隐性约束必须提前写进文档:余票必须是「可售数量」,不是「座位总数减去已售数量」这种实时算出来的结果。前者是独立存储、能加锁的字段,后者在并发下必然对不上账。这一点决定了后面表怎么设计。

2.2 技术栈选型:为什么是 Spring Boot 加 MyBatis 这套组合

技术栈按「一个人能维护、答辩能讲清、部署能跑通」三条标准选,不追新。

层次选型选它的理由
开发框架Spring Boot内置容器,打成一个 jar 就能跑,答辩现场不用装 Tomcat
持久层MyBatisSQL 写在 XML 里,余票扣减这类需要精确控制的语句好审
数据库MySQL事务和行锁成熟,乐观锁扣减余票够用
缓存Redis(可选)车次查询热点缓存、下单幂等令牌,不加也能跑
前端Thymeleaf 或 Vue课设用模板引擎省事,毕设想体面点用 Vue 前后端分离
权限Spring Security 或自写拦截器角色简单时自写拦截器 + 注解更轻

这里有个反直觉的点:很多人一上手就想上微服务、上分库分表。以这个项目的体量,单机 MySQL 配合乐观锁完全扛得住演示和中等并发,上微服务只会让你在答辩时被追问「服务之间怎么保证一致性」而答不上来。我的经验是,把单体做扎实,把事务边界讲清楚,比堆组件更能体现设计能力。

前端要不要分离,取决于交付形式。要求交「系统」而非「网页」的,模板引擎一套页面更快;要求体现前后端协作的,Vue 加接口文档更完整。两条路的服务端代码几乎一样,差别只在返回视图还是返回 JSON。

2.3 数据库表设计:从车站到订单的九张核心表

表设计的原则是「一个事实只存一处」。余票是事实,订单状态是事实,学生优惠次数剩余是事实,其余都是推导。九张表如下。

-- 车站表:站序用于判断学生优惠区间是否覆盖行程 CREATE TABLE station ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL COMMENT '车站名称', city VARCHAR(32) NOT NULL COMMENT '所属城市', station_no INT NOT NULL COMMENT '线路站序,用于区间比较', UNIQUE KEY uk_name (name) ) COMMENT '车站'; -- 车次表:只存车次级别的信息,席别与余票拆到下一张 CREATE TABLE train ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(16) NOT NULL COMMENT '车次号,如 G1234', start_station_id BIGINT NOT NULL, end_station_id BIGINT NOT NULL, depart_time DATETIME NOT NULL, arrive_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0停运', UNIQUE KEY uk_no_depart (train_no, depart_time) ) COMMENT '车次'; -- 余票表:车次 + 乘车日期 + 席别 唯一,是可加锁的库存事实 CREATE TABLE train_seat_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_id BIGINT NOT NULL, travel_date DATE NOT NULL COMMENT '乘车日期', seat_type TINYINT NOT NULL COMMENT '1商务 2一等 3二等 4硬座', price DECIMAL(10,2) NOT NULL COMMENT '全价', total INT NOT NULL COMMENT '投放数量', remain INT NOT NULL COMMENT '可售数量', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', UNIQUE KEY uk_train_date_seat (train_id, travel_date, seat_type) ) COMMENT '余票库存'; -- 学生资质表:区间与次数都在这里,审核状态控制能否购票 CREATE TABLE student_qualification ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, school_name VARCHAR(64) NOT NULL, home_station_id BIGINT NOT NULL COMMENT '乘车区间起点站', school_station_id BIGINT NOT NULL COMMENT '乘车区间终点站', total_times INT NOT NULL DEFAULT 4 COMMENT '年度可享次数', used_times INT NOT NULL DEFAULT 0, audit_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审 1通过 2驳回', UNIQUE KEY uk_user (user_id) ) COMMENT '学生优惠资质'; -- 订单表:金额、状态、学生票标记全部落库,退票靠状态流转不删数据 CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT '业务订单号', user_id BIGINT NOT NULL, train_id BIGINT NOT NULL, travel_date DATE NOT NULL, seat_type TINYINT NOT NULL, passenger_name VARCHAR(32) NOT NULL, id_card VARCHAR(32) NOT NULL, amount DECIMAL(10,2) NOT NULL COMMENT '实付金额', is_student TINYINT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已出票 3已退票 4已取消', idempotent_key VARCHAR(64) NOT NULL COMMENT '幂等令牌', expire_time DATETIME NOT NULL COMMENT '支付截止时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_idem (idempotent_key) ) COMMENT '订单';

剩下四张表按同样思路补齐:sys_user存账号与角色,passenger存常用乘车人,train_stop存经停站与到发时刻(用于区间售停判断),refund_record存退票流水。订单表里特意留了idempotent_key唯一索引,这是防止用户连点两次下单的后悔药,具体用法放到第 4 章讲。

提示:remain字段不要用total - 已售的视图或计算列替代。库存必须是能UPDATE ... WHERE remain >= n的独立字段,否则并发下锁不住。

选型与表结构定完,项目的基本盘就稳了。后面所有模块都是围绕这几张表展开读写,改动集中在业务层,不会牵连结构。

3. 总体架构设计:分层结构与四个核心模块的职责划分

架构这一层要回答两个问题:代码怎么摆放,状态怎么流转。摆放错了,加一个功能要动五个文件;状态没定义清楚,退票和出票就会打架。

3.1 分层与包结构:让每个类只有一个改动理由

服务端按五层组织,包名直接体现职责,答辩时打开目录树就能讲。

com.example.ticket ├── controller 接口层,只做参数校验与响应封装 ├── service 业务层,事务边界在这一层 │ └── impl ├── mapper 持久层接口,SQL 在 resources/mapper 下 ├── entity 与表一一对应的实体 ├── dto 出入参对象,避免实体直接暴露给前端 ├── common 统一响应、异常、常量、工具 └── config 拦截器、跨域、数据源配置

职责边界上有两条硬规矩。第一,Controller 里不写业务判断,if (stock <= 0)这种判断放在 Service,否则同一段逻辑会在后台补票接口里再抄一遍。第二,跨表的写操作必须收在一个 Service 方法里并由@Transactional包住,比如「扣余票 + 建订单 + 扣学生次数」必须同生共死,拆到两个方法里就等着对不上账。

四个核心模块对应四条业务主线:车次模块(车次、经停、余票查询)、订单模块(下单、支付回调、取消、退票)、资质模块(学生信息提交、审核、区间与次数校验)、后台模块(车次维护、订单管理、统计)。模块之间只通过 Service 接口调用,不允许 Mapper 跨模块直连。

3.2 余票与订单的状态机:把「可能的状态」提前写死

订单状态用 0 到 4 五个值表示,流转方向固定,别的路径一律抛异常。待支付可以到已支付或已取消;已支付到已出票或已退票;已出票到已退票。已取消和已退票是终态,不再变化。写代码时用一个常量类加一个静态校验方法兜底:

public final class OrderStatus { public static final int UNPAID = 0; // 待支付 public static final int PAID = 1; // 已支付 public static final int ISSUED = 2; // 已出票 public static final int REFUNDED = 3; // 已退票 public static final int CANCELED = 4; // 已取消 // 允许的状态迁移,key 为当前状态,value 为可去的状态集合 private static final Map<Integer, Set<Integer>> FLOW = Map.of( UNPAID, Set.of(PAID, CANCELED), PAID, Set.of(ISSUED, REFUNDED), ISSUED, Set.of(REFUNDED) ); public static boolean canTransit(int from, int to) { return FLOW.getOrDefault(from, Set.of()).contains(to); } }

这段代码的价值不在逻辑复杂,而在把所有非法流转收口到一处。支付回调想直接把订单改成已出票、退票接口想把已取消订单再退一次,都会在canTransit这里被拦住并抛出业务异常。没有这个校验,测试时最容易出现「退了一张本来就没付钱的票」,账面上多退一笔。

余票状态同样要设计。库存表里的remain只减不增,唯一的增加入口是两个:退票回补和后台人工调整。这两个入口必须打日志,写明操作人、订单号、变更前后值。我习惯把这部分日志单独用一张流水表记,出问题时能一条条对出来,不至于只能拍脑袋。

3.3 学生资质校验的位置:拦截器管登录,注解管角色

权限分两件事:认人、认权。认人交给登录拦截器,解析 token 或 session 拿到用户放进 ThreadLocal;认权交给自定义注解,方法上标@RequireRole("ADMIN"),拦截器统一比对。这样后台接口不会因为漏写一行判断被学生账号调用。

@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { // 1. 认人:从请求头取 token,解析出当前用户 String token = req.getHeader("Authorization"); LoginUser user = tokenService.parse(token); if (user == null) { throw new BizException(401, "未登录或登录已过期"); } UserContext.set(user); // 存入 ThreadLocal,供 Service 读取 // 2. 认权:方法或类上有 @RequireRole 时校验角色 if (handler instanceof HandlerMethod hm) { RequireRole anno = hm.getMethodAnnotation(RequireRole.class); if (anno != null && !user.hasRole(anno.value())) { throw new BizException(403, "无权访问该功能"); } } return true; } @Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { UserContext.clear(); // 必须清理,避免线程复用导致的身份串号 } }

UserContext用 ThreadLocal 实现,afterCompletion里的清理是最容易漏的一行。Tomcat 线程池会复用线程,不清理就会出现 A 同学的请求拿着 B 同学的身份去买票,这种 bug 在测试环境偶发、线上规律出现,排查起来非常费劲。拦截器注册时排除掉登录、注册、车次查询这几个公开接口,其余一律拦截。

学生资质的判断不放在拦截器里,因为它是业务规则不是访问控制:同样是已登录学生,资质未审核通过就不能买学生票,但可以买全价票。这条判断放在下单 Service 里,和其它校验一起执行,出错时能给出「资质审核中,请选择全价票」这种可读提示。架构上把「能不能进这个接口」和「这笔业务能不能做」分开,是这套分层里最值得强调的一点。

4. 核心功能落地:从车次查询到订单生成的可运行骨架

这一章给的是能直接改吧改吧跑起来的骨架。重点不在代码长度,在每段代码背后的取舍:为什么这么写、参数怎么调、出错先看哪。

4.1 车次查询与余票分页:把「查得到」和「查得准」分开

车次查询按出发站、到达站、日期三个条件过滤,再左连余票表把可售数量带出来。注意「出发站到达站」是站序区间,不是车次的始发终到,中途站上车必须支持。

<!-- TrainMapper.xml:按区间查车次并带出指定席别余票 --> <select id="pageByRoute" resultType="TrainVO"> SELECT t.id, t.train_no, t.depart_time, t.arrive_time, s.remain, s.price, TIMESTAMPDIFF(MINUTE, t.depart_time, t.arrive_time) AS duration FROM train t JOIN train_stop from_stop ON from_stop.train_id = t.id AND from_stop.station_id = #{fromId} JOIN train_stop to_stop ON to_stop.train_id = t.id AND to_stop.station_id = #{toId} LEFT JOIN train_seat_stock s ON s.train_id = t.id AND s.travel_date = #{date} AND s.seat_type = #{seatType} WHERE t.status = 1 AND from_stop.stop_no &lt; to_stop.stop_no <!-- 站序必须递增,防止反向 --> AND DATE(t.depart_time) = #{date} ORDER BY t.depart_time LIMIT #{offset}, #{size} </select>

三个参数要留意。#{seatType}必传,不传就无法关联库存,余票会显示为空,前端看着像「没票」实际是漏参数。from_stop.stop_no < to_stop.stop_no这行是防止用户查反向区间却匹配到车次,删了会出「到站比发站还早」的诡异结果。LIMIT用 offset 分页在数据量大时会慢,但车次按日期过滤后单日数据量有限,这个项目不必上游标分页,够用就好。

查询接口允许匿名访问,因为选票阶段还没登录。真正需要登录的是下单,这个分界要在接口文档里写清楚,否则前端会在首页就跳登录,体验很差。余票为 0 的车次不隐藏,置灰显示「无票」,比直接过滤掉更符合真实订票系统的心智。

4.2 下单接口:事务、幂等、库存扣减三件套

下单是整个系统最容易出事的地方。用户手抖连点、网络重试、前端重复提交,都会造出两张订单占两次库存。解法是把「一次下单意图」变成一个唯一令牌,配合事务和条件更新。

@Service public class OrderServiceImpl implements OrderService { @Resource private TrainSeatStockMapper stockMapper; @Resource private TicketOrderMapper orderMapper; @Resource private StudentQualificationMapper qualMapper; @Override @Transactional(rollbackFor = Exception.class) // 任何异常都回滚,含受检异常 public String createOrder(CreateOrderCmd cmd) { // 1. 幂等:令牌唯一索引冲突会直接抛异常,说明是重复提交,返回原订单 TicketOrder exist = orderMapper.selectByIdemKey(cmd.getIdempotentKey()); if (exist != null) { return exist.getOrderNo(); } // 2. 学生票先校验资质与次数,不通过直接拒绝,不占用库存 BigDecimal amount = stockMapper.selectPrice(cmd.getTrainId(), cmd.getTravelDate(), cmd.getSeatType()); if (cmd.isStudent()) { StudentQualification q = qualMapper.selectByUserId(cmd.getUserId()); StudentTicketChecker.check(q, cmd); // 区间、审核状态、剩余次数 amount = amount.multiply(new BigDecimal("0.5")).setScale(2, RoundingMode.HALF_UP); } // 3. 扣减余票:条件更新,影响行数为 0 表示库存不足,直接失败 int affected = stockMapper.decrease( cmd.getTrainId(), cmd.getTravelDate(), cmd.getSeatType(), 1); if (affected == 0) { throw new BizException("余票不足,请选择其它车次或席别"); } // 4. 落订单,状态待支付,15 分钟内未付自动取消 TicketOrder order = TicketOrder.builder() .orderNo(OrderNoGenerator.next()) .userId(cmd.getUserId()) .trainId(cmd.getTrainId()) .travelDate(cmd.getTravelDate()) .seatType(cmd.getSeatType()) .passengerName(cmd.getPassengerName()) .amount(amount) .isStudent(cmd.isStudent() ? 1 : 0) .status(OrderStatus.UNPAID) .idempotentKey(cmd.getIdempotentKey()) .expireTime(LocalDateTime.now().plusMinutes(15)) .build(); orderMapper.insert(order); // 5. 学生票次数在支付成功后才扣,不在这里扣,避免占票失败却消耗次数 return order.getOrderNo(); } }

扣库存那条 SQL 是命门,写出来是这样:

<update id="decrease"> UPDATE train_seat_stock SET remain = remain - #{num}, version = version + 1 WHERE train_id = #{trainId} AND travel_date = #{date} AND seat_type = #{seatType} AND remain &gt;= #{num} <!-- 关键:余量不足时更新 0 行,不会扣成负数 --> </update>

逻辑说明补几句。remain >= #{num}放在 WHERE 里而不是先查再判断,是因为「先查后改」在并发下必然超卖,两个请求同时查到剩 1 张,然后都扣成功。条件更新把判断和修改合并成一条原子语句,靠 MySQL 行锁保证只有一个能成功。学生次数扣减故意推迟到支付成功,是因为下单不等于成交,如果下单就扣次数,用户连下三单失败,第四次就买不了学生票了,这是典型的血泪经验。

参数上:expireTime十五分钟是常见设定,太短用户来不及付,太长占座影响他人。超时取消由定时任务扫表完成,扫描条件是status = 0 AND expire_time < NOW(),取消时记得把库存加回去。

4.3 学生票校验:区间用站序比较,次数用剩余量

资质校验拆成三步,顺序不能乱:先看审核状态,再看区间是否覆盖,最后看次数是否还有。审核未通过就提示「资质审核中」,比笼统报「不能购买学生票」有用得多。

public final class StudentTicketChecker { public static void check(StudentQualification q, CreateOrderCmd cmd) { if (q == null || q.getAuditStatus() != 1) { throw new BizException("学生优惠资质未通过审核,可先购买全价票"); } // 区间判断:行程的出发、到达必须落在资质区间之内(按站序比较,不用字符串包含) int rideFrom = cmd.getFromStopNo(); int rideTo = cmd.getToStopNo(); int homeNo = q.getHomeStationNo(); int schoolNo = q.getSchoolStationNo(); boolean inside = rideFrom >= Math.min(homeNo, schoolNo) && rideTo <= Math.max(homeNo, schoolNo) && rideFrom < rideTo; if (!inside) { throw new BizException("行程超出学生优惠乘车区间"); } // 次数判断:年度可享次数减已用次数 if (q.getUsedTimes() >= q.getTotalTimes()) { throw new BizException("本年度学生优惠次数已用完"); } } }

为什么坚持用站序而不是字符串判断区间,值得单独说。曾经见过把区间存成「北京-上海」这样的文本,然后用contains判断行程是否在区间内,结果「北京-南京」这种子区间匹配不上,「南京-上海」又误判通过。站序是整型比较,天然支持中途站,也支持区间反向的对称处理,逻辑清晰还能写单元测试。

次数扣减放在支付成功回调里,用一条带条件的UPDATE完成:SET used_times = used_times + 1 WHERE user_id = ? AND used_times < total_times,影响行数为 0 就说明并发下次数已被抢占,此时退款并提示。退票时把次数加回去,但只退当年度的,跨年退票要判断乘车日期所属学年,这个细节写进接口文档,答辩时是个加分项。

4.4 后台管理:车次排班与订单查询的复用思路

后台不需要另写一套逻辑,复用学生端的 Service,只在两处加能力:车次维护和人工退票。

车次维护包含新增车次、设置经停站与到发时刻、按日期批量投放余票。投放余票本质是往train_seat_stock插行,同一个车次同一天同一席别只能有一行,用唯一索引挡住重复投放。批量插入用insert ... on duplicate key update时要注意,它会把已售出的remain重置,正确做法是冲突时只更新total并按差值调整remain,或者干脆跳过已存在的行并给出提示。我一般选择后者,简单可靠。

订单查询按订单号、乘车日期、状态、是否学生票四个条件组合过滤,后台列表要多展示一列「操作人」,人工退票的每一笔都能追溯到是谁点的。人工退票走和用户退票同一个方法,只是跳过「本人订单」这条校验,避免出现两套退票逻辑导致退款金额算错。

统计模块只做三张图:按日期看订单量、按车次看售出率、按席别看学生票占比。数据直接从订单表聚合,不上宽表。这一块是答辩的展示窗口,比任何技术细节都容易被记住,值得花半天做干净。

5. 避坑与排查:这类订票系统最容易翻车的五个点

下面五条是我在这个类型项目里反复见到的翻车点,每条按现象、原因、解决写,遇到问题时按顺序对照排查,多数能直接定位。

5.1 余票扣了订单没生成,或订单生成了余票没扣

现象是系统里的已售数量对不上,跑一段时间后出现「明明显示有票,下单提示余票不足」。多数原因是扣库存和建订单不在同一个事务里,或者 Service 方法上漏了@Transactional,还有一种更隐蔽的情况:异常被 catch 住吞掉了,事务没有回滚。解决办法是把这两步收进同一个方法并声明rollbackFor = Exception.class,同时在 catch 块里只做日志记录再原样抛出,绝不吞异常。排查时直接看这两条 SQL 的执行顺序日志,中间有没有异常栈。

5.2 支付回调重复执行导致重复出票

现象是同一笔订单出现两条出票记录,或者学生优惠次数被扣了两次。原因是第三方支付的回调不保证只来一次,网络抖动、超时重试都会重复投递。解决是给回调处理加幂等:先按订单号查状态,状态已经是已支付就直接返回成功不再处理;同时状态流转走第 3 章那个canTransit校验,非法流转直接拒绝。数据库层面给「支付流水号」加唯一索引作为最后一道防线。

5.3 订单超时未取消,座位被长期占死

现象是余票持续减少但订单表里全是待支付,实际成交很少。原因是定时任务没跑、或者任务跑了但取消时忘了回补库存。这里的坑在于回补库存必须和状态变更在一个事务里,且回补条件要带状态判断WHERE status = 0,防止把已经支付成功的订单也取消了。排查顺序:先看定时任务是否注册成功,再看扫描 SQL 的时间条件有没有写反,最后核对待取消的订单取消后库存是否真的加了回去。

5.4 时间字段跨时区后车次全部错位

现象是本地跑得好好的,部署到另一台机器或者换个数据库连接参数后,车次时间偏移八小时,跨天车次直接筛不出来。原因是DATETIME与TIMESTAMP混用,加上 JVM 时区和数据库时区不一致。解决是全库统一用DATETIME存本地时间,连接串显式指定serverTimezone=Asia/Shanghai,Java 侧用LocalDateTime而不是Date。这类问题排查成本极高,因为数据本身没坏,只是解释方式变了,黑匣子一样的表现。

5.5 并发测试一压就超卖,单测却全绿

现象是功能测试怎么点都对,用脚本并发一百个请求就出现负库存或订单数大于库存数。原因是单元测试串行执行,永远碰不到竞争窗口。解决办法是写一个简单的并发用例:用线程池同时发起 N 个下单请求,断言成功数等于库存数、remain不小于 0。这个用例建议直接放进验收脚本,答辩演示时跑一遍,比讲十句「我做了并发控制」更有说服力。

注意:以上五条里,第 5.1 和第 5.5 是同一个根因的两面,前者是代码结构问题,后者是验证手段缺失,修一处最好两处一起补。

6. 验收与答辩:用一条完整链路证明系统真的能跑

验收不要按页面逐个点,那只能证明页面能打开。按「一条订单的完整生命周期」走一遍,从查票到退票,中间穿插学生票和并发两个特殊场景,一条链路走完,系统的骨架是否结实基本就清楚了。

验收项操作路径预期结果判定标准
区间查票输入中途站作为出发站查询返回途经该站的车次站序递增,反向车次不出现
学生资质拦截未审核账号购买学生票提示资质未通过并可选全价不产生订单,不扣库存
半价计算资质通过账号购买学生票金额为全价 50%,保留两位与手工计算一致
并发下单库存 5,20 线程并发下单成功 5 单,余票为 0无负数,无第 6 单
重复提交同令牌连发两次下单返回同一订单号订单表仅一条记录
超时取消下单后等到过期时间订单转已取消,库存回补余票恢复且只回补一次
退票已出票订单退票状态已退票,学生次数回补次数加回且不超年度上限

答辩环节常被追问的三个问题,提前把答案备好。为什么用条件更新而不是先查后改——因为判断和修改要原子。为什么学生次数在支付后才扣——因为下单不等于成交。订单状态为什么不能随便改——因为迁移路径写死在常量类里,非法流转会抛异常。这三个回答都指向同一件事:你在设计时考虑过失败路径,而不是只考虑顺利路径。

演示顺序建议反过来:先跑并发用例和幂等用例,让评审看到系统扛得住;再走一遍正常购票链路,画面流畅;最后展示后台统计和退票回补,收在数据一致上。中途如果卡住,不要现场改代码,直接说明预期结果并跳到下一项,事后补一份排查记录,比当场手忙脚乱强得多。

我第一次带这类项目验收时,把七项检查全放在最后一天,结果并发用例直接暴露了扣库存的顺序问题,临时改事务边界又引出了新的空指针,通宵到天亮。从那以后我每次交付前都强制走一遍「并发用例先跑、正常链路后跑」的顺序,问题早发现一天,返工就少一半。希望这套结构和这份验收清单,能帮你把这份订票系统方案稳稳落地。

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

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

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

立即咨询