ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Spring Boot旅游订票系统开发实战:从表设计到部署上线全解析

Spring Boot旅游订票系统开发实战:从表设计到部署上线全解析

去年有段时间,我在帮人看一个“Springboot旅游景点订票系统”的课程设计项目。说实话,这类项目的代码在网上并不少见,但真正要把它从压缩包变成一台能演示、能讲解、后续还能继续加功能的系统,远不是“跑起来就行”那么简单。旅游订票和普通的增删改查有一个很大的不同:它涉及到库存、支付、核销三个环节,任何一个环节断了,整套系统在现实中都用不了。所以我打算从这次完整开发调试部署过程出发,把需求拆解、表结构设计、业务代码、打包上线这条线完完整整捋一遍,给正在做类似系统的朋友一点参考。

1. 这个项目的来龙去脉:一个旅游订票系统到底要解决什么问题

1.1 传统售票方式的核心痛点

为什么要把痛点放在最前面?因为痛点直接决定了系统的数据结构。传统售票方式通常是在景区门口排队买票,或者通过电话预留,售票员手工登记游客信息和人数。这里有三个绕不开的问题:第一,余票数量完全不透明,游客只能到了现场才知道还有没有票;第二,景区无法提前掌握预约情况,热门日期容易超售,冷门日期又大量空置;第三,人工核验纸质票效率低,无法快速判断重复使用和伪造门票的风险。所以这个订票系统表面上做的是“订票”,本质上做的是“余票管理”和“履约闭环”。明白了这一点,就不会把精力都花在页面美化上,而是会认真思考订单状态应该怎么流转、库存怎么扣减才算安全。

1.2 功能边界:哪些必须做,哪些可以不做

动手前要先画一张功能地图,把系统分成前台和后台两条线。前台面向游客,必须包含注册登录、景点列表、景点详情、票型选择、余票查询、提交订单、支付、查看订单、获取电子凭证。后台面向景区管理人员,必须包含景点信息管理、票型与价格管理、每日余票库存设置、订单查询、支付记录、核销验票。至于景区公告、短信通知、会员等级、营销活动、多语言这些需求,就看项目周期和定位了。对于课程设计或者内部演示系统,先把“用户能下单、管理员能核销”这个闭环做扎实,远比堆功能重要。把边界划清楚之后,后续开发能省掉大量反复改表的时间,也方便别人接手时快速理解系统能力。

1.3 为什么选用Spring Boot而不是老一套框架

选型这个问题,我几乎每次都会被问到。如果只是做一个几百行代码的Demo,用什么都不重要;但如果要做成能演示、能扩展、能部署到服务器上的系统,技术栈的成熟度和开发效率就非常关键。我这次选择Spring Boot而不是更早期的SSH、SSM,最重要的原因是约定优于配置带来了极低的启动成本。用户只需要引入spring-boot-starter-web,一个main方法就能启动内嵌的Web容器,不需要外置Tomcat,不需要繁琐的web.xml和一堆XML配置。再配合MyBatis-Plus做数据访问,几乎不用写复杂的基础CRUD;使用JWT做登录态,比Session更契合前后端分离的架构;Hutool工具包可以搞定日期、加密、随机数这些琐碎逻辑。这套组合在中小型项目里已经很成熟,遇到问题很容易找到参考,对单人开发或者小组协作都比较友好。

关于前端形态,这个系统我建议按前后端分离来做,后端只提供JSON接口,前端使用Vue或者普通HTML页面均可。这样做的理由很直接:如果使用Thymeleaf这类服务端渲染,订单状态更新时需要不断刷新整页,体验很差;分离后,前端可以单独部署在Nginx上,后端的Spring Boot应用只面对/api请求,也方便维护。后面要加微信小程序端,只需要复用后端接口,不需要改Java代码。

2. 开发环境与工程结构:从零搭起一套能跑的骨架

很多项目最后卡在环境不一致的问题上,明明代码一样,在这台电脑能跑,换一台就报错。下面是我实际使用的环境组合,并附上理由,方便直接照做。

2.1 环境版本的选择

