支付边界由 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 缺服务裸抛异常。
{ "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.1Stripe-Signature: t=1724750000,v1=5257a869e7eceeda32abad62f1a986b77e875f930dd4725130ed02f5d2b7e35fContent-Type: application/json
{ "id": "evt_1Pxy001", "type": "payment_intent.succeeded" }端点开启 request buffering,读取原始 Body、全部 Headers 和 QueryString,构造 PaymentCallbackInput 后调用 HandleCallbackAsync。不能先 JSON 反序列化再序列化,因为字节变化可能破坏验签。
4. 回调处理步骤
- 由当前活跃
_provider验签;异常或失败统一映射为 SignatureInvalid; - 要求 provider 解析出 OutTradeNo;
- 只接受 PaymentStatus.Success;
- 用 OutTradeNo 查 IdempotencyKey 对应发票;
- 已 Paid 直接成功,不重复保存/发事件;
- 将 PaidAmount 分单位除以 100,与发票 Amount 精确比较;
- 只允许 Issued/Overdue 进入 Paid;
- 显式
IUnitOfWork.BeginAsync; - 保存发票并发布 InvoicePaidIntegrationEvent;
- 一起 Commit,异常时 Rollback 后重抛。
InvoicePaidIntegrationEvent 包含 InvoiceId、TenantId、PaymentOrderId(实际填 OutTradeNo)、Amount、PaidAt、ProviderCode、IdempotencyKey。和 InvoiceIssued 一样,运行时主题是类型全名。
5. 供应商应答策略
| 路由 provider | 成功响应 |
|---|---|
| alipay | 200 text/plain,body=success |
| wechatpay | 200 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 测试。