☰
旅游管理系统实战:从Excel到高并发库存扣减与订单状态机
2026/10/15 14:00:08 网站建设 项目流程

简介:这份资源是面向Java与数据库课程设计场景的旅游管理系统项目源码,适合正在完成课设、需要参考完整实现思路的计算机专业学生。系统以Java编写业务逻辑与界面,后端采用SQL Server 2008 R2存储导游、游客、线路及特价线路等数据,通过JDBC完成数据库连接与增删改查操作,并涉及MVC分层、权限控制、报表统计与异常日志等常见设计要点。压缩包为rar格式,共32个文件,约38KB,其中21个class文件为编译后的核心业务类,4个xml与4个properties文件承担项目配置与构建描述,另有清单文件辅助工程识别,整体结构紧凑、便于导入IDE后阅读与调试。目前已有307人学习下载,可作为课设选题参考、代码骨架复用与数据库设计练习的实践素材,帮助读者快速理解Java桌面应用与关系型数据库协作的完整流程。

1. 旅游管理系统:从一张 Excel 表到能扛住旺季并发的落地路线

很多团队做旅游管理系统,起点都是一张 Excel:线路、团期、导游、车辆、订单全塞在一个文件里,旺季一来就崩。这个标题背后要解决的核心问题很具体——把线路库存、团期排班、订单支付、导游派单这几件事,从人工表格搬到一套能并发扣减、能对账、能追溯的系统里。它适合中小旅行社的技术负责人、接外包单的独立开发者,以及想拿一个完整业务闭环练手的后端同学。我做过几套类似系统,最深的体会是:难点不在增删改查,而在库存扣减的时序和订单状态机的一致性。下面按选型、建模、核心接口、避坑、进阶验证的顺序讲透。

2. 技术选型与数据建模:为什么先定状态机再写代码

2.1 选型不是选最潮的,是选团队能维护的

旅游管理系统的业务量级差异极大。一个区域地接社,日订单可能就几百;一个在线票务平台,秒杀团期时 QPS 能上千。选型前先问三个问题:日订单峰值多少、是否需要对接第三方支付和短信、团队里有没有人熟悉分布式事务。

常见做法是:中小规模直接用 Spring Boot + MySQL + Redis,单体应用先跑起来,别一上来就微服务。我一般会建议把订单、库存、线路三个模块在代码层面分清楚包结构,但部署仍是一个进程。这样后期要拆服务时,边界已经画好了。数据库用 MySQL 8,库存扣减依赖行锁和唯一索引,比引入消息队列简单得多。缓存只放两类数据:团期余量和线路详情,其余走库。

提示:如果团队没有专职运维,不要碰 K8s 和分库分表,单体加读写分离足够撑到日订单五千。

2.2 四张核心表定生死

旅游管理系统的数据模型里,线路(product)、团期(schedule)、订单(order)、库存流水(stock_log)这四张表是骨架。线路是模板,团期是线路在某一天的具体售卖实例,订单挂在团期上,库存流水记录每一次扣减和回滚。

-- 团期表:库存扣减的核心,用乐观锁版本号控制并发 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL COMMENT '线路ID', depart_date DATE NOT NULL COMMENT '出发日期', total_seats INT NOT NULL DEFAULT 0 COMMENT '总位', sold_seats INT NOT NULL DEFAULT 0 COMMENT '已售', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', status TINYINT NOT NULL DEFAULT 1 COMMENT '1可售 2截团 3取消', UNIQUE KEY uk_product_date (product_id, depart_date) ) COMMENT '团期'; -- 订单表:状态机字段是重点,不要用布尔值拼状态 CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, schedule_id BIGINT NOT NULL, user_id BIGINT NOT NULL, adult_num INT NOT NULL DEFAULT 1, child_num INT NOT NULL DEFAULT 0, amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已出行 3已取消 4已退款', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_schedule (schedule_id) ) COMMENT '订单';

逻辑说明:团期表用sold_seats加version做乐观锁,扣减时UPDATE ... SET sold_seats = sold_seats + n, version = version + 1 WHERE id = ? AND version = ? AND sold_seats + n <= total_seats,影响行数为 0 就说明并发冲突或余位不足。订单表的状态用整型枚举,不要用多个布尔字段,否则查询和状态流转会变成一团乱麻。参数上,total_seats和sold_seats都用 INT,别用字符串存数字,否则排序和比较全是坑。

