火车票在线预订业务流程全解:从车次查询到出票与退改签

本文梳理火车票在线预订业务的完整链路,涵盖车次查询、余票显示、座位选择、订单生成、支付处理、出票与退改签等核心环节,并结合一套典型的三方对接架构(C 端 / 业务中台 / 供应商)给出状态机设计与异常场景处理方案,供相关系统的设计者与开发者参考。

一、背景介绍

火车票是 OTA(在线旅游)平台继机票、酒店之后最重要的一块业务。与机票相比,火车票业务有两个显著特点:

  1. 资源强依赖 12306:票源、余票、占座、出票、退改签全部由 12306 官方体系掌控,OTA 平台本质上是"代理",必须通过供应商对接 12306,自身几乎没有定价和库存的自主权;
  2. 流程状态复杂:一笔订单要经历"预订 → 支付 → 占座 → 出票 → 出行/退改"多个环节,每个环节都可能被用户取消、被上游拒绝、被系统中断,状态机设计是系统的核心难点。

本文以一个典型的"C 端 + 业务中台 + 第三方供应商"三方架构为例,完整走一遍火车票预订的旅程,重点回答三个问题:一个查询到出票的完整流程长什么样?订单状态如何流转?遇到异常(余票不足、超时未支付、重复下单)怎么兜底?

二、系统架构与角色划分

整个链路涉及三个角色:

角色职责
C 端用户入口。负责车站/车次查询、12306 账号绑定、旅客维护、预订下单、开票申请等操作
业务中台业务中台。承接 C 端请求,编排业务流程,维护订单状态机,并将创建订单、确认出票、退款等动作下发给供应商
供应商对接 12306 的执行方。负责实际占座、出票、退改,并通过异步通知将结果回传中台

关键点在于:业务中台与供应商之间是异步协作。中台下发"创建订单"后,并不能立即拿到结果,占座成功、出票成功、订单取消等结果都由供应商通过通知回调(如 占座成功通知出票成功通知订单取消通知)异步送达。这决定了订单系统必须基于状态机 + 事件驱动来设计,而非简单的同步 RPC 调用链。

三、业务流程概览

一次完整的火车票购买旅程如下:

sequenceDiagram
autonumber
participant c as C端
participant s as 业务中台
participant b as 供应商
c ->> s: 查询车站(/api/train/cn/query/station)
s -->> c: 车站数据
c ->> s: 搜索车站(/api/train/cn/query/search/station)
s -->> c: 搜索车站数据
c ->> s: 查询车次(/api/train/cn/query/list)
s -->> c: 车次列表
c ->> s: 是否登陆成功(/api/train/cn/query/login-state)
s -->> c: 登陆失败;可以继续登陆(没有账号密码、验证码、密码错误...) & 不可以继续登陆(未通过验证、app信息未核验)
c ->> s: 保存12306账号密码(/api/train/cn/login/save)
s -->> c: 保存成功
c ->> s: 查询旅客列表(/api/train/cn/query/pg-list)
s -->> c: 旅客列表
c ->> s: 新增旅客(/api/train/cn/pg/add)
s -->> c: 新增旅客失败;可操作新增(账号密码错误)
c ->> s: 删除旅客(/api/train/cn/pg/del)
s -->> c: 删除旅客成功
c ->> s: 预订车次(/api/train/cn/booking)
s -->> c: 预订失败;可以继续下单(乘客信息错误,占座失败),不能继续下单(行程冲突)
c ->> s: 申请开票(/api/train/cn/invoice/apply)
s -->> c: 开票请求已受理
c ->> s: 换开发票申请(/api/train/cn/invoice/exchange)
s -->> c: 换开票请求已受理
c ->> s: 开票状态查询(/api/train/cn/invoice/query/status)
s -->> c: 已开票
c ->> s: 开票详情(/api/train/cn/invoice/query/detail)
s -->> c: 发票图像等
s ->> b: 创建订单
b -->> s: 创建订单成功
b -->> s: 占座成功通知
s ->> b: 确认出票,预付款支付
b -->> s: 支付成功
b -->> s: 出票成功通知
s ->> s: 修改订单已出票
b -->> s: 订单取消通知
s ->> s: 已支付,进入退款流程

整个流程可以压缩为一条主线:查询(车站/车次)→ 账号绑定 → 维护旅客 → 预订(占座)→ 支付 → 出票 → 出行 / 退改 / 开票。下面逐个环节展开。

四、核心环节详解

4.1 车站与车次查询

查询是用户旅程的第一步,由两个接口组成:

  • 查询车站:返回全量车站数据(车站名、三字码等),供前端渲染输入联想;
  • 搜索车站:按关键词模糊匹配车站,如输入"北京"返回北京站、北京西、北京南、北京朝阳等;
  • 查询车次:基于出发/到达站、日期查询车次列表,返回车次号、出发到达时间、历时、票价、余票等。