我使用的版本组合是:JDK 1.8、Maven 3.6.3、MySQL 5.7(8.0也可以,但连接驱动和时区配置略有区别)、IDEA 2021以后的版本。Spring Boot版本选了2.7.18,没有直接上3.x。原因很实际:Spring Boot 3.x要求JDK 17,很多教学资料和网友分享的代码都是基于javax.servlet的写法,换成3.x之后变成jakarta.servlet,对没有经验的开发者来说,光是处理包名迁移就是个不小的坑。而2.7.x停在javax时代,网上资料最多,兼容性也最稳。MyBatis-Plus选3.5.x版本,它对Spring Boot 2.x支持得很好,分页插件、条件构造器都能直接使用。下面是一个可以直接用的核心依赖片段。

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.7</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.19.4</version> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.26</version> </dependency> </dependencies>

这里补充一个很多人容易忽略的点:MySQL连接驱动不要用老旧的com.mysql.jdbc.Driver,要使用com.mysql.cj.jdbc.Driver,驱动包版本尽量与数据库实例大版本接近。不然可能出现连接成功但中文乱码、时区错误等诡异问题。如果使用MySQL 8.0,连接URL中还需要额外加allowPublicKeyRetrieval=true,否则某些安全连接场景会报“Public Key Retrieval is not allowed”错误。

2.2 工程目录分层:为什么这么分

包结构建议这样规划,不要所有类都堆在一个包下面。下面这个分层是我比较推荐的。

src/main/java └── com.example.ticket ├── TicketApplication.java ├── common // 公共返回结果、异常处理、常量 ├── config // 配置类,如WebMvcConfig、MybatisPlusConfig ├── controller // 接收前端请求 ├── service // 业务逻辑层 ├── mapper // MyBatis-Plus的Mapper接口 ├── entity // 数据库实体类 ├── dto // 前端传入参数对象 ├── vo // 返回给前端的视图对象 └── utils // JWT、日期等工具类

为什么这么分?核心原因是让每一层的职责变得明确。Controller只负责参数校验和结果包装,业务判断全部放到Service;Mapper只做数据访问;Entity对应数据库表,DTO和VO避免把数据库实体直接暴露给前端。这样做的好处有两个:一是排查问题时能快速定位,比如订单金额不对,直接去Service里找计算逻辑,而不是在Controller里翻;二是当接口改造时不会牵一发而动全身。比如管理员给订单增加一个“导出Excel”的功能,只需要在Service里增加处理方法,Controller和Mapper的改动非常小。

2.3 初始化数据库与application.yml配置要点

先把数据库创建好,新建数据库时注意字符集。我在MySQL中执行的语句是:

CREATE DATABASE ticket_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

字符集必须用utf8mb4,不要只写utf8,否则用户输入生僻字或者表情符号时,后台会报字符集不支持的错误。接着把第三部分的建表SQL按顺序执行,再插入一个初始管理员账号和几个测试景点数据。配置文件通常拆成application.yml、application-dev.yml、application-prod.yml三份,开发环境连本地数据库,生产环境连远程数据库,启动时通过spring.profiles.active切换。下面是dev环境的核心配置。

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/ticket_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

这里特别提醒两件事。第一,serverTimezone=Asia/Shanghai千万不能省,否则数据库连接时因为时区问题报错的情况非常常见。第二,数据库密码写在配置文件里只是本地开发方便,部署到服务器后建议改用环境变量方式注入,避免把密钥提交到代码仓库。具体做法在部署部分会详细说。另外,逻辑删除字段我用了deleted,它可以避免用户或景点被物理删除后导致历史订单查不到关联信息,但要注意查询时所有Mapper默认会带deleted=0条件,如果将来要统计已删除数据,需要额外写自定义SQL。

3. 数据库设计:表结构怎么定才能少踩坑

这个项目能不能稳定跑起来,表结构占了六成决定因素。我见过太多人把订单表设计成只有订单号和总金额两个字段,结果后面要加退票、加核销记录时根本无从下手。下面讲一下我最终使用的核心表结构,以及每张表为什么需要这些字段。

3.1 用户、景点、票型三张基础表

用户表不需要太复杂,但手机号和密码字段一定要预留足够长度。密码前期不推荐用明文,至少用BCrypt或者MD5加盐,项目演示时可以省去很多安全审查方面的尴尬。景点表的核心字段包括封面图、开放时间、简介、上下架状态。票型表用于表达同一个景点下的成人票、儿童票、学生票等不同价格和库存。

CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(64) NOT NULL, `password` varchar(128) NOT NULL, `phone` varchar(20) DEFAULT NULL, `nickname` varchar(64) DEFAULT NULL, `create_time` datetime NOT NULL, `deleted` tinyint NOT NULL DEFAULT '0', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `scenic_spot` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL, `address` varchar(256) DEFAULT NULL, `intro` text, `open_time` varchar(64) DEFAULT NULL, `cover_url` varchar(512) DEFAULT NULL, `status` tinyint NOT NULL DEFAULT '1', `create_time` datetime NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `ticket_type` ( `id` bigint NOT NULL AUTO_INCREMENT, `scenic_id` bigint NOT NULL, `type_name` varchar(64) NOT NULL, `price` decimal(10,2) NOT NULL, `stock` int NOT NULL DEFAULT '0', `version` int NOT NULL DEFAULT '0', `status` tinyint NOT NULL DEFAULT '1', `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_scenic_id` (`scenic_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意我在ticket_type上加了version字段,这不是业务字段,是后续做乐观锁更新库存用的。很多初学者直接在scenic_spot上放库存,这样做的问题是同一个景点有多个票型时根本没法管理,所以必须要拆出票型表。还有一个细节:用户名加了唯一索引,这样注册接口就可以在数据库层面拦截重复用户,不需要每次先查一次再插入,也减少了并发注册时重复数据出现的风险。

3.2 订单、支付流水、核销记录三张业务表

订单表记录每次购买行为,支付流水表记录支付通道的每笔流水,核销表记录电子凭证什么时候被使用。三张表的关系是一笔订单对应一条支付流水,核销记录则是对应一次凭证使用。具体建表SQL如下。

CREATE TABLE `orders` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `user_id` bigint NOT NULL, `ticket_type_id` bigint NOT NULL, `quantity` int NOT NULL, `amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT '0', `pay_status` tinyint NOT NULL DEFAULT '0', `verify_code` varchar(16) DEFAULT NULL, `create_time` datetime NOT NULL, `pay_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `payment_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `payment_no` varchar(32) NOT NULL, `order_no` varchar(32) NOT NULL, `channel` varchar(16) DEFAULT 'mock', `amount` decimal(10,2) NOT NULL, `status` tinyint NOT NULL DEFAULT '0', `callback_time` datetime DEFAULT NULL, `create_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_payment_no` (`payment_no`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `verify_record` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `verify_code` varchar(16) NOT NULL, `verify_time` datetime NOT NULL, `operator_id` bigint DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_verify_code` (`verify_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

我把订单号和支付流水号都设计成独立业务编号,而不是直接暴露自增主键,主要为了两个目的:一是避免别人通过订单ID猜测业务量;二是给后续对接第三方支付平台留好字段,支付平台要求的商户订单号通常是独立的前端订单号。订单状态和支付状态我拆开,是因为“待支付”和“已取消”之间,支付回调可能延迟到达,拆开处理更符合真实场景。verify_code放在订单表里,并给核销记录表也单独存一份,是为了线下核销时能快速定位;真实项目如果要做套票拆分,更建议拆卡券表,但在这个规模下订单表加字段已经够了。

3.3 容易被忽略的索引、字段与状态设计

踩过几次坑后,我总结了几个容易忽略的细节。第一,状态字段建议用tinyint,对应关系写在常量类或枚举里,不要用中文“已支付”“未支付”直接入库,否则后续做统计聚合很痛苦。第二,金额字段一律用decimal(10,2),不要图省事用float或double,浮点运算的精度问题在支付场景下是不能接受的。第三,order_no、payment_no、verify_code加唯一索引,既是性能优化,也是业务兜底。第四,订单查询必然带创建时间,所以create_time字段上要建索引,否则订单量一大,后台列表就会越来越慢。下面是我推荐的状态定义,可以直接参考。

字段取值含义
orders.status0待支付
orders.status1已支付
orders.status2已核销
orders.status3已取消
orders.status4已退款
orders.pay_status0未支付
orders.pay_status1支付成功
orders.pay_status2已退款

如果以后接真实支付平台,退款还需要在payment_record中记录退款流水号,字段可以再加refund_no和refund_time。前期不要过度设计,但这几个关键状态位必须预留。状态字段宁可多留两个值,也不要后面需要新增状态时去改表结构,因为改表结构在外网运营阶段成本非常高。

4. 核心业务逻辑:从选票到出票的关键实现

数据库设计好了,接下来是业务代码。旅游订票系统最核心的逻辑不是景点列表,而是“查询余票、扣库存、生成订单、支付、核销”这条链路。下面按照链路顺序讲实现思路,重点说清楚每一步为什么这样写。

4.1 余票查询:联表而不是多次查询

前台页面需要展示景点名称、票型名称、价格、剩余数量,这些数据分散在scenic_spot和ticket_type两张表里。如果不用联表,容易写出N+1查询:先查景点列表,再循环查每个景点的票型,虽然功能能跑,但性能和代码可维护性都不好。我用的是MyBatis-Plus条件构造器加VO对象返回,核心还是SQL的JOIN。实际查询可以简化为这样。

SELECT t.id AS ticketId, s.name AS scenicName, t.type_name AS typeName, t.price, t.stock FROM ticket_type t LEFT JOIN scenic_spot s ON t.scenic_id = s.id WHERE s.status = 1 AND t.status = 1 ORDER BY s.id, t.price;

在Service层只需要把查询结果映射到TicketVO,不需要来回访问数据库。对于这个规模的项目,全表联查也没有问题,但最好还是带上分页,订单列表、景点列表、票型列表这些管理端接口都需要分页,避免后面数据量上来再返工。分页用MyBatis-Plus的Page对象,配置一个分页插件,代码量很小。

4.2 库存扣减与下单:一条Update保住底线

库存扣减是整个系统最危险的地方,不夸张地说,这里写错了,节假日一开放售票就会超卖。很多初学者容易写成“先查库存,判断大于购买数量,再减库存”,这在单用户测试时完全正常,但一旦有几个人同时下单,两个请求都可能读到同一个库存快照,最后累计扣减超过总库存,系统就超卖了。正确解法是使用数据库的原子更新,把“判断库存足够”和“扣减库存”放到同一条SQL里完成。

@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(Long userId, Long ticketTypeId, Integer quantity) { // 1. 原子扣减库存 int rows = ticketTypeMapper.reduceStock(ticketTypeId, quantity); if (rows == 0) { throw new BusinessException("库存不足或票型已下架"); } // 2. 生成订单 String orderNo = generateOrderNo(); Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTicketTypeId(ticketTypeId); order.setQuantity(quantity); order.setAmount(calculateAmount(ticketTypeId, quantity)); order.setStatus(OrderStatus.WAIT_PAY); order.setPayStatus(PayStatus.UNPAID); orderMapper.insert(order); // 3. 创建支付记录(模拟或发起真实支付) paymentService.createPayment(orderNo, order.getAmount()); return OrderVO.from(order); }

对应的Mapper方法是自定义SQL:

@Update("UPDATE ticket_type SET stock = stock - #{quantity}, version = version + 1 " + "WHERE id = #{ticketTypeId} AND stock >= #{quantity} AND status = 1") int reduceStock(@Param("ticketTypeId") Long ticketTypeId, @Param("quantity") Integer quantity);

这段代码的精髓是stock >= #{quantity}这个条件。如果库存不够,这次更新影响行数为0,Service里拿到返回值后会抛出异常,事务回滚,不会产生新订单。我第一次处理并发问题时也想过用Redis分布式锁,后来想想,对于这种单库单表的业务,一条原子SQL在绝大多数场景下已经够了,没必要为了“高并发”强行引入Redis,增加部署和维护成本。真实高并发的抢票系统当然要更复杂,但这个项目的定位是一套可用、能讲清楚原理的系统。

4.3 支付回调与核销:状态流转的闭环

订单创建后是待支付状态,支付完成后要更新订单状态并生成核销码。这里讲两个实现方式:一个是本地演示用的模拟支付,另一个是对接真实支付通道时的回调处理思路。模拟支付其实就是一个接口,比如/api/payment/mock,前端点击“立即支付”后会传入订单号,后端校验金额和状态后直接标记支付成功,并生成一个8位核销码。代码如下。

@Transactional(rollbackFor = Exception.class) public void mockPay(String orderNo) { Order order = orderMapper.selectByOrderNo(orderNo); if (order == null || order.getStatus() != OrderStatus.WAIT_PAY) { throw new BusinessException("订单不存在或状态不正确"); } order.setStatus(OrderStatus.PAID); order.setPayStatus(PayStatus.SUCCESS); order.setPayTime(LocalDateTime.now()); order.setVerifyCode(RandomUtil.randomNumbers(8)); orderMapper.updateById(order); paymentRecordMapper.updateStatus(orderNo, PayStatus.SUCCESS, LocalDateTime.now()); }

真正对接第三方支付平台时,核心点在于回调接口必须做签名校验,并且要用“支付平台通知中携带的金额”而不是直接信任本地数据库金额去更新订单。这样做能防止回调被篡改或重复通知导致金额不一致。模拟支付省掉签名校验是为了演示方便,如果上线必须补上。核销流程也很简单:后台输入核销码,查询订单,如果状态是已支付且核销码匹配,就改为已核销,同时写入一条验票记录。核销属于线下操作,要防止验票员在弱网环境下重复点击提交,所以核销记录表的verify_code加了唯一索引,第二次提交会报重复,直接返回“该凭证已被使用”。

超时未支付的订单怎么处理?我这里推荐一个简单方案:某个定时任务定期扫描超过15分钟仍未支付的订单,先把库存加回,再把订单状态改成已取消。注意加回库存和取消订单也要放在同一个事务里,并且要判断当前状态仍然是待支付,否则已经支付的订单被定时任务误取消就麻烦了。还可以用延迟队列、Redis过期监听这些方案,但就这个项目而言,一个定时任务配合状态判断已经非常够用。

5. 调试部署全流程:本地跑完、打包、服务器部署

很多人在本地把系统跑通之后就以为结束了,实际部署阶段的问题往往更多。这个章节按我自己的操作顺序来写,把容易掉进去的坑并列出来。

5.1 本地启动最常见的三个报错

本地启动是最先暴露问题的地方,我整理了三类高频报错。

报错信息原因解决办法
java.sql.SQLException: The server time zone value...数据库连接时区未设置在JDBC URL后加serverTimezone=Asia/Shanghai
Port 8080 was already in use.端口被其他进程占用找到占用进程杀掉,或者修改server.port
Invalid bound statement (not found)Mapper接口和XML没有绑定检查@MapperScan路径、XML的namespace是否正确

时区问题出现概率最高。JDK 8之后如果MySQL连接串里不指定时区,驱动会尝试读取系统时区,一旦不一致就会抛异常,解决方式就是规范URL的写法。端口占用在Windows下用netstat -ano | findstr 8080查到PID再释放,在Linux下用lsof -i:8080。Mapper绑定问题则要检查MyBatis-Plus的配置和XML位置,如果是纯注解SQL,更需要确认@MapperScan("com.example.ticket.mapper")和启动类没写错。还有一种情况是启动类的位置不对,Spring Boot默认扫描启动类所在包以及子包,如果启动类放错包,Controller和Service都不会被加载。

5.2 Maven打包与多环境配置

本地跑通后,打包用Maven就可以。我习惯先执行单元测试再打包,但如果你的项目有测试环境配置问题,可以先跳过测试。

mvn clean package -DskipTests

打包完成后会在target/目录下生成ticket-0.0.1-SNAPSHOT.jar。这个jar是内嵌Tomcat的,可以直接用java -jar运行,不需要再装Tomcat。需要特别注意生产环境不要直接使用application-dev.yml,要指定生产配置。启动命令可以这样写。

java -jar ticket-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod

进一步,可以把数据库密码等敏感信息从配置文件中抽到环境变量里,例如启动前设置DB_PASSWORD,然后在配置文件中写成${DB_PASSWORD}。这样代码仓库里不会出现明文密码。为了让服务宕机后能自动重启,通常给这类Java服务写一个systemd配置。

[Unit] Description=Ticket System After=network.target [Service] User=root WorkingDirectory=/opt/ticket EnvironmentFile=/etc/ticket.env ExecStart=/usr/bin/java -Xms512m -Xmx1024m -jar /opt/ticket/ticket-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod Restart=always RestartSec=10 [Install] WantedBy=multi-user.target

然后还需要在/etc/ticket.env里配置好SPRING_PROFILES_ACTIVE=prod、DB_PASSWORD=xxxx等变量。这样部署就从一个整天盯着终端窗口的活,变成了一个受systemd托管的服务。执行完systemctl daemon-reload、systemctl start ticket之后,服务就在后台稳定跑着了。

5.3 部署后的冒烟验证清单

服务启动完成后,不能直接宣布结束,至少要按下面的清单走一遍。

  1. 访问健康检查或登录接口,确认应用正常返回JSON,而不是Whitelabel Error Page。
  2. 用管理员账号新增一个景点和票型,设置一个较小库存,再用普通用户账号完成一次注册、登录、下单、支付的完整流程。
  3. 把库存设置为0,尝试再下单,确认系统提示库存不足,并且订单表没有插入新的脏数据。
  4. 使用同一个核销码连续核销两次,确认第二次返回“已核销”提示。
  5. 检查日志目录,确认没有堆栈报错,并确认数据库连接池配置没有被耗尽。

如果项目里有Nginx作为反向代理,可以加一段简单配置,把/api/请求转发到Spring Boot应用,前端静态文件放在Nginx站点目录下。

server { listen 80; server_name example.com; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

这里example.com只是占位域名,实际部署时替换成自己的域名或服务器IP。还有一个容易掉进去的坑:如果服务器上同时有防火墙,记得放行端口,否则外部访问不到。部署完成后,建议把application-prod.yml中的日志文件路径配到独立目录,比如/var/log/ticket/,方便后续排障。

6. 我在这类项目里总结的经验教训

最后这部分不讲具体代码,讲几个我认为比代码本身更重要的判断。遇到过很多次接手项目之后跑不起来、改不动、不敢动的情况,大部分原因都可以归结到下面这几条。

6.1 状态字段别用“魔法值”

我见过不少项目,订单状态直接用0、1、2散落在代码各个角落,新接手的人看得一头雾水。更好的做法是单独定义一个OrderStatus常量类或枚举,将状态含义沉淀在代码里。比如前面用到的OrderStatus.WAIT_PAY、OrderStatus.PAID,在代码里看到名字就能明白业务含义。对于团队项目,这不仅是代码规范问题,更是业务理解问题。与其让每个成员都去数据库表备注里翻状态含义,不如让状态定义成为代码里唯一的事实来源。我甚至建议在状态流转方法的前面写清楚“从什么状态可以到什么状态”,这样将来加退票功能时,直接对着状态机检查就行。

6.2 并发扣库存必须用数据库原子操作

这个问题我在前面提过,这里再强调一遍。只要目标是真实可用的订票系统,并发下的库存一致性就不能妥协。先查再改的写法在演示时看不出毛病,遇到集中抢票就必然出事故。原子更新的本质是让数据库保证判断和扣减的原子性,而不是在Java代码里做两步操作。我记得给项目演示时,有人问“为什么不先加Redis锁再查库存?”答案是这个项目根本没到那种并发量级,一条带条件的UPDATE已经能保证不超卖,加Redis反而复杂化了。做技术选型时,永远要考虑当前阶段最合适的复杂度,而不是把大厂方案硬套到小系统上。

6.3 给维护者留下什么比代码更值钱

课程设计、毕设或者企业内部小系统,最后会被别人接手或者被老师检查。这时候一份简单的部署说明、接口列表、数据库初始账号,比任何花哨的代码都更能体现项目的完整度。我一般会在项目根目录放一个README.md,写清楚三件事:如何初始化数据库、如何启动项目、默认管理员账号是什么。再在API层面加一套Swagger注解,或者导出一份Markdown格式的接口文档,后面的人上手会轻松很多。这是我踩了无数次“接手项目三天没跑起来”的坑之后,养成的习惯。文档不需要长篇大论,但至少要有接口路径、请求参数、响应结构、异常情况这四类信息。

6.4 关于时间与金额处理的两个小细节

额外再说两个容易被忽略的小细节。第一,项目里所有时间字段建议统一使用LocalDateTime,并且数据库字段也是datetime,前端传参和返回值统一格式化为yyyy-MM-dd HH:mm:ss。有的项目用到java.util.Date,在不同时区环境下显示会偏移8小时,排查起来很浪费时间。第二,金额计算建议把“元”为单位的分量计算改成更稳妥的方式:数据库用decimal(10,2),Java里用到BigDecimal,不要用double;避免出现“0.1 + 0.2 = 0.30000000000000004”这种让景区财务抓狂的问题。如果只是教学项目,这些细节可能不会被当场发现,但一旦上线对账,每个都会变成真正的坑。

如果非要说还有什么心得,那就是做这类系统的时候,别急着写代码,先把“谁在用、用几步、每一步会导致什么状态变化”想明白。订单状态机、库存扣减、支付回调这条链路想清楚了,后面写代码多半是水到渠成的事。真正出问题的地方,永远在你想得不够清楚的地方。

RELATED READING

延伸阅读

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