2.3 状态机要先画在纸上再写进代码

订单状态流转是旅游管理系统最容易出 bug 的地方。待支付、已支付、已出行、已取消、已退款,这五个状态之间的合法迁移必须收敛到一个方法里。我一般会写一个OrderStateMachine类,用 Map 存「当前状态 -> 允许的下一状态集合」,任何变更前先校验。

public class OrderStateMachine { // 合法迁移表:key 是当前状态,value 是允许到达的状态 private static final Map<Integer, Set<Integer>> TRANSFER = Map.of( 0, Set.of(1, 3), // 待支付 -> 已支付 / 已取消 1, Set.of(2, 4), // 已支付 -> 已出行 / 已退款 2, Set.of(), // 已出行 终态 3, Set.of(), // 已取消 终态 4, Set.of() // 已退款 终态 ); public static void check(int from, int to) { if (!TRANSFER.getOrDefault(from, Set.of()).contains(to)) { throw new IllegalStateException("非法状态流转: " + from + " -> " + to); } } }

逻辑说明:把状态迁移规则集中在一处,任何业务代码改状态前调用check,能挡掉绝大多数脏数据。参数上,状态码要和数据库注释严格一致,建议用常量类而不是魔法数字。这套写法在退款和取消并发时尤其有用,能避免「已退款又变成已出行」这种血泪事故。

3. 核心接口实现:库存扣减与订单创建的时序

3.1 下单接口的完整链路

下单是旅游管理系统压力最大的接口。链路是:校验团期状态 -> 扣库存 -> 创建订单 -> 返回支付参数。扣库存和创建订单必须在同一个本地事务里,否则会出现扣了库存没订单,或者有订单没扣库存。

@Transactional(rollbackFor = Exception.class) public OrderVO createOrder(CreateOrderDTO dto) { // 1. 查团期,校验可售 Schedule schedule = scheduleMapper.selectById(dto.getScheduleId()); if (schedule == null || schedule.getStatus() != 1) { throw new BizException("团期不可售"); } int need = dto.getAdultNum() + dto.getChildNum(); // 2. 乐观锁扣减,失败即冲突或余位不足 int rows = scheduleMapper.deductStock( dto.getScheduleId(), need, schedule.getVersion()); if (rows == 0) { throw new BizException("余位不足,请刷新重试"); } // 3. 创建订单,状态待支付 Order order = buildOrder(dto, schedule); orderMapper.insert(order); // 4. 写库存流水,便于对账和回滚 stockLogMapper.insert(buildLog(order, need)); return toVO(order); }

逻辑说明:deductStock的 SQL 里同时带version和余位判断,一条语句完成校验和扣减,避免先查后改的竞态。参数上,need是成人和儿童之和,儿童是否占位要在产品层面定清楚,代码里别做隐式假设。库存流水表记录订单号、团期、变动数量、变动类型,退款时按流水反向加回,比直接改sold_seats可追溯得多。

3.2 支付回调的幂等处理

支付回调可能重复到达,旅游管理系统必须做幂等。常见做法是用订单号加支付流水号做唯一索引,回调进来先尝试插入支付记录,插入成功才处理业务,重复插入直接返回成功。

-- 支付记录表,唯一索引保证幂等 CREATE TABLE pay_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, pay_no VARCHAR(64) NOT NULL COMMENT '第三方支付流水号', pay_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1, UNIQUE KEY uk_pay_no (pay_no) ) COMMENT '支付记录';

逻辑说明:uk_pay_no是幂等的关键,同一个支付流水号第二次插入会抛唯一键冲突,捕获后直接返回成功即可。参数上,pay_amount要和订单金额比对,防止篡改。处理完支付后,把订单状态从待支付改为已支付,并调用状态机校验,这一步不能省。

3.3 团期余量的缓存与失效

团期余量读多写少,适合放 Redis。但缓存和数据库的一致性要处理好:扣减库存成功后,删除缓存而不是更新缓存,让下次读时回源重建。

# 扣减成功后删除团期余量缓存 redis-cli DEL "schedule:stock:{scheduleId}"

逻辑说明:删除缓存比更新缓存安全,因为并发更新缓存可能写入旧值。参数上,缓存 key 用团期 ID 拼接,过期时间设短一点,比如 60 秒,作为兜底。如果对一致性要求极高,可以加一个延迟双删,但中小系统单删足够。

4. 避坑与排查:那些上线后才暴露的问题

4.1 超卖:现象是余位显示有但下单失败

现象:用户看到团期还有 3 个位,下单却提示余位不足。原因通常是缓存里的余量和数据库不一致,缓存没及时失效。解决:扣减成功后立即删缓存,并在下单接口里以数据库扣减结果为准,缓存只用于展示。另外检查deductStock的 SQL 是否漏了sold_seats + n <= total_seats条件。

4.2 订单状态错乱:已取消的订单又变成已支付

现象:用户取消订单后,延迟到达的支付回调把状态改成了已支付。原因:回调处理没有校验当前状态,直接覆盖。解决:回调里先查订单当前状态,只有待支付才允许改为已支付,其余状态记录日志并忽略。状态机校验方法在这里必须调用。

4.3 导游派单重复:同一个团期派了两个导游

现象:两个运营同时操作派单,结果团期关联了两条导游记录。原因:派单接口没有做唯一约束或乐观锁。解决:在团期导游关联表上加uk_schedule唯一索引,或者用团期版本号做乐观锁,派单前校验是否已有导游。

4.4 退款金额算错:儿童价和成人价混用

现象:退款时按成人价全额退,导致多退。原因:订单表只存了总金额,没存成人和儿童的数量与单价快照。解决:订单创建时把成人单价、儿童单价、数量都冗余存下来,退款按快照计算。参数上,金额用 DECIMAL,别用 DOUBLE,否则对账时会出现分位误差。

4.5 团期日期跨天:出发日期用字符串存导致排序错误

现象:按出发日期排序时,团期顺序混乱。原因:日期字段用了 VARCHAR。解决:统一用 DATE 或 DATETIME 类型,查询和排序交给数据库。如果前端传的是字符串,在 DTO 层做格式校验和转换。

5. 进阶验证:用压测和对账脚本确认系统真的可靠

系统能不能扛住旺季,不能靠感觉,要靠压测和对账。我一般会写两个脚本:一个用 JMeter 或 wrk 对下单接口施压,观察超卖和响应时间;一个每天跑一次对账,比对订单金额、支付记录、库存流水三者是否一致。

# 对账脚本核心逻辑:三方比对,输出差异 import pymysql def reconcile(date): conn = pymysql.connect(host='localhost', user='app', password='***', database='travel') cur = conn.cursor() # 订单侧应收 cur.execute("SELECT IFNULL(SUM(amount),0) FROM t_order WHERE DATE(created_at)=%s AND status IN (1,2)", (date,)) order_sum = cur.fetchone()[0] # 支付侧实收 cur.execute("SELECT IFNULL(SUM(pay_amount),0) FROM pay_record WHERE DATE(created_at)=%s AND status=1", (date,)) pay_sum = cur.fetchone()[0] # 库存流水侧扣减总数 cur.execute("SELECT IFNULL(SUM(change_num),0) FROM stock_log WHERE DATE(created_at)=%s AND type=1", (date,)) stock_sum = cur.fetchone()[0] if abs(order_sum - pay_sum) > 0.01: print(f"[差异] 订单应收 {order_sum} 与支付实收 {pay_sum} 不一致") print(f"对账完成: 订单 {order_sum}, 支付 {pay_sum}, 扣减位 {stock_sum}") conn.close() if __name__ == '__main__': reconcile('2025-01-01')

逻辑说明:对账脚本按天跑,订单应收和支付实收的差额超过一分钱就告警。参数上,status IN (1,2)表示已支付和已出行才算应收,取消和退款要排除。库存流水只统计扣减类型,和订单数量做交叉验证。这个脚本帮我抓到过一次支付回调漏处理的问题,当时订单已支付但支付记录没写,差额直接暴露。

压测时重点看两个指标:下单接口 P99 响应时间和库存扣减失败率。失败率突然升高,说明乐观锁冲突严重,可以考虑把扣减改成 Redis 原子操作加异步落库,但那样一致性会弱一些,要权衡。我自己的习惯是,任何旅游管理系统上线前,先用脚本模拟 200 并发抢同一个团期,确认不超卖、不重复派单、对账无差异,才敢放量。这套流程踩过坑也救过场,希望帮到你。

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

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

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

立即咨询