Skip to content
bitzorcas
中EN

Guide

Billing 支付下单、回调与事务

深入解释支付 provider 组合、金额单位、匿名回调验签、幂等入账、CAP Outbox 事务、供应商应答与安全缺口。

Last updated

支付边界由 Contracts 的 IPaymentGateway 定义,Infrastructure 的 PaymentGatewayAdapter 翻译到 Bitzsoft.Integrations.Payment.IPaymentProvider。API Host 暴露匿名回调,JobHost 通过同一端口创建自动续费订单与查询支付状态。

1. Provider 组合

Payment:Provider 大小写不敏感地支持:

  • alipay → Payment:Alipay;
  • wechatpay → Payment:WeChatPay;
  • stripe → Payment:Stripe。

缺失或未知 provider 时注册 UnavailablePaymentGateway 与 UnavailablePaymentReconciliationPort。调用方能解析端口,但每次操作得到 Payment.ProviderUnavailable,不会因 DI 缺服务裸抛异常。

选择单一活跃支付 provider
{
"Payment": {
"Provider": "stripe",
"Stripe": {
"ApiKey": "由密钥存储注入,不提交到仓库",
"WebhookSecret": "由密钥存储注入,不提交到仓库"
},
"AutoRenewal": {
"Enabled": false,
"CronExpression": "0 0 1 * * ?",
"ExpiryWindowDays": 3
},
"Reconciliation": {
"Enabled": false,
"CronExpression": "0 0 6 ? * MON"
}
}
}

具体 provider 配置键归连接器包所有,部署时应以连接器版本的契约为准。PlatformBilling 只负责选择配置节与绑定端口。

2. 下单请求与当前调用方

CreatePaymentOrderRequest 包含 InvoiceId、IdempotencyKey、ProviderCode、Scene、Subject、TotalAmount、Currency、ReturnUrl、OpenId、AuthCode、Extra。适配器校验:

  • InvoiceId、IdempotencyKey、ProviderCode 非空;
  • Currency 只能是 CNY、USD、EUR;
  • TotalAmount 大于 0。

随后把平台 decimal 主货币单位乘 100,按 ToEven 舍入为连接器 long 分单位;OutTradeNo 使用发票 IdempotencyKey。

从权威发票构造支付订单
public static class PaymentErrors
{
public static readonly Error InvoiceNotPayable =
Error.Conflict("Payment.Invoice.NotPayable", "账单当前不可支付。");
}
// 只有服务端读取的已开具发票才能成为金额和币种来源。
// 当前示例只允许 Issued 账单进入支付发起路径。
var invoice = await repository.FindInvoiceAsync(
currentUser.User.TenantId,
invoiceId,
cancellationToken);
if (invoice.IsFailure || invoice.Value!.Status != InvoiceStatus.Issued)
return PaymentErrors.InvoiceNotPayable;
var order = await gateway.CreatePaymentOrderAsync(
new CreatePaymentOrderRequest
{
InvoiceId = invoice.Value.InvoiceId,
IdempotencyKey = invoice.Value.IdempotencyKey,
ProviderCode = configuredProvider,
Scene = PaymentSceneKind.Web,
Subject = $"平台订阅 {invoice.Value.Period}",
TotalAmount = invoice.Value.Amount, // 不接受客户端金额。
Currency = invoice.Value.Currency,
ReturnUrl = trustedReturnUrl,
},
cancellationToken);

上面是安全调用方式。适配器本身不会按 InvoiceId 回读发票,也不核对请求的 Amount/Currency/IdempotencyKey 是否互相匹配;信任边界由调用方承担。当前仓库没有公开 PaymentOrder Command/Endpoint,只有 AutoRenewalJobExecutor 调用该方法,并丢弃返回的 PayUrl/QrCode/PrepayId。

3. 匿名回调入口

统一回调路由形状
POST /api/payments/callback/stripe HTTP/1.1
Stripe-Signature: t=1724750000,v1=5257a869e7eceeda32abad62f1a986b77e875f930dd4725130ed02f5d2b7e35f
Content-Type: application/json
{ "id": "evt_1Pxy001", "type": "payment_intent.succeeded" }

端点开启 request buffering,读取原始 Body、全部 Headers 和 QueryString,构造 PaymentCallbackInput 后调用 HandleCallbackAsync。不能先 JSON 反序列化再序列化,因为字节变化可能破坏验签。

CAP OutboxUnit of WorkBilling repositoryPaymentGatewayAdapterCallback endpointPayment providerCAP OutboxUnit of WorkBilling repositoryPaymentGatewayAdapterCallback endpointPayment providerraw body + signature headersPaymentCallbackInputVerifyCallbackAsyncstatus + outTradeNo + paidAmountinvoice by idempotency keyalready Paid? amount matches?BeginMarkPaid + SaveInvoiceInvoicePaidIntegrationEventCommitResultprovider-specific acknowledgement

4. 回调处理步骤

  1. 由当前活跃 _provider 验签;异常或失败统一映射为 SignatureInvalid;
  2. 要求 provider 解析出 OutTradeNo;
  3. 只接受 PaymentStatus.Success;
  4. 用 OutTradeNo 查 IdempotencyKey 对应发票;
  5. 已 Paid 直接成功,不重复保存/发事件;
  6. 将 PaidAmount 分单位除以 100,与发票 Amount 精确比较;
  7. 只允许 Issued/Overdue 进入 Paid;
  8. 显式 IUnitOfWork.BeginAsync;
  9. 保存发票并发布 InvoicePaidIntegrationEvent;
  10. 一起 Commit,异常时 Rollback 后重抛。

