找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4817

积分

0

好友

621

主题
发表于 昨天 18:47 | 查看: 19| 回复: 0

《从零搭建企业级支付渠道中台》04

一次支付会同时面对同步响应、异步回调、主动查询、重试、路由切换与人工修复。本文给出一套可落地的状态机、数据模型和恢复闭环。

先给结论

支付状态机不是 status 字段的枚举集合,而是一套约束资金事实如何被证据合法推进的机制。

它至少要保证四件事:

  1. 网络超时不被误判为支付失败;
  2. 同一渠道结果重复、乱序到达时,只产生一次业务副作用;
  3. 一次渠道尝试失败,不会过早终结整笔支付;
  4. 任意时刻都能回答:这笔钱为什么是这个状态、依据是什么、下一步由谁处理。

如果只记住三条原则,请记住:

UNKNOWN ≠ FAILED
PaymentAttempt FAILED ≠ PaymentOrder FAILED
支付成功 ≠ 已结算;退款成功 ≠ 原支付失败

1. 支付状态的难点,不在“状态多”,而在“事实来源多”

普通订单往往由单一服务推进:创建、付款、发货、完成。支付则同时有商户前端、支付核心、支付服务商(Payment Service Provider,PSP)、收单行、发卡行及异步清算网络。

用户 ──> Payment API ──> Payment Core ──> PSP ──> 银行网络
                     │                    │
                     │                    ├── 同步响应
                     │                    ├── Webhook
                     │                    └── 主动查询
                     └── 定时恢复 / 人工受控修复

最危险的情形是:PSP 已扣款,但同步 HTTP 响应在网络中丢失。此时本地只知道“没有拿到结果”,不知道“交易失败”。若把超时直接写成 FAILED 并切换 PSP 重扣,便可能造成重复扣款。

因此状态机的输入不应是“某段代码想改成什么状态”,而应是经过归一化后的领域事件(Domain Event),并携带其来源、证据等级和关联的 PSP 交易号。

2. 先划清聚合边界:不要设计超级 PaymentStatus

把授权、扣款、退款、拒付和结算都塞进一条 payment.status,最初很省表,之后一定失控:它们属于不同生命周期,不能互相覆盖。

PaymentOrder(业务支付意图)
  └── 1:N PaymentAttempt(一次具体 PSP 执行)
        ├── Authorization(授权)
        ├── Capture(扣款)
        └── 1:N RefundOrder(退款资金动作)

Settlement / Reconciliation(清结算、对账)是独立账务生命周期

以 100 USD 的支付为例:Attempt-01 在 PSP-A 被拒绝,Attempt-02 在 PSP-B 成功。Attempt-01=FAILEDAttempt-02=SUCCEEDED,而 PaymentOrder=SUCCEEDED。因此 Attempt 的失败是一次渠道执行结果;Order 的失败是编排器判定“再无合法尝试可做”的业务结果

推荐状态语义

聚合 建议状态 关键语义
PaymentOrder CREATEDPROCESSINGREQUIRES_ACTIONAUTHORIZEDSUCCEEDEDFAILEDCANCELEDEXPIRED 用户支付意图是否完成
PaymentAttempt CREATEDSUBMITTEDPROCESSINGREQUIRES_ACTIONAUTHORIZEDSUCCEEDEDFAILEDCANCELEDUNKNOWN 对一个 PSP 的执行事实
RefundOrder CREATEDPROCESSINGUNKNOWNSUCCEEDEDFAILEDCANCELED 一笔反向资金动作

UNKNOWN 建议只先落在 Attempt / Refund 等渠道动作上;Order 保持 PROCESSING。这能避免上游把“未知”误当成“可立即重试的失败”。

3. 用事件驱动迁移,禁止散落的 setStatus()

渠道适配器(PSP Adapter)负责把 Stripe、Adyen、PayPal 等渠道语义翻译为统一事件;状态机不认识具体 PSP。

PSP Response / Webhook / Query
              │
              ▼
       PSP Adapter(校验、映射)
              │
              ▼
  PaymentEvent(统一领域事件)
              │
              ▼
  State Machine(决定是否可迁移)

示例事件:ATTEMPT_SUBMITTEDCUSTOMER_ACTION_REQUIREDPAYMENT_AUTHORIZEDPAYMENT_SUCCEEDEDPAYMENT_FAILEDRESULT_UNKNOWNREFUND_SUCCEEDED。业务代码只能提交事件,不能直接执行 payment.setStatus(SUCCEEDED)

