ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java学生火车票系统:Spring Boot分层架构与并发控制实战

Java学生火车票系统:Spring Boot分层架构与并发控制实战

简介:本资源是一套完整的基于Java开发的学生火车票订票系统毕业设计项目,面向计算机专业本科生及Java初学者,聚焦课程设计、毕业设计与小型桌面应用开发实践。系统采用JavaFX构建图形界面,后端集成Hibernate框架与SQL Server 2014数据库,完整实现学生信息管理、车票预订(含价格与目的地)、在线退票、多维度统计查询及操作员权限管理五大核心功能,代码经严格测试,配套README.md和PDF课设报告,便于理解架构设计与业务逻辑。压缩包共72个文件,含9个核心Java源码、16个XML配置与映射文件、15个JAR依赖库(涵盖Hibernate、JavaFX、CDI等)、11个PNG界面截图及1份PDF参考论文,整体8.87MB,结构清晰、模块分明,开箱即用。目前已有42人学习下载,适合用于快速掌握Java桌面应用开发全流程、数据库交互实践及MVC分层思想落地。

1. 为什么一个“学生火车票订票系统”能成为Java工程能力的试金石?

不是所有Java毕业设计都值得花三周重写——但这个题是。它表面是模拟12306的简化版,实则是一次对分层架构落地能力、事务边界意识、并发安全直觉、数据库设计权衡的集中检验。我带过几届某高校课程设计,发现80%的学生卡在“查余票时多人抢同一张票却都成功下单”,剩下20%卡在“退票后库存没回滚”或“学生身份校验只靠前端JS判断”。这根本不是功能没做完,而是没真正理解:一个带业务语义的“订票”动作,在Java后端必须拆解成状态机+事务+幂等+缓存穿透防护四层防御。它适合两类人:刚学完Spring Boot想验证分层是否真懂的新人;或需要快速搭建可演示、可调试、可讲清技术决策链路的毕设/课设开发者。别被“学生”二字误导——它的数据模型(学生证号绑定、优惠区间、乘车次数限制)、业务规则(预售期控制、候补逻辑简化版、退改签阶梯手续费)恰恰比通用电商更考细节。下面我们就从零开始,用最贴近真实开发节奏的方式,把这套系统跑通、调稳、讲透。


2. 用Spring Boot 3 + MyBatis-Plus 搭建最小可运行骨架:4个核心模块怎么切

提示:不推荐用Spring Boot 2.x起步,MyBatis-Plus 3.5+对LambdaQueryWrapper的泛型推导更稳,避免后期因版本差异导致QueryWrapper写法反复重构。

2.1 创建模块结构:按职责而非功能切分

Maven多模块不是炫技,是为后续测试和部署留活路。我们采用四模块结构:

student-ticket-system/ ├── ticket-api/ # Spring Boot Web启动器,只放Controller和DTO ├── ticket-service/ # 业务逻辑层,含Service接口+实现,不含Mapper ├── ticket-dao/ # 数据访问层,含Entity、Mapper、XML(或注解) └── ticket-common/ # 公共实体、异常枚举、工具类(如身份证校验、时间工具)

为什么这样切?

  • ticket-api可独立打包为API文档服务(配合Knife4j),前端联调时只依赖它;
  • ticket-service能被单元测试直接注入,Mock掉DAO层后测纯业务逻辑;
  • ticket-dao的Mapper XML可集中管理SQL,避免@Service里散落大量@Select注解,方便DBA审阅;
  • ticket-common把StudentIdentityValidator这类校验逻辑抽离,避免在Controller里写if-else判断学生证号格式。

2.2 初始化ticket-dao:用MyBatis-Plus代码生成器一键产出基础CRUD

不手写Entity和Mapper——用官方代码生成器(mybatis-plus-generator3.5.3.1)生成。关键配置如下:

