📦 本文基于的完整项目源码:https://github.com/meirongdev/shop

电商系统接入支付时,第一步通常不是“把所有 webhook 和退款补偿一次做完”,而是先把支付入口、provider 边界和返回模型抽清楚。Shop Platform 当前已经完成的是这条基线:buyer-bff 统一调用 wallet-servicewallet-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 前端确认链路真正提交进来。所以这篇文章也应该按“已落地基线 + 明确演进方向”来写,而不是把未来设计写成现状。


当前支付架构#

flowchart TD Client["客户端 / SSR Portal"] BFF["buyer-bff"] Wallet["wallet-service"] Provider["PaymentProviderService"] Stripe["StripeGateway"] Paypal["PayPal Orders API"] Klarna["Klarna Sessions API"] Internal["WalletApplicationService"] Client -->|"checkout / payment intent"| BFF BFF -->|"WalletApi"| Wallet Wallet --> Provider Provider --> Stripe Provider --> Paypal Provider --> Klarna Wallet -->|"wallet balance pay / refund"| Internal

这里最重要的设计点不是“只支持 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();
}

这段实现里有两个我觉得比较顺手的地方:

  1. Wallet 余额支付直接走内部记账
  2. 外部支付方式统一收敛成 PaymentIntentResponse

PaymentIntentResponse 又同时带了:

  • intentId
  • clientSecret
  • provider
  • status
  • redirectUrl

这让同一个 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 同时支持:

  • clientSecret
  • redirectUrl
  • provider
  • status

这比“只返回一个 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 年常见的支付演进路径来排优先级,我自己会先按这个顺序补:

  1. 先补真实 Stripe client_secret 回传 + Payment Element
  2. 再补 webhook 验签和订单 paid / failed 回写
  3. 然后把 refund 扩展到 provider-native refund
  4. 最后再把更多 provider 的 capture / cancel / settlement 语义统一起来

这样通常更有助于避免“抽象先做太满,但真实状态机没闭环”的问题。


参考与实现位置#


小结#

Shop Platform 当前的支付实现,更准确的定位是:

  • 已经落地:统一支付合约、provider routing、Stripe/PayPal/Klarna/Wallet 方式矩阵、Mock/真实网关切换
  • Stripe 现状:真实网关已能创建 PaymentIntent / provider reference,但真实 clientSecret 闭环还没接完
  • 退款现状:当前补偿动作是内部钱包退款,不是 Stripe Refund API
  • 架构价值:BFF 不直接依赖支付 SDK,后续扩展 provider 或补 webhook 时改动面更可控
  • 后续方向:Payment Element、webhook 验签、外部退款幂等、多币种最小单位规则

项目仓库:github.com/meirongdev/shop