迁移矩阵比 if-else 更可靠

当前 Attempt 状态 事件 新状态 处理规则
CREATED ATTEMPT_SUBMITTED SUBMITTED 记录 PSP 请求已发出
SUBMITTED/PROCESSING CUSTOMER_ACTION_REQUIRED REQUIRES_ACTION 等待 3DS / 跳转动作
SUBMITTED/PROCESSING PAYMENT_AUTHORIZED AUTHORIZED 只适用于先授权模式
SUBMITTED/PROCESSING/UNKNOWN PAYMENT_SUCCEEDED SUCCEEDED 确认资金成功
SUBMITTED/PROCESSING/UNKNOWN PAYMENT_FAILED FAILED 仅接受渠道明确失败
SUBMITTED/PROCESSING RESULT_UNKNOWN UNKNOWN 网络、协议或依赖异常
SUCCEEDED PROCESSING 事件 SUCCEEDED 审计为 stale,不回退

迁移函数应是纯函数:输入当前快照和事件,输出 APPLYIGNOREREJECTNEEDS_RECONCILIATION。它不发 HTTP、不选路由、不发消息。状态机负责“能不能变”;支付编排器(Payment Orchestrator)负责“接下来做什么”。

public Decision decide(PaymentAttempt current, PaymentEvent event) {
if (event.isDuplicateOf(current.lastAcceptedEventId())) return Decision.ignore("DUPLICATE");
if (current.status().isSucceeded() && event.isNonFinalProgress()) return Decision.ignore("STALE");
return transitions.resolve(current.status(), event.type(), event.evidenceLevel());
}

4. UNKNOWN 是资金安全阀,不是异常垃圾桶

调用 PSP 后出现连接超时、网关 502、客户端崩溃,均只能证明本系统没有获取结果。对于可能已经提交给 PSP 的动作,应进入 UNKNOWN 并冻结同一业务意图的重扣路径。

调用 PSP
  ├── 获得明确成功 / 失败 ──> 处理相应事件
  └── 超时或不确定异常 ────> Attempt = UNKNOWN
                                  │
                                  ▼
                        按 attemptId / PSP reference 主动查询
                                  │
               ┌──────────┬───────┴──────────┐
               ▼          ▼                  ▼
           SUCCEEDED    FAILED           仍无结论
                                      延迟重试 / 转人工队列

查询必须使用最初生成的商户请求号或 PSP 交易号,绝不能通过“再创建一次交易”探测结果。只有查询得到渠道明确失败、且没有后续成功证据时,编排器才可评估是否创建新的 Attempt。对于高风险支付方式,UNKNOWN 老化应升级为人工审核或对账处理,而不是无限自动重试。

5. 并发、重复和乱序:一次迁移必须原子提交

Webhook、同步响应和恢复任务可能并发处理同一 Attempt。正确的提交边界是一个本地事务:

BEGIN
  1. Inbox / ChannelEvent 去重
  2. 读取 Attempt 与金额汇总
  3. 校验证据与状态迁移
  4. 使用版本号 CAS 更新快照
  5. 追加不可变状态历史
  6. 写入 Transactional Outbox
COMMIT
UPDATE payment_attempt
SET status = :next_status,
    result_certainty = :certainty,
    last_accepted_event_id = :event_id,
    version = version + 1,
    updated_at = NOW()
WHERE attempt_id = :attempt_id
AND version = :expected_version;

受影响行数为 0 不代表“失败重试更新”。处理器应重新读取、重新决策:事件可能已被别的入口接受,也可能现在已成为旧事件。CAS 解决“有没有并发者先提交”,状态迁移矩阵解决“是否允许这样变”,二者缺一不可。

两层幂等性

  1. 传输幂等:用 provider_event_id 或签名载荷摘要写入 Inbox,唯一索引拦住同一 Webhook 的重复投递。
  2. 业务幂等:即便 PSP 没有稳定事件 ID,也要以 provider_payment_id + event_type + normalized_result、Attempt 版本和迁移规则去重。下游消费 PaymentSucceeded 时同样使用业务键去重。

乱序不能简单按时间戳排序。渠道时间、接收时间和重试抵达时间都可能不可靠。应依据事件语义和证据等级:已验签的查询成功可以将 UNKNOWN 收敛为 SUCCEEDEDSUCCEEDED 后到达的处理中通知只记录为 IGNORED_STALE,不能回退资金事实。

