3. 用事件驱动迁移,禁止散落的
|
| 当前 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,不回退 |
迁移函数应是纯函数:输入当前快照和事件,输出 APPLY、IGNORE、REJECT 或 NEEDS_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());
}
调用 PSP 后出现连接超时、网关 502、客户端崩溃,均只能证明本系统没有获取结果。对于可能已经提交给 PSP 的动作,应进入 UNKNOWN 并冻结同一业务意图的重扣路径。
调用 PSP
├── 获得明确成功 / 失败 ──> 处理相应事件
└── 超时或不确定异常 ────> Attempt = UNKNOWN
│
▼
按 attemptId / PSP reference 主动查询
│
┌──────────┬───────┴──────────┐
▼ ▼ ▼
SUCCEEDED FAILED 仍无结论
延迟重试 / 转人工队列
查询必须使用最初生成的商户请求号或 PSP 交易号,绝不能通过“再创建一次交易”探测结果。只有查询得到渠道明确失败、且没有后续成功证据时,编排器才可评估是否创建新的 Attempt。对于高风险支付方式,UNKNOWN 老化应升级为人工审核或对账处理,而不是无限自动重试。
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 解决“有没有并发者先提交”,状态迁移矩阵解决“是否允许这样变”,二者缺一不可。
provider_event_id 或签名载荷摘要写入 Inbox,唯一索引拦住同一 Webhook 的重复投递。provider_payment_id + event_type + normalized_result、Attempt 版本和迁移规则去重。下游消费 PaymentSucceeded 时同样使用业务键去重。乱序不能简单按时间戳排序。渠道时间、接收时间和重试抵达时间都可能不可靠。应依据事件语义和证据等级:已验签的查询成功可以将 UNKNOWN 收敛为 SUCCEEDED;SUCCEEDED 后到达的处理中通知只记录为 IGNORED_STALE,不能回退资金事实。
授权、部分扣款、部分退款的风险在金额并发,而不是状态名称。例如已捕获 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,明确失败时释放预占,结果未知时继续保留并查询。不要在持有数据库锁时发起外部网络调用。
| 表 / 聚合 | 关键字段 | 用途 |
|---|---|---|
payment_order |
payment_id、merchant_id、merchant_order_no、金额、status、version |
业务视图;merchant_id + merchant_order_no 唯一 |
payment_attempt |
attempt_id、payment_id、PSP、provider_payment_id、status、result_certainty、version |
每次渠道执行 |
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)能力承接。
状态提交后通常要通知订单、账务、权益和通知服务。先提交数据库、再直接发 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 的“恰好一次”不应成为支付正确性的前提。支付核心以数据库事务保证本地事实一致;消息按至少一次投递,消费者靠业务幂等键保证副作用只执行一次。
恢复任务应按状态和截止时间扫描,而不是全表轮询:
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。切换条件应是渠道明确失败、用户主动更换方式,或经过受控人工判断。
payment_id、attempt_id、provider_reference、event_id、trace_id 关联;不得记录敏感认证信息。“终态不可逆”保护的是已确认资金事实,并不意味着拒绝记录新证据。渠道更正或人工修复应创建新的、带更高证据等级的受控事件和历史记录,而不是让管理员直接 UPDATE payment_attempt SET status = ...。
支付问题最后都会变成客服和资金问题,因此必须能一键串起证据。每次迁移至少记录:payment_id、attempt_id、event_id、事件来源、前后状态、原因码、PSP 引用、证据等级与 trace_id。
建议指标:
UNKNOWN 数量与老化时长,超出恢复目标时间(RTO)立即告警;一个实用的告警不是“错误日志超过阈值”,而是“某 PSP 的 UNKNOWN 在 10 分钟内陡增,且查询成功收敛率下降”。它能直接指导值班人员:暂停该 PSP 的新路由、保持已有 Attempt 不重扣、观察查询与回调,并根据证据进行恢复。
状态机的核心测试是属性与排列测试。为每种支付方式构造同步响应、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),先比对新旧状态决策与渠道查询结果,再逐步接管写路径。
不必一开始建成庞大平台,但第一版至少应具备:
PaymentOrder + PaymentAttempt 分离
UNKNOWN 与主动查询恢复
事件驱动迁移矩阵 + version CAS
Webhook Inbox 去重与验签
状态历史 + Transactional Outbox
退款金额原子预占
按 payment_id 贯通的日志、指标和告警
在此基础上,再演进授权 / Capture、智能路由、对账、争议处理和多活。不要反过来:先堆渠道数量,再补状态、证据和恢复机制;那会把每一个渠道异常都放大为资金事故。
一套成熟的支付状态机,管理的不是“最后一次请求写进了什么”,而是“在并发、重复、乱序和故障下,系统能否把可信证据收敛为唯一、可审计、可恢复的资金事实”。
当用户说“钱扣了,订单却没成功”时,系统应能在几分钟内说明:请求是否发出、PSP 如何确认、哪个事件被接受或忽略、是否已排入查询、金额是否被预占、以及下一步恢复动作。这才是支付核心真正的完成标准。