设计上的两个要点:一是车站数据要本地化缓存(车站全集相对稳定,没必要每次回源上游),并按拼音/首字母/三字码做多维度索引,支撑搜索联想;二是车次查询是只读且高频的,应走缓存 + 定时刷新余票的策略,避免每次查询都穿透到供应商,把查询与预订两条链路在性能上解耦。

4.2 余票显示与座位选择

车次列表页通常只展示"有票 / 无票 / 候补"的粗粒度余票状态,用户点进车次详情后才会看到按席别(二等座、一等座、商务座、硬卧、软卧等)拆分的具体余票与票价

这里需要特别说明:火车票的余票是实时变化的,列表页显示有余票不代表下单时一定占座成功。因此产品层面通常做两件事:

  1. 详情页的余票数据设置较短的缓存 TTL(如 30~60 秒),并在页面给出"余票实时变动,以提交订单为准"的提示;
  2. 座位选择实际发生在下单时——用户提交订单后系统向 12306 发起占座请求,由上游按当时的真实库存分配座位,而不是由前端"提前选定"某个具体座位号。前端能做的只是选择席别和偏好(如靠窗),具体座位号以出票结果为准。

这一点与机票类似,但用户的体感预期往往更高,体验设计上要提前管理好预期。

4.3 12306 账号绑定与登录

火车票预订依赖用户的 12306 账号(乘车人实名信息在 12306 侧维护)。系统通过两个接口管理登录态:

  • 查询登录状态:判断当前用户是否已绑定并登录 12306;
  • 保存账号密码:保存用户填写的 12306 账号密码,完成绑定。

登录失败的返回被明确划分为两个等级,这个设计很值得借鉴:

失败等级含义示例
可继续登录用户侧可纠正的输入问题未填写账号密码、验证码错误、密码错误
不可继续登录账号或设备层面的硬性拦截未通过验证、app 信息未核验

区分等级的意义在于给 C 端不同的交互反馈:可继续登录时,前端应保留表单、高亮错误字段并允许重试;不可继续登录时,应终止流程并引导用户去 12306 App 完成核验,避免用户反复尝试产生挫败感。

4.4 旅客管理

预订前需要维护乘车人(旅客)名单,接口包括查询旅客列表、新增旅客、删除旅客。与登录类似,新增旅客失败也会细分原因——典型的是账号密码错误导致无法拉取/新增旅客,此时返回"可操作新增",提示用户重新校验账号。

实名制的敏感性在这里体现得很充分:旅客的姓名、证件号是后续占座、出票的硬性校验要素,一旦录入错误,会导致占座失败甚至出票后无法乘车。因此新增旅客时建议做格式校验(身份证号校验位、姓名长度、证件类型枚举)以及重复旅客的去重提示。

4.5 订单生成与占座(预订)

预订车次是整条链路的核心动作,也是三方协作最密集的环节:

  1. C 端提交预订请求 → 业务中台生成内部订单,下发创建订单给供应商;
  2. 供应商创建订单成功后,向 12306 发起占座
  3. 占座结果异步回调:占座成功通知 / 占座失败(以预订失败形式返回)。

预订失败同样分两个等级:

失败等级含义示例
可继续下单换一批乘客或重试即可乘客信息错误、占座失败
不可继续下单该订单/乘客在时间上已冲突行程冲突(同一乘车人同一时段已存在车票)

“行程冲突"是 12306 的硬性规则:同一身份证在同一时段(如出发时间重叠的区间)不能持有两张票。这是不可重试的确定性失败,系统应直接阻断并提示用户更换车次或乘客,而不是让用户反复提交。

4.6 支付处理

