简介:eyoo 旅游机票电商平台(重构版)是一份面向 Ruby/Rails 学习者的在线机票销售系统项目资源,适合正在学习后端开发、准备课程设计或毕业设计的人员,也适合希望借鉴电商系统重构经验的开发者。资源以压缩包形式提供,包体约 2.62MB,文件类型与数量在资源页暂未列出,具体目录需解压后查看。目前已有 122 人浏览学习。内容围绕机票业务场景,重点涉及 Rails MVC 分层、ActiveRecord 数据库建模、RESTful API 设计、支付接口集成、安全防护、前端交互与自动化测试等知识点,同时体现重构版在系统性能、功能模块和用户体验上的优化方向。通过这份资源,读者可以获得一个旅游机票平台的完整项目参考,既能梳理 Ruby 后端开发的核心技术,也能学习电商类系统在业务建模、接口联调、部署运维等方面的工程化组织方式。
1. eyoo 重构版在解决什么:机票电商不只是「能下单」
eyoo 是一个旅游机票电商平台(重构版):用户搜航班、比价、下单、支付,平台对接上游供应商完成出票与退改。这个领域和普通电商最大的区别是,支付成功不等于交易结束,后面还挂着出票、占座、退改签一长串异步履约。老系统的典型病灶是订单状态靠 if/else 散落在各 Service,运价规则写死在代码里,库存扣减靠数据库行锁硬扛。重构版要回答的不是「界面像不像新系统」,而是三个问题:订单状态能不能被一张表说清楚、运价能不能从代码里抽成规则、并发占座会不会压垮数据库。这篇笔记写给机票和旅游电商的从业者,核心讲清楚该重写什么、按什么顺序写、上线前怎么验证。
2. 订单状态机先于代码:机票履约与普通电商的三处差异
2.1 机票订单状态为什么不能照搬「待支付→已支付→已发货」
普通电商的订单状态机大家都熟:待支付、已支付、已发货、已完成、已退款。机票订单如果照搬这套,第一个坑就在「已支付 = 交易成功」这个假设上。
机票的真实履约链路是:用户支付成功 → 平台向供应商下单占座(创建乘客记录,拿到航司确认号)→ 供应商返回出票结果 → 订单才真正「成立」。也就是说,支付成功之后至少还有一步「出票中」,而出票可能失败、可能超时、可能人工介入。另一个差异是退改:机票有退票、废票、改签三种分支,各自有费用计算逻辑,不能用一个 cancel 接口糊弄过去。
还有一个容易被忽略的点:机票订单的状态不是由用户动作驱动的,而是由航司/供应商的确认结果驱动的。用户点了支付,订单不一定进入已支付;供应商确认了出票,订单才该进入已出票。所以重构时我坚持一个原则:状态是平台对客观事实的认定,不是对用户操作的响应。把这个原则定下来,后面所有状态流转的代码都不会写歪。
2.2 用状态机表和流转日志把订单规则「定死」:建表 SQL 与调用逻辑
老系统最痛的地方,是状态流转逻辑散落在十几个 Service 方法里,每个方法里都有几行if (order.status == XXX) { ... }。新版本我第一件事就是把「允许谁、在什么事件下、从哪个状态到哪个状态」抽成一张配置表,再加一张流转日志表,所有状态变更必须经过统一入口。
-- 状态机配置表:定义合法流转 CREATE TABLE order_status_machine ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_type VARCHAR(16) NOT NULL, -- 订单类型:ticket / refund / change status_from VARCHAR(32) NOT NULL, -- 当前状态 status_to VARCHAR(32) NOT NULL, -- 目标状态 event_name VARCHAR(64) NOT NULL, -- 触发事件:pay_success / issue_confirm allowed_role VARCHAR(32) NOT NULL, -- 允许触发方:user / system / admin guard_bean VARCHAR(128) DEFAULT NULL, -- 校验器 Bean 名,如 RefundGuard UNIQUE KEY uk_biz_event (biz_type, status_from, event_name) ); -- 状态流转日志表:记录每一次变更的事实 CREATE TABLE order_status_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(64) NOT NULL, biz_type VARCHAR(16) NOT NULL, status_from VARCHAR(32) NOT NULL, status_to VARCHAR(32) NOT NULL, event_name VARCHAR(64) NOT NULL, operator_id VARCHAR(32) NOT NULL, -- userId 或 system biz_data JSON, -- PNR、票号、航司确认号等上下文 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_no (order_no) );这里的关键设计有两个。第一,event_name是事件而不是动作,比如用户点「取消」不算事件,真正的事件是pay_timeout或issue_fail,这样状态机不会被用户操作误导。第二,guard_bean是前置校验器,比如「已出票的订单不允许直接取消,只能走退票流程」,这种业务规则不进 Service,而是挂在状态机配置上。
调用侧我封装了一个transition(orderNo, eventName, operator)方法:先查配置表确认这条流转合法,再执行 guard 校验,然后开启事务更新订单状态并写日志。任何一段业务代码都不允许直接UPDATE order SET status = ...。这个约束在新系统里靠代码评审和统一入口双重保证。血的教训是,只要有绕过入口改状态的地方,后续对账和问题排查就会开始「翻车」。
2.3 出票中的超时、失败与人工救急:状态机的逃生通道
出票是典型的异步操作:平台把乘客信息发给供应商,供应商去航司系统创建记录并出票,整个过程从几秒到几分钟不等。重构版里我保留了「出票中」这个中间态,并且给它配了两个出口:成功出票回到主链路,失败或超时进入异常分支。
超时时间我一般设 10 到 30 分钟,取决于供应商的接口承诺。这里要特别小心一个场景:平台超时了,但供应商那边其实已经出票成功,只是通知没送达。如果直接按失败把订单关掉,就会出现「用户已经拿到票号,平台却说退款」的严重事故。所以我的做法是:超时后先进入「出票异常」状态,主动向供应商发起查询确认,拿到明确结果再决定走成功还是失败。
人工救急通道也得留。客服会接到各种边界情况:用户证件号录错、婴儿票和成人票关联失败、航司系统临时维护。重构版里管理员可以强制改状态,但必须记录 operator_id 和改单原因,而且只能改到状态机定义的目标状态,不能随便改到任意状态。这条逃生通道平时没人用,出问题的时候能救整个团队。
3. 运价与库存重构:两个最不该继续手写 if/else 的核心模块
3.1 运价不是一张价格表:fare_rule 设计与求值引擎的分层
机票价格是旅游电商里最容易被低估的模块。普通人以为一张航班对应一个价格,实际上一个航班按舱位代码能拆出十几个价格:Y 舱全价、B 舱折扣价、Q 舱特价,成人价儿童价婴儿价各不相同,还会叠加提前购票天数限制、是否可退可改规则。老系统把这些规则写成if (cabin == "Y") return price * 0.9;,维护成本极高。
重构版我采用的常见做法是:把运价拆成「规则数据 + 求值引擎」两层。规则数据落库,引擎统一计算。先看表结构:
CREATE TABLE fare_rule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, route_key VARCHAR(64) NOT NULL, -- 出发地_目的地_承运航司 cabin_class VARCHAR(8) NOT NULL, -- Y/B/H/L/Q 等舱位代码 passenger_type VARCHAR(8) NOT NULL, -- ADT成人 / CHD儿童 / INF婴儿 effective_date DATE NOT NULL, -- 规则生效日 expire_date DATE NOT NULL, -- 规则失效日 base_price DECIMAL(10,2) NOT NULL, -- 票面价 discount_rate DECIMAL(4,2) DEFAULT 1.00, -- 折扣率 min_advance_days INT DEFAULT 0, -- 至少提前 N 天购票 refund_rule JSON NOT NULL, -- 退票费率、是否可退 change_rule JSON NOT NULL, -- 改签费率、是否可改 source VARCHAR(16) NOT NULL, -- 数据来源:供应商A/供应商B version INT NOT NULL DEFAULT 1, UNIQUE KEY uk_route_cabin_passenger (route_key, cabin_class, passenger_type, effective_date, source) );求值引擎做四件事:第一步按 route_key、舱位、乘客类型、当前日期命中有效规则;第二步叠加税费(机建、燃油、离境税);第三步应用提前购票条件和促销折扣;第四步输出含税总价、退改规则说明和规则版本号。四步都在同一个服务里完成,不允许各业务方自己拼价格。
这里有一个设计红线:搜索报价、下单确认、退改费计算必须调用同一个求值引擎,只是入参不同。老系统最常见的幺蛾子是搜索接口一套价格逻辑、下单接口又写了一套,导致用户搜到 500 元,点进去变成 580 元。重构版把「同源」写进架构约束,谁再另起炉灶按事故处理。规则数据由定时任务从供应商拉取并版本化,每次拉取生成新的 version,订单只认自己下单时的 version。hot take 是:运价模块重构成败的衡量标准,不是算得快,而是所有入口算得一致。
3.2 库存预占:用 Lua 脚本代替数据库行锁,并配好释放时机
机票库存和普通商品库存有本质区别:普通电商的库存是平台自己的,机票库存是航司的。平台能做的只是「预占」——向供应商申请短暂的座位保留期,在保留期内完成出票确认。如果平台把预占当作扣减,一旦用户不支付,座位就被白白锁住。
重构前我用的是数据库行锁:SELECT ... FOR UPDATE扣减库存表。功能没问题,但峰值下数据库连接被打满,而且锁等待导致下单接口耗时飙到秒级。重构版改成 Redis 原子预占,核心是一段 Lua 脚本,保证「检查可售数量 + 扣减」的原子性:
-- inventory_hold.lua -- KEYS[1] = 航班可售库存 key(如 flight:stock:CA1801:Y) -- KEYS[2] = 预占集合 key(记录哪些订单占用了多少座) -- ARGV[1] = 本次预占数量 -- ARGV[2] = 订单号 -- ARGV[3] = 预占有效期(秒),到期自动释放 local remain = tonumber(redis.call('GET', KEYS[1]) or '0') if remain >= tonumber(ARGV[1]) then redis.call('DECRBY', KEYS[1], ARGV[1]) redis.call('ZADD', KEYS[2], os.time() + tonumber(ARGV[3]), ARGV[2]) return 1 end return 0这个脚本的关键点有三个。第一,用ZADD而不是SADD,把过期时间戳作为 score 存进有序集合,后台定时任务用ZRANGEBYSCORE扫出过期的预占记录并释放,释放逻辑就是INCRBY回补库存。第二,预占数量默认 1,但婴儿票不占座不扣库存,这部分逻辑在调用脚本之前分流。第三,预占成功不是终点,支付成功后还要走「预占转确认」,向供应商正式下单,确认成功扣减供应商配额,确认失败则同时释放本地预占。
超时释放的时长我一般按支付页倒计时设置,常见做法是 5 到 15 分钟,太长会让真实乘客买不到票,太短会导致用户还没来得及支付就被释放。这个参数要放到配置中心,跟业务方对齐后动态调整,不要写死在代码里。另外强调一点:Redis 预占是本地视图,供应商侧的可售配额仍然要定时同步,两个数字之间的差就是「在途预占」,运营看板必须展示这个差值,否则库存对不上时会怀疑人生。
3.3 下单时冻结价格快照:搜票价、下单价、退改价必须同源
价格是浮动的。用户搜索时 500 元,下单时可能已经涨到 580 元;即使下单成功,几天后退票,退票费按什么价格算也是个问题。重构版的解法是:订单创建那一刻,把完整的运价快照写进订单表,后续所有涉及金额的计算都以快照为准。
CREATE TABLE order_fare_snapshot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, route_key VARCHAR(64) NOT NULL, cabin_class VARCHAR(8) NOT NULL, base_price DECIMAL(10,2) NOT NULL, tax_amount DECIMAL(10,2) NOT NULL, -- 税费合计 total_price DECIMAL(10,2) NOT NULL, -- 含税总价 fare_rule_version INT NOT NULL, -- 命中的运价规则版本 fare_detail JSON, -- 完整计算过程,便于对账 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) );这个表看起来简单,却是重构中收益最大的一个改动。它解决了三类历史问题:第一,用户在支付页看到的价格和支付金额不一致时,可以直接拿快照核对;第二,退改签费用计算不再依赖「最新规则」,而是按下单时的规则版本计算,避免规则调整后旧订单被误算;第三,财务对账时,快照里的fare_detail记录了税费明细,每一分钱都能解释清楚。
我一般还会在快照里加一个price_mark字段,标记这次下单是实时报价还是缓存降级报价。如果上游供应商短暂不可用,系统会用上一版缓存价继续卖票,但会在支付页提示「价格可能变动」。这个字段就是给后续风控和争议处理留证据的。价格模块的原则一句话总结:搜索可以变,下单必须锁,退改必须认账。
4. 避坑:机票平台重构里最容易翻车的 4 个现场
4.1 支付回调与下单并发:幂等没做全,用户付了钱订单不动
现象:用户支付成功后,支付渠道回调到达系统,但订单还停在「待支付」。回调处理逻辑查不到订单,直接丢弃,用户付款成功却看不到订单更新,只能找客服。 原因:下单请求和支付回调是并发的,回调先于下单请求落库;更隐蔽的是,回调处理只校验了「回调 ID 是否存在」,没校验「订单号 + 支付事件」的组合幂等,同一个支付事件被重放时重复处理。 解决:回调处理第一步先查订单号,订单不存在则把回调暂存到消息队列,等下单事务提交后再消费;幂等控制改为(order_no, event_name)联合唯一键,重复事件直接忽略。重构版里我把幂等表建在支付模块内部,和订单表同库同事务,避免分布式事务带来的不一致。
4.2 缓存过期后直接透传上游:价格像过山车,用户直接流失
现象:某段时间用户刷新几次页面,同一航班价格在 500 到 1200 之间跳变,投诉量暴增。排查发现是本地缓存过期后,请求直接打到供应商接口,而供应商在那个时段正好在做价格调整,响应含税价逻辑不稳定。 原因:缓存设计只有一级,过期即穿透回源;上游接口没有超时熔断,也没有降级策略。机票价格波动本身正常,但平台把上游的每一次抖动都「如实」放大给了用户。 解决:改成两级缓存,本地进程缓存扛读峰值,Redis 存运价版本;缓存刷新用异步定时任务主动拉取,而不是请求触发回源;上游接口超时设为 1 秒,超时或异常时直接返回上一版有效报价并附加降级标记。价格是旅游电商的命根子,「宁可卖旧价,不可报错价」是我给这个模块定的规矩。
4.3 出票失败只记日志不改状态:退款要人工救火
现象:用户支付成功,平台出票失败,订单一直卡在「出票中」,钱压在平台,用户收不到票也拿不到退款。客服每天手动建退款单,财务再手工核对,整个链路耗时一周。 原因:异常处理逻辑只写了log.error,没有把失败事件接入状态机,也没有自动退款的任务。出票失败被当作「意外情况」而不是「正常业务分支」。 解决:重构版把出票失败定义为一等状态流转事件,issue_fail触发订单从「出票中」进入「出票失败」,同时向退款服务发一条内部消息,退款到账后由支付渠道回调触发状态机流转到「已退款」。退款必须做幂等和渠道对账,防止同一笔订单被退款两次。这条链路我建议在重构上线前做一次故障演练:人为让供应商返回出票失败,全链路跑通自动退款,再来谈上线。
4.4 预占库存超时未释放:航班「幽灵无票」,座位移交黄牛
现象:航班明明有余票,用户却搜不到;放票几分钟后突然又有了。扒数据发现一批订单停留在「待支付」状态超过 30 分钟,库存一直被占着,而占座的用户里相当比例是恶意占座的黄牛脚本。 原因:预占逻辑只做了「支付成功后释放」,支付超时没有任何兜底;预占记录没有 TTL,数据库里也没有定时扫描释放的任务。 解决:Redis 预占脚本里写入过期时间戳(即 3.2 节的有序集合方案),后台每分钟扫描一次过期预占并回补库存;同时给用户支付页加倒计时,前端到点提示「座位保留已失效,请重新搜索」。这里再补一刀:对高频占座但支付转化率极低的账号做风控标记,限制其预占频率。库存释放不光是技术问题,也是业务防黄牛的第一道闸。
5. 双写对账:重构版上线前最值得投入的一次验证
重构版代码写完,最怕的是「你以为逻辑对了,实际上差一点点」。我的验证习惯是上线前做一轮双写对账:把线上老系统的真实请求,异步复制一份喂给重构版,两边各自计算,然后逐字段比对结果。这个做法成本低、收益极高,是机票电商重构里我最推荐先做的验证手段。
做法分三步。第一步在老系统的入口处加一个复制过滤器,把请求体脱敏后投递到影子队列,主链路完全不受影响:
// ShadowReplayFilter.java(伪代码) public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) { // 1. 脱敏:手机号、证件号、姓名替换为占位符 ShadowRequest shadowReq = maskAndBuild((HttpServletRequest) req); // 2. 异步投递到影子队列,不阻塞主链路 shadowQueue.offer(shadowReq); // 3. 主链路继续走老系统原有逻辑 chain.doFilter(req, res); }影子队列的消费者调用重构版的服务,但不是直接落生产库,而是写到独立的影子库,或者只做计算不打任何写操作。第二步是比对,建议按业务模块分开比,重点盯三类结果:报价总价是否一致、订单状态流转路径是否一致、异常分支是否一致。比对维度可以落到一张表里:
| 比对对象 | 关键字段 | 不一致时怎么处理 |
|---|---|---|
| 搜索报价 | 含税总价、税费明细、舱位 | 记录 diff 并触发告警,人工核对规则版本 |
| 下单结果 | 订单状态、预占库存值 | 高优先级,直接影响用户交易 |
| 退改计算 | 退票费、改签费、可退金额 | 金额差一分钱都要查,格式化差异最常见 |
| 异常分支 | 超时、失败后的目标状态 | 重点看状态机是否走到定义内的合法状态 |
第三步是定收敛标准。双写对账跑两周,每天看差异率,差异率收敛到 0.01% 以下才开始切流量。切流量也讲究节奏:先切 1%,观察一整天支付成功率和出票成功率;稳定后切 10%,再观察退改链路;最后才切 50% 和全量。每次切换前都要准备回滚方案——重构版本质上是一次大型手术,双写就是麻醉机的监测仪,没了它,谁都不敢断言手术能成功。
最后说一个我的习惯:双写对账的 diff 结果我每周都会翻一遍,即使两边结果一致,我也会看日志里有没有「老系统花了 3 秒、新系统花了 300 毫秒」这种性能差异——它们往往是索引或缓存设计问题的早期信号。这套「复制请求、影子计算、逐字段比对、灰度切流」的打法,我用在不止一个项目上,每次都能在正式上线前捞出一批边界问题,比任何代码评审都实在。希望帮到你。
本文还有配套的精品资源,点击获取