为什么需要这份速查
交易端的事故,很多来自状态与金额的混淆:把支付状态当成订单状态,把券后价当成实付价,把退款中当成已退款。这些混淆在代码里表现为「一个字段装了两件事」,在界面上表现为「文案和实际不一致」。
这份附录把四类信息集中起来:
- 订单状态、支付状态、退款状态三套状态机的取值与迁移。
- 金额从商品价到实付、再到退款的分段口径。
- 状态之间的映射与禁止组合。
- 前端展示文案与按钮可用性的对照。
它不替代正文。第 4、5、6 章分别展开了状态机的设计理由;这里只做速查。
三套状态机,不要合并
先强调一条:订单状态、支付状态、退款状态是三套独立的状态机。
- 订单状态描述「这笔交易走到哪一步」。
- 支付状态描述「这一次支付尝试的结果」。
- 退款状态描述「这一次退款申请的处理进度」。
一张订单可以有多次支付尝试(换渠道、重新拉起),所以支付状态是一对多;一次支付可以对应一次或多次退款(部分退款),所以退款状态也是一对多。把它们压成一个 status 字段,迟早会出现「已支付但退款中」这种无法表达的组合。
订单状态
| 取值 | 含义 | 终态 | 端上主要动作 |
|---|---|---|---|
created | 已创建,尚未进入支付 | 否 | 去支付、取消 |
pending_payment | 待支付 | 否 | 去支付、取消、查看倒计时 |
paid | 已支付,待发货 | 否 | 查看物流、申请退款 |
shipped | 已发货 | 否 | 确认收货、查看物流 |
completed | 已完成 | 否 | 申请售后、再次购买 |
cancelled | 已取消 | 是 | 再次购买 |
refunding | 退款处理中 | 否 | 查看进度 |
refunded | 已退款 | 是 | 查看退款详情 |
允许的迁移:
created -> pending_payment | cancelled
pending_payment -> paid | cancelled
paid -> shipped | refunding
shipped -> completed | refunding
completed -> refunding
refunding -> refunded | paid # 退款失败可回到已支付
cancelled -> (无)
refunded -> (无)
两个容易写错的地方:
refunding -> paid是合法的:退款申请被驳回或渠道退款失败,订单回到已支付。completed -> refunding也是合法的:确认收货后仍可申请售后。
cancelled 与 refunded 是终态。任何从终态出发的迁移都应该被拒绝,而不是「允许但忽略」。
支付状态
| 取值 | 含义 | 是否终态 | 端上动作 |
|---|---|---|---|
created | 支付单已创建,未拉起 | 否 | 拉起渠道 |
pending | 已拉起,等待用户完成 | 否 | 等待、轮询 |
unknown | 结果不确定(超时、断网、回跳丢失) | 否 | 查询,不重试支付 |
succeeded | 渠道确认成功 | 是 | 展示成功,刷新订单 |
failed | 渠道明确失败 | 否 | 允许换渠道重试 |
cancelled | 用户取消或支付单过期 | 是 | 回订单页,可重新发起 |
unknown 是这张表里最重要的状态。它不是「失败」,也不是「成功」,而是「需要查询」。端上必须为它提供独立的出口:
- 查询中:显示「正在确认支付结果」。
- 查询到成功:转为
succeeded。 - 查询到失败:转为
failed。 - 查询也失败:保持
unknown,提示稍后重试查询,不提供「重新支付」按钮。
退款状态
| 取值 | 含义 | 是否终态 | 端上动作 |
|---|---|---|---|
requested | 用户已提交申请 | 否 | 等待审核 |
reviewing | 平台审核中 | 否 | 查看进度、补充证据 |
approved | 审核通过,退款中 | 否 | 查看到账时间 |
succeeded | 已到账 | 是 | 查看退款详情 |
rejected | 申请被驳回 | 是 | 查看原因、重新申请 |
failed | 渠道退款失败 | 否 | 等待平台处理,不自行重试 |
退款状态与支付状态不是同一套。一次支付成功后才可能进入退款;退款 failed 不改变支付状态,订单仍可能是 completed 或 paid。
金额的分段口径
同一笔交易,金额至少有六种口径:
| 口径 | 含义 | 谁计算 | 端上能否预演 |
|---|---|---|---|
| 商品原价 | 未优惠的单价 × 数量 | 服务端 | 可以展示,不预演 |
| 商品小计 | 各商品行金额之和 | 端上可算 | 可以 |
| 优惠金额 | 券、活动、会员折扣之和 | 服务端 | 可以预演,需标注 |
| 运费 | 配送费用 | 服务端 | 可以预演,需标注 |
| 应付金额 | 小计 − 优惠 + 运费 | 服务端 | 可以预演,需标注 |
| 实付金额 | 用户实际支付 | 服务端 | 不可以 |
关键规则:实付金额只能由服务端写,端上不能预演。 预演可以算到应付金额,但提交后必须以服务端返回的实付金额为准,并且在支付前展示给用户确认。
退款金额又有自己的口径:
- 整单退:实付金额全额,按支付方式原路退回。
- 部分退:按商品行分摊,优惠金额按比例分摊,运费是否退取决于规则。
- 分摊后可能出现 1 分的舍入差,必须由服务端给出最终值,端上不做二次分配。
金额字段的命名约定
为了避免「同一个数字在不同页面叫不同名字」,建议统一命名:
originalAmount:商品原价合计。discountAmount:优惠合计(正数表示减免)。shippingAmount:运费。payableAmount:应付金额。paidAmount:实付金额。refundAmount:本次退款金额。currency:币种,与金额成对出现。
所有金额用整数分,字段名带 Amount 后缀,避免 price、total、fee 混用。
状态与金额的禁止组合
下面这些组合在数据上不应该出现;如果出现,端上应该报错而不是猜测:
| 组合 | 为什么禁止 |
|---|---|
订单 pending_payment + 支付 succeeded | 支付成功必须推进订单状态 |
订单 cancelled + 支付 succeeded | 取消后仍扣款,需要人工处理 |
订单 refunded + 退款 succeeded 但无退款单 | 缺少退款记录 |
| 实付金额 > 应付金额 | 金额口径错误或多扣 |
| 退款金额 > 实付金额 | 超退 |
多次 succeeded 支付且未退款 | 重复支付 |
端上在合并服务端数据时,可以用这些规则做一次自检;发现异常时记录日志并展示「订单状态异常,请联系客服」,而不是把矛盾的数字直接渲染。
展示文案与按钮
状态到文案的映射应该集中在一处,不要散落在各个组件里。
| 订单状态 | 文案 | 主按钮 | 次按钮 |
|---|---|---|---|
pending_payment | 待支付 | 去支付 | 取消订单 |
paid | 待发货 | 查看详情 | 申请退款 |
shipped | 待收货 | 确认收货 | 查看物流 |
completed | 已完成 | 再次购买 | 申请售后 |
cancelled | 已取消 | 再次购买 | — |
refunding | 退款中 | 查看进度 | — |
refunded | 已退款 | 查看详情 | — |
支付状态为 unknown 时,订单页的主按钮必须是「查询支付结果」,而不是「去支付」。
事件与序列号
如果系统使用事件驱动(推送、回调、轮询),每个事件应带:
eventId:全局唯一,用于去重。seq:单调递增序号,用于排序与检测空洞。occurredAt:事件发生时间。version:订单或支付单的版本号。
端上按 seq 排序、按 eventId 去重;当版本号回退时丢弃旧事件。这些规则与卷二第 7 章的消息可靠性一致。
订单号、支付单号与幂等键
这三个标识经常被混用。它们解决的问题不同:
| 标识 | 作用域 | 谁生成 | 是否可变 | 用错会怎样 |
|---|---|---|---|---|
orderId | 一笔订单 | 服务端 | 否 | 用作幂等键会导致二次下单被吞掉 |
paymentId | 一次支付尝试 | 服务端 | 否 | 同一订单换渠道后无法区分重试 |
idempotencyKey | 一次提交动作 | 端上生成 | 否 | 重试生成新键,产生重复订单 |
幂等键的正确用法:
- 用户点击「提交订单」时生成一次,写入本地草稿。
- 网络超时重试时复用同一个键,不重新生成。
- 用户主动返回并修改商品后再提交,才生成新键。
- 键的生命周期覆盖「提交—支付」整个过程,支付成功后可以释放。
常见错误是把 orderId 当幂等键。服务端在创建订单之前还没有 orderId,端上自然拿不到;而如果端上先用时间戳拼一个「伪订单号」,重试时又拼了一个新的,幂等就失效了。
刷新与合并顺序
端上同时有本地草稿、页面缓存与推送事件三个来源时,合并顺序决定界面是否可信。
建议的优先级从高到低:
- 服务端查询结果(以版本号为准)。
- 推送事件(按
seq排序,版本号回退则丢弃)。 - 本地草稿(只用于尚未提交的表单)。
- 页面缓存(只用于首屏占位)。
一条实用规则:任何写操作返回后,都用返回值覆盖本地状态,而不是乐观地改本地再等推送。 推送是补充,不是事实来源。
当多个请求并发返回时,用请求发起时记录的版本号比较,旧版本的结果直接丢弃。没有版本号时,用「最后一次发起」而不是「最后一次返回」作为胜出者。
时间与时区口径
交易时间至少有四个口径,混用会造成「倒计时差 8 小时」「超时时间提前」这类问题:
| 口径 | 例子 | 用途 |
|---|---|---|
| 服务端时间戳 | createdAt(UTC 毫秒) | 排序、超时计算 |
| 展示时间 | 2026-10-27 14:00(本地时区) | 界面文案 |
| 剩余时长 | expiresIn(秒) | 倒计时 |
| 业务日 | 2026-10-27(平台时区) | 对账、报表 |
规则:
- 传输一律用 UTC 时间戳,不用格式化字符串。
- 倒计时用服务端给的
expiresIn起算,并用本地单调时钟推进,不用Date.now()与服务器时间直接相减——设备时间可能被用户改过。 - 业务日按平台时区计算,不用设备时区;跨境订单尤其明显。
- 展示时间由端上按设备时区转换,但要在文案里说明是「本地时间」。
支付超时是最容易出错的地方。服务端说「30 分钟内完成支付」,端上应该展示基于 expiresIn 的倒计时;如果端上用 expiresAt - Date.now(),而设备时间快了 10 分钟,用户会看到只剩 20 分钟,实际还有 30 分钟。
状态变化的触发源
同一笔订单的状态可能由四条路径改变,排查时要先确认是哪一条:
| 触发源 | 典型场景 | 延迟 | 是否可靠 |
|---|---|---|---|
| 同步响应 | 下单、取消的返回值 | 立即 | 可靠 |
| 主动查询 | 支付结果查询、订单详情刷新 | 秒级 | 可靠 |
| 服务端推送 | 支付成功、发货通知 | 秒级到分钟级 | 可能丢失 |
| 渠道回调 | 支付渠道异步通知 | 秒级到分钟级 | 可能重复、可能延迟 |
端上不能只依赖推送。正确做法是:进入订单页先查询一次,订阅推送做增量,离开页面时取消订阅;重新进入时再查询一次,用版本号合并。
排查「状态没更新」时,按这个顺序确认:查询接口返回什么、推送是否到达、端上是否因版本号回退丢弃了事件、缓存是否被错误命中。
金额展示规则
金额的展示比计算更容易出错,因为它涉及格式、币种与舍入。
- 整数分转元时用整数运算,避免浮点:
(cents / 100).toFixed(2)在极大值下仍可能失真,安全做法是先取整再拼接。 - 千分位只用于展示,不进入计算。
- 币种符号与金额成对出现;跨境场景同时展示币种代码。
- 优惠金额展示为负数(
-¥12.00)还是正数加文案(已减 ¥12.00),全站统一。 - 划线价必须有依据;没有原价的商品不要造一个「原价」。
- 0 元订单、1 分订单、超大金额都要有展示用例。
一张总表
把三套状态机的关键终态与出口放在一起:
| 对象 | 终态 | 失败后的出口 |
|---|---|---|
| 订单 | cancelled、refunded | 重新下单 |
| 支付 | succeeded、cancelled | 换渠道重试(failed) |
| 退款 | succeeded、rejected | 重新申请(rejected) |
注意:支付 failed 不是终态,因为用户可以换渠道重试;但支付 cancelled 是终态,需要新建支付单。
与正文的对应
- 订单状态机与迁移校验:第 4.2 节。
- 支付编排与
unknown:第 5.1、5.2 节。 - 回调时序与重复通知:第 5.3 节。
- 重复支付与对账:第 6.1、6.2 节。
- 退款状态与分摊:第 6.3 节。
小结
这份速查的三条主线:
- 状态分开。 订单、支付、退款是三套状态机,各有自己的终态与出口;压成一个字段就无法表达「已支付但退款中」。
- 金额分段。 商品价、优惠、运费、应付、实付、退款是六个口径;端上可以预演到应付,实付只能由服务端给。
- 来源排序。 同步响应优先于推送,推送优先于缓存;任何写操作后用返回值覆盖本地,不用乐观值等待确认。
查表时先问三个问题:这个字段属于哪套状态机?这个金额是哪个口径?这个值来自哪个触发源?三个问题答清楚,大多数「状态不对」「金额不对」的争论会直接消失。
常见错误与边界
- 用一个
status字段同时表达订单、支付、退款三种状态。 - 把
unknown归入failed,引导用户重复支付。 - 把
refunding展示成「已退款」。 - 端上预演实付金额并用于提交。
- 部分退款时端上自行分摊优惠,产生 1 分误差。
- 金额字段命名混乱,
total有时指应付、有时指实付。 - 状态迁移采用黑名单,新增状态被静默放行。
- 事件没有
eventId与seq,重复与乱序无法处理。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。