订单状态机中,支付环节的状态流转为:待支付 → 支付成功 / 支付失败 / 支付超时 / 订单取消

  • 支付成功:进入后续的占座/出票环节;
  • 支付失败:停留在待支付,允许用户继续支付;
  • 超时未支付:系统自动取消订单(详见"异常场景”);
  • 用户主动取消:直接进入订单取消。

对于火车票这类"支付成功后才真正锁定资源"的业务,支付回调的幂等与对账是重中之重:支付结果必须与订单状态关联,防止"用户已扣款但订单状态未更新"的悬空态,通常由支付回调 + 主动轮询/对账双通道兜底。

4.7 出票

支付成功后,系统进入出票链路:

  1. 业务中台向供应商发起确认出票 + 预付款支付
  2. 供应商完成出票,异步回调 出票成功通知
  3. 业务中台收到通知后修改订单状态为已出票

值得注意的细节:占座与出票是分离的——占座只锁定座位但未支付给铁路局,确认出票时才完成预付款。这个"先占座、后出票、再付款"的节奏,正是为了降低用户取消和占座失败带来的资金风险。出票失败时,系统需要走程序自动退款(详见异常场景)。

4.8 退改签与开票

出行前,用户可能产生退票、改签需求;出行后,需要申请发票。这块是"售后链路",接口面较广:

  • 退票:用户主动申请退票 → 供应商执行退票 → 收到"退票 + 退款"通知 → 进入退款流程;
  • 改签:申请改签 → 供应商执行改签 → 收到改签成功通知 → 生成改签单(改签记录落库 train_change_record 表);
  • 开票:申请开票、换开发票(如抬头信息错误)、查询开票状态、查询开票详情(发票图像等)。

退改签在系统实现上会形成新的子订单,而不是在原单上原地修改。此时需要一个"主从关系"来管理:原单 + 改签单 / 退票单并存,各自独立流转状态。

五、订单状态机设计

把以上环节落成状态机,是火车票系统的核心资产。整体状态图如下:

stateDiagram-v2
[*] --> OrderLifecycle
state OrderLifecycle {
    创建订单 --> 待支付: 创建订单成功
    待支付 --> 支付成功: 支付有效
    待支付 --> 支付失败: 支付无效
    支付失败 --> 待支付: 可以继续进行支付操作
    待支付 --> 支付超时: 超时未支付
    待支付 --> 订单取消: 用户主动取消
    支付超时 --> 订单取消: 超时取消
    订单取消 --> 订单完成
    支付成功 --> 占座中
    占座中 --> 占座成功: 收到占座成功通知
    占座中 --> 占座失败: 收到占座失败通知
    占座失败 --> 退款中: 程序发起退款
    占座成功 --> 出票中: 调用确认出票,预付款
    出票中 --> 已出票: 出票成功
    出票中 --> 退款中: 出票失败,程序发起退款
    已出票 --> 出行完成: 正常出行
    已出票 --> 改签中: 申请改签
    改签中 --> 改签成功: 收到改签成功通知
    出行完成 --> 订单完成
    占座中 --> 退款中: 用户主动申请退款
    退款中 --> 已退款: 退款
    已出票 --> 退票中: 用户主动申请退票
    退票中 --> 退款中: 收到退票和退款通知
    已退款 --> 订单完成
}
订单完成 --> [*]

从状态机中可以提炼出三个设计原则:

  1. 状态由事件驱动:占座成功、出票成功、订单取消等结果都来自供应商的异步通知,状态机必须能正确响应回调事件,且回调必须是幂等的(同一通知重复到达不产生副作用);
  2. 自动兜底优先:占座失败、出票失败都自动进入退款流程,避免资金悬挂在用户与供应商之间;
  3. 原单与子单并存:改签、退票形成子订单,需要独立的记录与状态映射。

改签记录表结构如下(在原设计基础上做了一些修正):

CREATE TABLE `train_change_record` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `uid` varchar(64) NOT NULL COMMENT '用户ID',
  `source` tinyint NOT NULL COMMENT '供应商编码',
  `origin_order_no` varchar(128) NOT NULL COMMENT '平台原单订单号(被改签的原始订单)',
  `order_no` varchar(128) NOT NULL COMMENT '平台改签订单号(改签产生的新订单)',
  `out_order_no` varchar(128) NOT NULL COMMENT '平台传给供应商的订单号',
  `out_order_id` varchar(128) NOT NULL COMMENT '供应商侧订单号(回调幂等键)',
  `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  KEY `idx_uid` (`uid`),
  UNIQUE KEY `uk_out_order_id` (`out_order_id`)
) COMMENT='火车票改签记录表';

几点修正说明:

  • 原设计中 sourceorder_no 允许为空,但这张表只承载"原单与改签单的映射关系",任何一条记录都必须能完整追溯,因此统一改为 NOT NULL
  • 增加 idx_uid 索引支撑"按用户查改签记录"的高频查询;
  • 供应商回调(改签成功/失败通知)携带的是供应商侧订单号,out_order_id 应加唯一索引,作为回调消费的幂等键,防止通知重复投递产生重复记录;
  • origin_order_no(原单)与 order_no(改签单)容易混淆,注释中明确二者指向,避免维护时误用。

表中同时记录了三套订单号(origin_order_no 原单、order_no 改签单、out_order_no/out_order_id 外部单),正是三方架构下单号映射关系的缩影——任何一个环节的追溯都依赖这组映射。需要说明的是,这张表只负责映射关系,改签产生的车次、席别、补差价、手续费等业务明细应存放在改签订单主表中,以 order_no 关联,避免把一张关系表做成"大杂烩"。

原单、改签单、退票单在"占座中 / 占座成功 / 占座失败"三类状态下,对外展示的文案各不相同,需要一张映射表统一维护:

let orderStatus = {
    "占座中": {
        "原单": "占座中",
        "改签单": "改签中",
        "退票单": "退票中",
    },
    "占座成功": {
        "原单": "占座成功",
        "改签单": "改签成功",
        "退票单": "退票成功",
    },
    "占座失败": {
        "原单": "占座失败",
        "改签单": "改签失败",
        "退票单": "退票失败",
    }
}

这个设计看似简单,实际意义重大:底层状态与展示文案解耦,底层只维护统一的占座状态,上层按订单类型渲染不同文案,后续新增订单类型(如候补单)无需改动底层状态机。

六、典型异常场景处理

6.1 余票不足(占座失败)

场景:用户提交订单时余票充足,但占座请求到达 12306 时票已被抢完,供应商返回占座失败。

处理:系统将订单置为"占座失败",程序自动发起退款,并推送"下单失败,已为您全额退款"的通知。这里要特别注意退款的及时性——用户已经付款,退款拖延会直接触发客诉,建议退款失败时进入人工工单兜底。

产品侧优化:将"提交前实时校验余票"与"支持候补/无票时提示替代车次"纳入设计,从源头减少失败占比。

6.2 超时未支付

场景:用户创建订单后迟迟未支付(常见于比价、犹豫、中途退出)。

处理:待支付状态设有超时时间(如 10 分钟),超时后系统自动取消订单、释放占座资源。用户再次支付时需重新下单。实现上依赖定时任务扫描 + 支付回调双通道:定时任务负责兜底清理,支付回调负责处理"刚好在超时边缘完成支付"的竞态——此时以实际支付结果为准,若订单已被取消则触发自动退款。

6.3 重复下单

场景:用户手抖连点两次提交,或前端重试导致同一笔订单被创建多次。

处理:这是典型的幂等场景,方案分两层:

  1. 前端防抖:提交按钮置灰 + 请求去重,从源头降低概率;
  2. 后端幂等:预订接口由客户端生成幂等请求号(request_id),服务端以该请求号做唯一键,重复请求直接返回已创建的订单。不建议用"用户 + 车次 + 乘客 + 日期"这类业务字段做幂等键——它会把合法场景误伤:同一位乘客为多人代买同一车次、乘客取消后重新下单,都会被错误拦截。业务维度校验(如行程冲突)应当放在下单前的预校验,而不是当作幂等手段。

这里还要区分一个概念:重复下单行程冲突是两件事。幂等解决的是"同一次点击被重复提交",行程冲突解决的是"同一乘车人同一时段已存在车票",前者靠请求号唯一键拦截,后者靠业务预校验拦截,二者职责不同,不应混为一谈。

6.4 行程冲突(不可重试)

场景:乘客已在其他渠道购买了相同时段的车票。

处理:预订直接失败并标记为"不可继续下单",前端明确提示冲突原因,引导用户更换车次或乘车人。切忌让用户反复重试,因为这是确定性失败。

6.5 出票失败自动退款

场景:占座成功、用户已支付,但出票环节失败(上游系统异常、票价变动等)。

处理:状态机中"出票中 → 退款中"的边即为此设计——程序发起退款,无需用户申请。同时保留"已出票"与"退款中"的互斥:一旦退款流程启动,禁止再次出票,防止"钱退了票还出了"的资损事故。

6.6 回调丢失 / 重复回调

场景:供应商的占座/出票通知因网络问题丢失,或重复投递。

处理:这是异步协作架构下必须处理的可靠性问题:

  • 幂等消费:以外部单号 + 事件类型为唯一键,重复通知直接忽略;
  • 主动对账:不能只依赖回调,需提供"订单详情查询"等接口,定时任务对长时间停留在"占座中 / 出票中"的订单主动向上游拉取真实状态并修复;
  • 超时人工兜底:状态长时间无进展的订单进入告警 + 人工核查队列。

七、总结

火车票预订业务可以概括为一句话:把不可控的 12306 上游,编排成对用户可控的体验。围绕这条主线,本文梳理了几个核心结论:

  1. 三方异步协作是架构底色:C 端 / 中台 / 供应商之间以"下发指令 + 异步回调"协作,订单系统必须事件驱动、回调幂等、主动对账;
  2. 状态机是系统的核心资产:从创建订单到订单完成的全链路状态流转、原单与改签/退票子单并存、底层状态与展示文案解耦,都依赖一张设计严谨的状态机;
  3. 异常场景以"自动兜底"为原则:占座失败、出票失败自动退款,超时未支付自动取消,重复下单以请求号幂等 + 行程冲突预校验双重拦截;
  4. 资金安全优先级最高:先占座、后出票、再付款的节奏 + 退款流程的程序化触发,把资损风险控制在最低。

火车票业务链路长、上游强依赖、状态复杂,但一旦把状态机和异常兜底设计清楚,整体会变得相当可控。希望这份梳理能对正在设计同类系统的同学有所帮助,也欢迎交流不同架构下的实践经验。