// GeneratorConfig.java AutoGenerator generator = new AutoGenerator(); generator.setGlobalConfig(new GlobalConfig() .setOutputDir("src/main/java") // 输出路径 .setAuthor("dev") .setOpen(false) // 生成后不自动打开文件夹 .setServiceName("%sService") // Service命名规则:IUserServie → UserService ); generator.setPackageInfo(new PackageConfig() .setParent("com.example.ticket") // 包名前缀 .setMapper("mapper") .setXml("mapper.xml") .setEntity("entity") .setService("service") .setServiceImpl("service.impl") ); generator.setTemplate(new TemplateConfig() .setXml("/templates/mapper.xml.vm") // 使用自定义XML模板 ); generator.execute();

生成后你将得到:

  • Student.java(含@TableName("student")、@TableId(type = IdType.AUTO))
  • TicketOrder.java(含@TableField("order_status")映射status字段)
  • StudentMapper.java(继承BaseMapper<Student>)
  • TicketOrderMapper.java(同上)
  • mapper/xml/StudentMapper.xml(含<select id="selectByStudentId">等基础SQL)

注意:生成器默认不生成复杂查询,selectByStudentId这种需手动在Mapper接口加方法签名,XML里补SQL。这是故意为之——逼你思考哪些查询必须走DB,哪些可用Redis缓存。

2.3 在ticket-service中定义核心业务契约:订单状态机驱动的Service接口

订票不是简单insert,是状态流转。我们定义TicketOrderService接口,强制约束行为:

public interface TicketOrderService { /** * 创建订单(预占库存) * @param orderParam 订单参数(含学生ID、车次、日期、座位类型) * @return 订单ID,失败抛出BusinessException */ Long createOrder(OrderCreateParam orderParam); /** * 支付订单(扣减真实库存) * @param orderId 订单ID * @param payChannel 支付渠道(ALIPAY/WECHAT) * @return 支付结果 */ PayResult payOrder(Long orderId, String payChannel); /** * 退票(释放库存+计算手续费) * @param orderId 订单ID * @param refundReason 退票原因(用于日志审计) * @return 退款金额 */ BigDecimal refundOrder(Long orderId, String refundReason); }

为什么接口要这么设计?

  • createOrder不叫submitOrder,强调“预占”语义,为后续分布式锁留接口;
  • payOrder显式传入payChannel,避免Service里硬编码支付方式,方便后续接入银联;
  • refundOrder返回BigDecimal而非boolean,因为手续费计算需精确到分,float/double会丢失精度。

3. 订票核心逻辑:如何用@Transactional + 乐观锁防超卖?3步落地

超卖是毕设翻车第一高发区。别信“加个synchronized就行”——那是单机玩具。真实方案必须考虑:数据库行锁粒度、事务隔离级别、更新失败后的重试策略。

3.1 数据库表设计:为乐观锁预留version字段

在train_ticket_stock表中,除id,train_no,date,seat_type,available_count外,必须加version字段:

CREATE TABLE train_ticket_stock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(10) NOT NULL, date DATE NOT NULL, seat_type TINYINT NOT NULL COMMENT '1:二等座,2:一等座', available_count INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_train_date_seat (train_no, date, seat_type) );

注意:UNIQUE KEY uk_train_date_seat是防重复插入的关键,避免同一车次同日同席别建多条库存记录。

3.2 在Entity中声明乐观锁注解