6. 金额是不变量,不能只靠状态保护

授权、部分扣款、部分退款的风险在金额并发,而不是状态名称。例如已捕获 100,两个退款各申请 70;若都读取“可退 100”后再异步提交,可能超退。

推荐将金额使用最小货币单位 minor_unit 存为整数,并维护:

authorized_minor
captured_minor
refund_reserved_minor
refunded_minor

0 ≤ captured ≤ authorized(适用于授权模式)
0 ≤ refunded + refund_reserved ≤ captured

创建退款前,用短事务原子预占金额:

UPDATE payment_amount
SET refund_reserved_minor = refund_reserved_minor + :amount
WHERE payment_id = :payment_id
AND captured_minor - refunded_minor - refund_reserved_minor >= :amount;

只有影响一行才创建 RefundOrder。随后在事务外调用 PSP;明确成功时把预占转入 refunded_minor,明确失败时释放预占,结果未知时继续保留并查询。不要在持有数据库锁时发起外部网络调用。

7. 数据模型:快照、事实、原始证据三者分离

表 / 聚合 关键字段 用途
payment_order payment_idmerchant_idmerchant_order_no、金额、statusversion 业务视图;merchant_id + merchant_order_no 唯一
payment_attempt attempt_idpayment_id、PSP、provider_payment_idstatusresult_certaintyversion 每次渠道执行
payment_amount 授权、捕获、退款、退款预占金额 用事务维护资金不变量
refund_order / capture_order 业务幂等键、金额、状态、PSP 引用 独立资金动作
channel_event / webhook_inbox 渠道事件 ID、载荷摘要、验签结果、处理状态 原始输入与去重
payment_state_history from/to、事件、来源、原因、操作者、时间 不可变审计轨迹
payment_outbox 领域事件、分区键、投递状态、重试次数 可靠投递

原始 Webhook 应在验签后加密留存,业务表只保存脱敏字段和受控引用。银行卡号、CVV、完整支付凭据不应进入日志、消息、状态历史或通用事件表;PCI DSS 范围内的数据应尽可能由 PSP 托管令牌化(tokenization)能力承接。

8. 可靠消息与完整链路

状态提交后通常要通知订单、账务、权益和通知服务。先提交数据库、再直接发 MQ 会产生“支付成功但下游永远不知道”的断裂。事务发件箱(Transactional Outbox)把状态快照、历史和待投递事件放在同一个本地事务中;Publisher 再以至少一次投递发送消息,下游通过 Inbox 幂等消费。

PSP Webhook
  │ 验签、限流、快速 ACK
  ▼
Webhook Inbox ──> 异步 Processor ──> Adapter ──> State Machine
                                                    │
                         ┌──────────────────────────┴──────────────────────┐
                         ▼                 同一 DB 事务                    ▼
                 Attempt / Amount      History + Outbox              Refund / Capture
                         │                                                │
                         └──────────────> Outbox Publisher ──> MQ ────────┘
                                                                       │
                                           Order / Ledger / Notification / Analytics

MQ 的“恰好一次”不应成为支付正确性的前提。支付核心以数据库事务保证本地事实一致;消息按至少一次投递,消费者靠业务幂等键保证副作用只执行一次。

9. 故障恢复与高可用:把不可达变成可运营

恢复任务应按状态和截止时间扫描,而不是全表轮询:

SUBMITTED / PROCESSING 超时  → 查询 PSP
UNKNOWN                     → 指数退避查询,直至渠道 SLA 或人工阈值
REQUIRES_ACTION 已过期      → 关闭 Attempt,Order 进入 EXPIRED 或创建新意图
Outbox 未投递               → 退避重投,超过预算进入 DLQ
Inbox 处理失败              → 可重放;毒性事件隔离到 DLQ

支付 API、Webhook 接入、异步处理器和恢复任务应水平扩展。通过 payment_id / attempt_id 作为消息分区键,使同一聚合尽量有序;即使跨分区失序,状态机仍要正确。对 PSP 调用设置超时预算(Timeout Budget)、有限重试预算(Retry Budget)、熔断器(Circuit Breaker)和按 PSP / 商户 / 支付方式的隔离舱(Bulkhead),避免某一渠道故障耗尽整个支付核心线程池。

多 PSP 路由切换必须遵守一条硬规则:存在未收敛的 UNKNOWN Attempt 时,不自动创建可能导致再次扣款的新 Attempt。切换条件应是渠道明确失败、用户主动更换方式,或经过受控人工判断。

