《互联网产品的客户端架构·交易、增长与平台化》附录 A 状态与金额速查

把交易端最容易混淆的状态与金额字段收在一张表里:订单、支付、退款三套状态机各自的取值、迁移与终态,金额从商品价到应付、实付再到退款的分段口径与字段命名,状态之间的禁止组合,以及展示文案、按钮可用性和事件时序的对照,供评审、联调与线上排查时随手查用。

为什么需要这份速查

交易端的事故,很多来自状态与金额的混淆:把支付状态当成订单状态,把券后价当成实付价,把退款中当成已退款。这些混淆在代码里表现为「一个字段装了两件事」,在界面上表现为「文案和实际不一致」。

这份附录把四类信息集中起来:

  1. 订单状态、支付状态、退款状态三套状态机的取值与迁移。
  2. 金额从商品价到实付、再到退款的分段口径。
  3. 状态之间的映射与禁止组合。
  4. 前端展示文案与按钮可用性的对照。

它不替代正文。第 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,端上自然拿不到;而如果端上先用时间戳拼一个「伪订单号」,重试时又拼了一个新的,幂等就失效了。

刷新与合并顺序

端上同时有本地草稿、页面缓存与推送事件三个来源时,合并顺序决定界面是否可信。

建议的优先级从高到低:

  1. 服务端查询结果(以版本号为准)。
  2. 推送事件(按 seq 排序,版本号回退则丢弃)。
  3. 本地草稿(只用于尚未提交的表单)。
  4. 页面缓存(只用于首屏占位)。

一条实用规则:任何写操作返回后,都用返回值覆盖本地状态,而不是乐观地改本地再等推送。 推送是补充,不是事实来源。

当多个请求并发返回时,用请求发起时记录的版本号比较,旧版本的结果直接丢弃。没有版本号时,用「最后一次发起」而不是「最后一次返回」作为胜出者。

时间与时区口径

交易时间至少有四个口径,混用会造成「倒计时差 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 节。

小结

这份速查的三条主线:

  1. 状态分开。 订单、支付、退款是三套状态机,各有自己的终态与出口;压成一个字段就无法表达「已支付但退款中」。
  2. 金额分段。 商品价、优惠、运费、应付、实付、退款是六个口径;端上可以预演到应付,实付只能由服务端给。
  3. 来源排序。 同步响应优先于推送,推送优先于缓存;任何写操作后用返回值覆盖本地,不用乐观值等待确认。

查表时先问三个问题:这个字段属于哪套状态机?这个金额是哪个口径?这个值来自哪个触发源?三个问题答清楚,大多数「状态不对」「金额不对」的争论会直接消失。

常见错误与边界

  • 用一个 status 字段同时表达订单、支付、退款三种状态。
  • 把 unknown 归入 failed,引导用户重复支付。
  • 把 refunding 展示成「已退款」。
  • 端上预演实付金额并用于提交。
  • 部分退款时端上自行分摊优惠,产生 1 分误差。
  • 金额字段命名混乱,total 有时指应付、有时指实付。
  • 状态迁移采用黑名单,新增状态被静默放行。
  • 事件没有 eventId 与 seq,重复与乱序无法处理。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查