@TableName("train_ticket_stock") public class TrainTicketStock { @TableId private Long id; private String trainNo; private LocalDate date; private Integer seatType; private Integer availableCount; @Version // MyBatis-Plus识别乐观锁字段 private Integer version; // getter/setter... }

3.3 实现createOrder:用update+where version=oldVersion原子扣减

@Service public class TicketOrderServiceImpl implements TicketOrderService { @Override @Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateParam param) { // 1. 查询当前库存(带version) TrainTicketStock stock = stockMapper.selectOne( new LambdaQueryWrapper<TrainTicketStock>() .eq(TrainTicketStock::getTrainNo, param.getTrainNo()) .eq(TrainTicketStock::getDate, param.getDate()) .eq(TrainTicketStock::getSeatType, param.getSeatType()) ); if (stock == null || stock.getAvailableCount() < 1) { throw new BusinessException("余票不足"); } // 2. 尝试扣减库存:UPDATE ... SET available_count=available_count-1, version=version+1 // WHERE id=? AND version=? int updated = stockMapper.update( new TrainTicketStock().setAvailableCount(stock.getAvailableCount() - 1), new LambdaUpdateWrapper<TrainTicketStock>() .eq(TrainTicketStock::getId, stock.getId()) .eq(TrainTicketStock::getVersion, stock.getVersion()) // 关键:where version=oldVersion ); if (updated == 0) { // 更新失败:说明version已变(别人抢先扣了),抛异常触发事务回滚 throw new BusinessException("库存已被占用,请刷新重试"); } // 3. 创建订单记录(此时库存已扣减) TicketOrder order = new TicketOrder(); order.setStudentId(param.getStudentId()); order.setTrainNo(param.getTrainNo()); order.setDate(param.getDate()); order.setSeatType(param.getSeatType()); order.setOrderStatus(OrderStatus.PRE_PAY.getValue()); // 预支付状态 orderMapper.insert(order); return order.getId(); } }

这段代码的玄学点在哪?

  • stockMapper.update(...)的第二个参数是LambdaUpdateWrapper,它生成的SQL是:
    UPDATE train_ticket_stock SET available_count=?, version=? WHERE id=? AND version=?
    不是先SELECT再UPDATE,避免了“读-改-写”间隙被并发请求钻空;
  • @Transactional保证整个方法要么全成功,要么全回滚。若stockMapper.update失败抛异常,orderMapper.insert自动撤销;
  • throw new BusinessException而非RuntimeException,因为我们要在全局异常处理器中统一返回JSON格式错误码(如{"code":5001,"msg":"余票不足"}),而不是暴露堆栈。

4. 学生身份与优惠规则:如何把政策条款翻译成可配置的Java规则引擎?

学生票不是简单打五折,它有硬性政策约束:

  • 仅限普通高等学校、中等职业学校学生;
  • 每学年限购4次单程;
  • 优惠区间必须在学生证记载的乘车区间内;
  • 寒暑假期间(6.1-9.30, 12.1-2.28)才可购买。

硬编码if-else会把代码变成政策说明书。我们用规则配置化+运行时解析来解耦。

4.1 设计规则配置表:用JSON存储动态策略

建表student_discount_rule,字段如下:

字段类型说明
idBIGINT主键
rule_codeVARCHAR(32)规则编码(如STUDENT_SEASON_CHECK)
rule_descVARCHAR(255)规则描述(供运维查看)
rule_jsonTEXTJSON规则体(见下文)
statusTINYINT1启用/0停用

rule_json示例(寒暑假校验规则):

{ "type": "DATE_RANGE", "startDate": "06-01", "endDate": "09-30", "or": [ { "type": "DATE_RANGE", "startDate": "12-01", "endDate": "02-28" } ] }

4.2 编写规则解析器:用Jackson反序列化JSON并执行判断

@Component public class StudentRuleEngine { // 定义规则抽象类 public abstract static class Rule { public abstract boolean match(Student student, LocalDate travelDate); } // 日期范围规则实现 public static class DateRangeRule extends Rule { private String startDate; // "06-01" private String endDate; // "09-30" private List<DateRangeRule> orRules; // 或条件 @Override public boolean match(Student student, LocalDate travelDate) { String monthDay = travelDate.format(DateTimeFormatter.ofPattern("MM-dd")); if (isInMonthDayRange(monthDay, startDate, endDate)) { return true; } // 检查or条件 if (CollectionUtils.isNotEmpty(orRules)) { return orRules.stream().anyMatch(r -> r.match(student, travelDate)); } return false; } private boolean isInMonthDayRange(String monthDay, String start, String end) { // 简化实现:比较字符串字典序("06-01" < "09-30"成立) return monthDay.compareTo(start) >= 0 && monthDay.compareTo(end) <= 0; } } // 加载并执行规则 public boolean checkStudentEligible(Long studentId, LocalDate travelDate) { Student student = studentMapper.selectById(studentId); if (student == null) { return false; } // 查询启用中的所有规则 List<StudentDiscountRule> rules = ruleMapper.selectList( new LambdaQueryWrapper<StudentDiscountRule>().eq(StudentDiscountRule::getStatus, 1) ); for (StudentDiscountRule rule : rules) { try { JsonNode jsonNode = new ObjectMapper().readTree(rule.getRuleJson()); Rule parsedRule = parseRuleFromJson(jsonNode); if (!parsedRule.match(student, travelDate)) { return false; // 任一规则不满足即拒绝 } } catch (Exception e) { log.error("规则解析失败 ruleCode={}", rule.getRuleCode(), e); return false; } } return true; } private Rule parseRuleFromJson(JsonNode node) { String type = node.get("type").asText(); if ("DATE_RANGE".equals(type)) { return new DateRangeRule(); } throw new IllegalArgumentException("不支持的规则类型: " + type); } }

注意:isInMonthDayRange用字符串比较是取巧,因月份固定两位,字典序等价于时间序。生产环境应转为LocalDate比较,此处为简化演示。

4.3 在createOrder中集成规则校验

@Override @Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateParam param) { // 新增:校验学生资格 if (!studentRuleEngine.checkStudentEligible(param.getStudentId(), param.getDate())) { throw new BusinessException("当前不在学生票优惠期,请选择其他日期"); } // ... 后续库存扣减逻辑(同3.3节) }

这样做的好处:

  • 运维修改寒暑假时间,只需更新数据库rule_json字段,无需重启应用;
  • 新增“乘车区间校验”规则,只需扩展Rule子类+新增配置,业务代码零改动;
  • 所有规则执行日志可统一埋点,便于审计“为什么这个学生被拒”。

5. 避坑指南:那些让毕设答辩当场沉默的5个血泪问题

常见问题排查章节:现象 → 原因 → 解决,每条直击真实翻车现场

5.1 现象:本地MySQL能跑通,部署到阿里云ECS后创建订单总报“数据库连接超时”

原因:未配置HikariCP连接池最大连接数,云服务器MySQL默认max_connections=100,而Spring Boot默认hikari.maximum-pool-size=10,但某些IDE(如IntelliJ)调试时会开启多个热部署线程,瞬间占满连接。
解决:在application.yml中显式配置:

spring: datasource: hikari: maximum-pool-size: 20 connection-timeout: 30000 validation-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000

并登录云数据库执行:SET GLOBAL max_connections = 200;(需管理员权限)。

5.2 现象:学生用Chrome能订票,用Firefox提交订单后页面卡死,后台无日志

原因:前端表单未设置enctype="application/x-www-form-urlencoded",Firefox对空值处理更严格,导致Spring MVC绑定OrderCreateParam时studentId为null,后续NPE未被捕获。
解决:Controller方法添加@Valid注解,并配置全局校验:

@PostMapping("/order") public Result<Long> createOrder(@Valid @RequestBody OrderCreateParam param) { ... }

同时在OrderCreateParam中加@NotNull(message = "学生ID不能为空")。

5.3 现象:退票后查库存,available_count比实际多1

原因:refundOrder方法中,先执行stockMapper.update(...)增加库存,再执行orderMapper.update(...)修改订单状态,但事务未包裹整个方法,导致库存更新成功而订单状态更新失败时,库存已回滚不了。
解决:给refundOrder方法加上@Transactional,且确保两个Mapper操作在同一事务中:

@Override @Transactional(rollbackFor = Exception.class) public BigDecimal refundOrder(Long orderId, String refundReason) { // 1. 查询订单获取车次、日期、席别 TicketOrder order = orderMapper.selectById(orderId); // 2. 更新库存(+1) stockMapper.update(...); // 3. 更新订单状态为REFUNDED orderMapper.update(...); // 4. 返回手续费计算结果 return calculateRefundAmount(order); }

5.4 现象:导师说“你这系统没做登录,任何人都能订票”,但加Shiro太重,JWT又不会

原因:毕设常被质疑安全性,但完整权限体系非毕设重点。需用最小成本证明“有鉴权意识”。
解决:在ticket-api模块加一个轻量级拦截器:

@Configuration public class AuthConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new HandlerInterceptor() { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("X-Auth-Token"); if (token == null || !"STUDENT_123456".equals(token)) { // 演示用固定Token response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } return true; } }).excludePathPatterns("/api/public/**"); // 放行登录接口 } }

答辩时可说:“已预留Token校验入口,生产环境可对接LDAP或OAuth2”。

5.5 现象:导出的项目文档里ER图用PowerDesigner画的,但答辩时导师问“这张表为什么没加索引”,答不上来

原因:ER图只画关系,不体现性能设计。导师想看的是你是否思考过查询路径。
解决:在文档“数据库设计”章节,补充索引说明表:

表名字段索引类型用途说明
train_ticket_stock(train_no, date, seat_type)唯一索引防止重复建库存,加速余票查询
ticket_orderstudent_id普通索引加速“查询某学生所有订单”
ticket_order(order_status, create_time)复合索引加速“查询待支付订单”分页

6. 毕设交付技巧:如何让导师一眼看出你“真做过”,而不是Ctrl+C/V

毕设成败不在功能多全,而在能否讲清每个技术选型背后的trade-off。我带过的某同学,答辩时被问“为什么用MyBatis-Plus不用JPA”,他没背概念,而是打开IDE,指着TicketOrderMapper.xml说:“因为退票要写动态SQL——根据退票时间算手续费,JPA的@Query写分支太难维护,而XML里用<choose><when test="...">一目了然,DBA也能直接审”。导师当场点头。这就是真做过的样子。

6.1 文档里必放的3张图:不是截图,是决策图

第一张:分层架构图(手绘风格更佳)
不要用UML工具生成冷冰冰的框图。用draw.io画简笔架构,重点标出:

  • ticket-api层用红色虚线框住@RestController,旁注“仅接收HTTP请求,不做任何业务判断”;
  • ticket-service层用蓝色实线框住@Service,旁注“状态机驱动:PRE_PAY → PAID → REFUNDED”;
  • ticket-dao层用绿色实线框住@Mapper,旁注“SQL与Java分离,XML可被DBA独立审核”。

第二张:数据库事务边界图
用时序图展示createOrder的3个关键点:

  1. SELECT ... FOR UPDATE(如果用了悲观锁)或SELECT ...(乐观锁场景);
  2. UPDATE stock SET ... WHERE version=?;
  3. INSERT INTO order ...;
    在第2步旁标注:“此处version校验失败则整个事务回滚,保证库存一致性”。

第三张:学生优惠规则执行流程图
用菱形判断节点画:

  • 开始 → 加载规则列表 → 取第一条规则 → 解析JSON → 执行match() → 是否满足?→ 否:返回false;是:取下一条 → 规则遍历完?→ 是:返回true。
    在“解析JSON”节点旁写:“避免硬编码政策,运维可随时调整寒暑假日期”。

6.2 源码里藏一个“可演示彩蛋”:让答辩时有记忆点

在ticket-api的HomeController里加一个隐藏端点:

@GetMapping("/debug/rules") @PreAuthorize("hasRole('ADMIN')") // 仅本地启用 public List<Map<String, Object>> debugRules() { return ruleMapper.selectList(null).stream() .map(r -> { Map<String, Object> map = new HashMap<>(); map.put("code", r.getRuleCode()); map.put("json", r.getRuleJson()); map.put("lastCheck", LocalDateTime.now()); return map; }) .collect(Collectors.toList()); }

答辩时打开浏览器输入http://localhost:8080/debug/rules,展示规则可动态加载——比讲十分钟原理更直观。

6.3 答辩PPT最后一页:只放一行字

“这个系统没有用最新潮的Serverless或AI,但它让我真正理解了:事务不是注解,是状态承诺;分层不是目录,是职责契约;而毕业设计的价值,是把教科书里的‘应该’,变成自己代码里的‘确实’。”

希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进