Stripe 支付接入基线:PaymentIntent 抽象、Mock/真实网关切换与后续 Webhook 演进
目录
📦 本文基于的完整项目源码:https://github.com/meirongdev/shop
电商系统接入支付时,第一步通常不是“把所有 webhook 和退款补偿一次做完”,而是先把支付入口、provider 边界和返回模型抽清楚。Shop Platform 当前已经完成的是这条基线:buyer-bff 统一调用 wallet-service,wallet-service 再按支付方式路由到 Stripe / PayPal / Klarna / 内部 Wallet。
更准确地说,当前仓库已经具备:
- 统一的
WalletApi.PAYMENT_INTENT/PAYMENT_METHODS合约 PaymentProviderService里的 provider routing- Stripe 真/假网关切换(
StripePaymentGateway/MockPaymentGateway) - buyer-bff 对
clientSecret/redirectUrl的透传
但还没有把 Stripe webhook、Stripe Refund API、事件幂等表、Payment Element 前端确认链路真正提交进来。所以这篇文章也应该按“已落地基线 + 明确演进方向”来写,而不是把未来设计写成现状。
当前支付架构#
这里最重要的设计点不是“只支持 Stripe”,而是先把支付方式抽象统一。当前 WalletApi.PaymentMethod 已经包含:
| 支付方式 | 当前 provider | 仓库现状 |
|---|---|---|
WALLET |
内部钱包余额 | 已落地 |
STRIPE_CARD |
Stripe | 已落地 |
APPLE_PAY |
Stripe 通道 | 已落地到 provider enum / routing |
GOOGLE_PAY |
Stripe 通道 | 已落地到 provider enum / routing |
PAYPAL |
PayPal Orders API | 已落地 |
KLARNA |
Klarna Sessions API | 已落地 |
这也意味着文章不能再把“支付抽象 = Stripe 单一实现”写成事实。
buyer-bff 当前怎么接支付#
当前 checkout 里,buyer-bff 会根据 paymentMethod 分成两条路:
if ("WALLET".equals(paymentMethod)) {
WalletApi.TransactionResponse payment = post(
properties.walletServiceUrl() + WalletApi.PAYMENT_CREATE,
new WalletApi.CreatePaymentRequest(buyerId, orderTotal, "usd", "checkout", "ORDER"));
paymentTransactionId = payment.transactionId();
} else {
WalletApi.PaymentIntentResponse intent = post(
properties.walletServiceUrl() + WalletApi.PAYMENT_INTENT,
new WalletApi.CreatePaymentIntentRequest(buyerId, orderTotal, "usd", paymentMethod));
paymentTransactionId = intent.intentId();
clientSecret = intent.clientSecret();
lastRedirectUrl = intent.redirectUrl();
}
这段实现里有两个我觉得比较顺手的地方:
- Wallet 余额支付直接走内部记账
- 外部支付方式统一收敛成
PaymentIntentResponse
而 PaymentIntentResponse 又同时带了:
intentIdclientSecretproviderstatusredirectUrl
这让同一个 BFF 返回模型同时兼容:
- Stripe / Klarna 这类可能需要
clientSecret的前端确认流 - PayPal 这类更偏 redirect 的支付流
Stripe 在当前仓库里到底落到了哪一步#
1. 真正落地的是“创建 provider reference”#
PaymentProviderService 的 Stripe 分支现在是这样的:
private WalletApi.PaymentIntentResponse createStripeIntent(WalletApi.CreatePaymentIntentRequest request) {
if (!properties.stripeEnabled()) {
String mockId = "pi_mock_" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
String mockSecret = mockId + "_secret_" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
return new WalletApi.PaymentIntentResponse(mockId, mockSecret, "STRIPE_MOCK", "requires_payment_method", null);
}
StripeGateway.PaymentReference ref = stripeGateway.createDeposit(
request.buyerId(), request.amount(), request.currency());
return new WalletApi.PaymentIntentResponse(ref.providerReference(), null, "STRIPE", "requires_confirmation", null);
}
而真实 Stripe 网关内部调用的是 Stripe Java SDK:
PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
.setAmount(toMinorUnits(amount))
.setCurrency(currency.toLowerCase())
.putMetadata("buyerId", buyerId)
.build();
PaymentIntent intent = PaymentIntent.create(params);
return new PaymentReference(intent.getId(), "STRIPE");
2. 一个需要明确写出来的边界#
当前实现里:
- mock Stripe 会返回
clientSecret - 真实 Stripe 只返回
intentId
所以更准确的判断应该是:
仓库已经完成了 Stripe PaymentIntent 创建入口与 provider 抽象,但还没有把真实 Stripe 的
client_secret回传和前端确认流程真正接通。
这也是为什么这篇文章应该叫“接入基线”,而不是“完整 Stripe 支付闭环”。
Deposit / Wallet Payment / Refund 三条链路的真实差别#
Deposit / Withdraw#
WalletApplicationService.deposit() / withdraw() 当前通过 StripeGateway 创建 provider reference,然后直接更新本地钱包余额或流水:
StripeGateway.PaymentReference paymentReference =
stripeGateway.createDeposit(request.buyerId(), request.amount(), request.currency());
account.credit(request.amount());
transactionRepository.save(new WalletTransactionEntity(..., paymentReference.providerReference()));
这更像是支付接入基线或 POC:本地账务和外部支付确认还没有通过 webhook / capture / settlement 事件真正解耦。用于技术演示是成立的,但如果要走生产化资金确认,通常还要再补一层异步确认。
Wallet 余额支付#
WalletApi.PAYMENT_CREATE 是另一条完全不同的路径:它不走 Stripe,而是直接扣减内部钱包余额并写 ORDER_PAYMENT 流水。这条链路对“余额支付”这个用例来说是自洽的。
Refund#
当前 /wallet/v1/payment/refund 也不是 Stripe Refund API,而是把金额退回内部钱包余额:
@Transactional
public WalletApi.TransactionResponse refundOrder(WalletApi.CreateRefundRequest request) {
WalletAccountEntity account = accountRepository.findById(request.buyerId())
.orElseGet(() -> accountRepository.save(new WalletAccountEntity(request.buyerId(), BigDecimal.ZERO)));
account.credit(request.amount());
transactionRepository.save(new WalletTransactionEntity(
request.buyerId(), "ORDER_REFUND", request.amount(), request.currency(), "COMPLETED", "WALLET",
request.referenceId(), request.referenceType()));
}
所以文章里如果写成“buyer-bff 触发 wallet-service 调用 Stripe Refund API”就是不准确的。现在更合理的表述是:
当前仓库已经有 Saga 补偿入口,但补偿动作落在内部钱包记账,还没有延伸到 Stripe 原生退款。
为什么现在这套抽象仍然值得保留#
虽然还没到完整支付闭环,但当前抽象至少先把后续扩展里最难回头改的边界抽出来了:
1. BFF 不直接绑死某个支付 SDK#
buyer-bff 只依赖 WalletApi,不会在聚合层直接知道 Stripe / PayPal / Klarna 的 SDK 细节。
2. 返回模型已经为多 provider 预留了差异#
PaymentIntentResponse 同时支持:
clientSecretredirectUrlproviderstatus
这比“只返回一个 Stripe client secret”更适合未来继续扩展。
3. Mock / 真实实现可以通过配置切换#
shop:
wallet:
stripe-enabled: ${STRIPE_ENABLED:false}
stripe-secret-key: ${STRIPE_SECRET_KEY:}
stripe-public-key: ${STRIPE_PUBLIC_KEY:}
paypal-enabled: ${PAYPAL_ENABLED:false}
klarna-enabled: ${KLARNA_ENABLED:false}
这让本地开发、演示环境和真实 provider 接入可以逐步演进,而不是一开始就把所有环境都绑到外部支付。
当前仓库里还没有的能力#
下面这些能力很重要,但应该明确标为后续演进方向:
1. Stripe Payment Element / Elements 前端确认链路#
如果后续要真正完成卡支付前端确认,通常会接 Stripe 的 Payment Element 或 Embedded Checkout。那时真实 client_secret 回传才会有完整闭环。
2. Webhook 验签与支付状态回写#
当前仓库里没有看到 Stripe webhook handler,也没有看到 payment_intent.succeeded / payment_intent.payment_failed 对订单状态的回写逻辑。如果要继续往生产化方向走,这通常是很难绕开的补充层。
3. 外部支付的幂等与退款#
wallet-service 已经有 Idempotency-Key 头和本地 WalletIdempotencyKeyRepository,但这条能力现在主要用于 deposit 接口;如果后续扩展到 Stripe refund / capture / webhook 消费,我会继续沿用“本地幂等 + provider 幂等键”的思路。
4. 多币种最小单位换算#
当前 Stripe 网关的 toMinorUnits() 是简单的 movePointRight(2)。这对 USD 没问题,但对 zero-decimal currencies 并不通用。后续如果要扩多币种,需要按 Stripe 货币规则单独处理。
更稳妥的演进顺序#
如果按 2026 年常见的支付演进路径来排优先级,我自己会先按这个顺序补:
- 先补真实 Stripe
client_secret回传 + Payment Element - 再补 webhook 验签和订单 paid / failed 回写
- 然后把 refund 扩展到 provider-native refund
- 最后再把更多 provider 的 capture / cancel / settlement 语义统一起来
这样通常更有助于避免“抽象先做太满,但真实状态机没闭环”的问题。
参考与实现位置#
- Stripe Payment Intents:https://docs.stripe.com/payments/payment-intents
- Stripe Payment Element:https://docs.stripe.com/payments/payment-element
- Stripe Webhooks:https://docs.stripe.com/webhooks
- Stripe Idempotent Requests:https://docs.stripe.com/api/idempotent_requests
- Stripe Java SDK:https://stripe.com/docs/api?lang=java
- Stripe Zero-decimal currencies:https://docs.stripe.com/currencies#zero-decimal
- PayPal Orders API:https://developer.paypal.com/docs/api/orders/v2/
- Klarna Payments API:https://docs.klarna.com/api/payments/
- 仓库实现入口:
services/wallet-service/src/main/java/dev/meirong/shop/wallet/service/PaymentProviderService.java、services/wallet-service/src/main/java/dev/meirong/shop/wallet/service/StripePaymentGateway.java、services/wallet-service/src/main/java/dev/meirong/shop/wallet/service/MockPaymentGateway.java、services/wallet-service/src/main/java/dev/meirong/shop/wallet/service/WalletApplicationService.java、services/wallet-service/src/main/java/dev/meirong/shop/wallet/controller/WalletController.java、shared/shop-contracts/shop-contracts-wallet/src/main/java/dev/meirong/shop/contracts/wallet/WalletApi.java、services/buyer-bff/src/main/java/dev/meirong/shop/buyerbff/service/BuyerAggregationService.java
小结#
Shop Platform 当前的支付实现,更准确的定位是:
- 已经落地:统一支付合约、provider routing、Stripe/PayPal/Klarna/Wallet 方式矩阵、Mock/真实网关切换
- Stripe 现状:真实网关已能创建 PaymentIntent / provider reference,但真实
clientSecret闭环还没接完 - 退款现状:当前补偿动作是内部钱包退款,不是 Stripe Refund API
- 架构价值:BFF 不直接依赖支付 SDK,后续扩展 provider 或补 webhook 时改动面更可控
- 后续方向:Payment Element、webhook 验签、外部退款幂等、多币种最小单位规则