10. 安全与审计边界

  • Webhook 先验签、校验时间窗口与重放保护;验签失败不进入状态机。
  • 订单创建、支付确认、退款和人工修复都必须具备调用方身份、商户隔离、权限校验与业务幂等键。
  • 退款、强制关闭 UNKNOWN、状态纠正属于高风险命令:双人审批、原因码、证据附件和全量审计不可省略。
  • 日志使用 payment_idattempt_idprovider_referenceevent_idtrace_id 关联;不得记录敏感认证信息。
  • 密钥通过专用密钥管理服务托管,定期轮换;PSP 凭据与商户配置要按租户最小权限隔离。

“终态不可逆”保护的是已确认资金事实,并不意味着拒绝记录新证据。渠道更正或人工修复应创建新的、带更高证据等级的受控事件和历史记录,而不是让管理员直接 UPDATE payment_attempt SET status = ...

11. 可观测性与运行手册

支付问题最后都会变成客服和资金问题,因此必须能一键串起证据。每次迁移至少记录:payment_idattempt_idevent_id、事件来源、前后状态、原因码、PSP 引用、证据等级与 trace_id

建议指标:

  • 按 PSP、支付方式、商户划分的成功率、失败码分布、P95 / P99 处理时延;
  • UNKNOWN 数量与老化时长,超出恢复目标时间(RTO)立即告警;
  • Webhook 验签失败、重复率、处理积压、DLQ 数量;
  • CAS 冲突率、非法迁移率、Outbox 投递延迟与重试耗尽数;
  • 退款预占金额、超时未释放预占、对账差异金额。

一个实用的告警不是“错误日志超过阈值”,而是“某 PSP 的 UNKNOWN 在 10 分钟内陡增,且查询成功收敛率下降”。它能直接指导值班人员:暂停该 PSP 的新路由、保持已有 Attempt 不重扣、观察查询与回调,并根据证据进行恢复。

12. 测试:验证事件排列,而不只是代码覆盖率

状态机的核心测试是属性与排列测试。为每种支付方式构造同步响应、Webhook、查询、超时和人工命令的组合,验证最终状态和副作用均正确。

A. timeout → query succeeded → delayed processing webhook
   期望:UNKNOWN → SUCCEEDED;最后一个事件为 stale

B. processing webhook → succeeded webhook → duplicate succeeded webhook
   期望:只发生一次 SUCCEEDED,且只发布一次成功领域事件

C. captured=100,两个并发退款各 70
   期望:仅一个金额预占成功,另一个被拒绝

D. 状态事务已提交,进程在发送 MQ 前崩溃
   期望:Outbox 重放;下游业务幂等后只生效一次

E. PSP-A 的 Attempt 为 UNKNOWN,路由器尝试 PSP-B
   期望:默认策略阻止自动重扣,进入查询 / 人工决策

还应进行故障注入:模拟 PSP 延迟、重复回调、签名错误、数据库 CAS 冲突、消息重复投递和恢复任务重启。上线采用灰度发布(Canary Release),先比对新旧状态决策与渠道查询结果,再逐步接管写路径。

13. 最小可落地版本

不必一开始建成庞大平台,但第一版至少应具备:

PaymentOrder + PaymentAttempt 分离
UNKNOWN 与主动查询恢复
事件驱动迁移矩阵 + version CAS
Webhook Inbox 去重与验签
状态历史 + Transactional Outbox
退款金额原子预占
按 payment_id 贯通的日志、指标和告警

在此基础上,再演进授权 / Capture、智能路由、对账、争议处理和多活。不要反过来:先堆渠道数量,再补状态、证据和恢复机制;那会把每一个渠道异常都放大为资金事故。

写在最后

一套成熟的支付状态机,管理的不是“最后一次请求写进了什么”,而是“在并发、重复、乱序和故障下,系统能否把可信证据收敛为唯一、可审计、可恢复的资金事实”。

当用户说“钱扣了,订单却没成功”时,系统应能在几分钟内说明:请求是否发出、PSP 如何确认、哪个事件被接受或忽略、是否已排入查询、金额是否被预占、以及下一步恢复动作。这才是支付核心真正的完成标准。




上一篇:GPT-6 Astra 的 AGI 时刻:用 Blender 抢 3D 设计师饭碗
下一篇:南大蒋炎岩生成式软件工程课:没有付费Token的CS学生该退学吗
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-9-10 16:57 , Processed in 1.496396 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表