InvoicePaidIntegrationEvent 包含 InvoiceId、TenantId、PaymentOrderId(实际填 OutTradeNo)、Amount、PaidAt、ProviderCode、IdempotencyKey。和 InvoiceIssued 一样,运行时主题是类型全名。

5. 供应商应答策略

路由 provider成功响应
alipay200 text/plain,body=success
wechatpay200 JSON {code:"SUCCESS",message:"成功"}
stripe/其他200 空体

端点把 SignatureInvalid 和 AmountMismatch 视为“硬失败”,记录日志后仍返回供应商成功响应,阻止重试;其他失败返回 400,让供应商重试。

这个策略需要安全评审。验签失败可能来自密钥轮换、时间漂移、解析 bug 或配置错误;直接确认成功会永久丢失真实付款通知。更稳妥的做法是:保存脱敏失败证据、按原因区分 2xx/4xx/5xx、告警、支持 provider 查询补偿,并确保伪造请求不能制造高成本重试风暴。

6. provider 路由一致性缺口

回调 URL 的 {provider} 只进入 PaymentCallbackInput.ProviderCode 和应答格式。PaymentGatewayAdapter 使用 DI 中的单一 _provider,没有比较:

route provider == configured provider == verified provider.Code

也就是说,配置 Stripe 时向 /callback/alipay 发送一个能通过 Stripe 验签的 payload,会由 Stripe provider 验证,却按支付宝格式返回。应在读取 body 之前或 Adapter 入口拒绝 provider 不一致,并把未知 provider 返回稳定错误;不要让路由参数只控制应答格式。

7. 金额与币种边界

下单仅接受 CNY/USD/EUR,因为换算硬编码两位小数。JPY/KRW 等零位币种、KWD/BHD 等三位币种会被拒绝。ToMinorUnits 使用银行家舍入;请求金额 1.005 可能舍入到 100 分,业务规则必须在账单生成时先规范到币种精度。

回调只比较 PaidAmount 与 Invoice.Amount。当前 connector verification 结果没有在平台层比较 Currency;也没有比较 provider merchant/account、InvoiceId、TenantId、subject、已知 PaymentAttempt 或 provider transaction id。OutTradeNo 是唯一关联键。

上线前应至少持久化 PaymentAttempt:

字段用途
AttemptId / IdempotencyKey下单与重试幂等
InvoiceId / TenantId权威归属
Provider / MerchantAccount路由与商户一致性
OutTradeNo / ProviderTradeNo双向对账
Amount / Currency不可变收款预期
Status / FailureCode尝试生命周期
CreatedAt / PaidAt时序证据
CallbackEventId / PayloadHash回调重放与审计

8. 幂等与并发回调

“发票已经 Paid 就成功”只能抑制串行重复。两个并发回调可能都读取 Issued、都 MarkPaid、都尝试保存并发布 InvoicePaid。源码没有 callback inbox、分布式锁或条件状态更新;聚合 Version 能否让一个失败取决于 ORM/UoW 行为,且没有支付回调并发测试证明只发布一次。

生产门禁应验证:

  • 同 payload 串行/并行投递 100 次只产生一次状态迁移和一次事件;
  • A/B 不同 provider event id 指向同订单时语义确定;
  • save 成功/event 失败、event 成功/commit 失败都可安全重试;
  • 回调在 Issue commit 之前到达时不会永久丢失;
  • Voided/unknown/mismatched invoice 进入持久异常队列;
  • provider 查询可以补偿丢失回调。

推荐以 (Provider, CallbackEventId) inbox 唯一键加 UPDATE ... WHERE Status IN (Issued,Overdue),并在同事务写入支付事实和 Outbox。

9. 事务保证的范围

Adapter 显式包住 SaveInvoice 与 Publish。只有当生产 IUnitOfWork 与 CAP publisher 共享同一数据库事务时,才能宣称原子 Outbox。API Shell 的 NullUnitOfWork/Null publisher 只能让应用启动,不提供生产保证。

回滚异常会被记录但不掩盖原异常;外层端点对普通异常如何转 HTTP 由统一异常处理决定。日志含 InvoiceId/OutTradeNo 等业务标识,生产需要脱敏策略和受限访问。

10. 当前测试证据

仓库没有 PaymentGatewayAdapter、PaymentReconciliationAdapter、PaymentEndpointGroup、AutoRenewal 或 Reconciliation 的专用行为测试。现有 Billing tests 不构造 IPaymentProvider,也不发送真实回调。

必须补齐:

  • 三 provider 的签名成功/失败 byte fixtures;
  • route/configured/provider code 一致性;
  • CNY/USD/EUR 舍入边界与不支持币种;
  • unknown order、wrong amount、wrong currency、wrong merchant;
  • 串行/并发重复回调与事件 exactly-once effect;
  • 数据库+CAP 提交/回滚故障注入;
  • 三种 success acknowledgement 的 content type/body;
  • 硬失败应答与 provider 重试策略的沙箱验证;
  • 密钥轮换、时钟漂移和回调重放窗口;
  • 未配置/未知 provider 的 fail-closed Host 测试。

返回 Billing 总览 · 账单与幂等 · 后台任务、测试与 GA

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%