免费

让 Claude 接两种支付:Stripe + x402

让 Claude 写两种完全不同协议的支付集成(Stripe 托管 + x402 链上钱包),踩了三个静默失败的坑,跑通一套同时跑两条支付线的架构。


最近给 how2claude 的 Pro 套餐同时接了 Stripe(信用卡/法币)和 x402(EVM 链上 USDC)。让 Claude 写两种完全不同协议的支付集成——一个托管 Checkout + webhook,另一个 HTTP 402 + 浏览器钱包——花了一整个晚上的 session。踩了三个静默失败的坑,也跑通了一套可以同时跑两条支付线的架构。

这篇是实战记录,不是"如何接 Stripe"教程——那种文档满地都是。关注点在:两套协议该如何各安其位、Claude 写哪些东西最容易翻车、哪些地方必须你亲自盯着。


两种支付范式

维度 Stripe x402
触发方式 button_to → 重定向到 checkout.stripe.com POST /x402/subscribe → 返回 HTTP 402
用户动作 在 Stripe 托管页输入卡号 浏览器钱包签名
结果回传 webhook (checkout.session.completed) 请求重试带 X-PAYMENT header,gem 同步结算
我要持久化的数据 payment_intent_id + amount_total tx_hash + payer + amount
协议复杂度 SDK 一把梭 需要 viem + x402-fetch 协议握手

本质不同:Stripe 把用户推走去它的页面,你只需要在用户回来时核对 webhook;x402 从头到尾在你自己的域下跑,HTTP 协议扩展层面直接完成协议握手。

这个差异决定了后面所有架构决策。

控制器薄下来:record 方法抽到模型层

最早控制器里塞满了字段映射:

# ❌ 早期写法
def subscribe_via_stripe
  session = Stripe::Checkout::Session.retrieve(params[:session_id])
  Subscription.create!(
    user: current_user,
    provider: "stripe",
    stripe_subscription_id: session.subscription,
    # ... 十多行 field mapping
  )
end

两种支付都要持久化 Purchase + Subscription,但字段完全不同。字段映射塞在控制器里,每种支付都要抄一遍映射逻辑。

迁移(9f3e239)把这部分抽到了模型:

class Purchase < ApplicationRecord
  validates :provider, presence: true, inclusion: { in: %w[stripe x402] }

  def self.record_x402!(article:, user:, payment:, settlement:)
    create!(
      article: article,
      user: user,
      provider: "x402",
      wallet_address: payment[:payer],
      amount_cents: article.price_cents,
      tx_hash: settlement.transaction,
      purchased_at: Time.current
    )
  end

  def self.record_stripe!(session:, user:)
    create!(
      article_id: session.metadata.article_id,
      user: user,
      provider: "stripe",
      amount_cents: session.amount_total,
      stripe_payment_intent_id: session.payment_intent,
      purchased_at: Time.current
    )
  end
end

四个方法:Purchase.record_x402! / record_stripe! / Subscription.record_x402! / record_stripe!。控制器变成一行:

Purchase.record_x402!(article:, user:, payment:, settlement:)

让 Claude 干这件事特别合适:它会老老实实逐个字段映射、加测试、加 validates :provider, inclusion: { in: %w[stripe x402] }。人写这种代码容易"先跑通再说",字段就散落控制器里出不来了。

节奏:手摇先过,再迁到 gem

b2f0333 我第一次让 Claude 写 x402 集成,它手摇了三个类:

  • X402::PaymentHandler — build 402 requirements、解 PAYMENT-SIGNATURE header
  • X402::FacilitatorClient — 包 x402.org/facilitator/verify + /settle
  • app/controllers/concerns/content_gate.rb — 检测 402 header 返回 PAYMENT-REQUIRED

449 行代码,能跑,测试也过。

6 小时后(9f3e239)我让它全换成 x402-rails gem(v1 协议 + 非 optimistic 模式),把那三个类删掉,控制器改用 x402_paywall(amount:) DSL,从 request.env["x402.payment"]request.env["x402.settlement_result"] 读数据。

节奏有讲究:手摇先让你理解协议,gem 后让你解放出来。如果一上来就装 gem,Claude 会按 gem 文档写代码,但你不知道 402 header 里到底装了什么、facilitator 的 /settle 在干啥。等到出问题(总会出问题),你没底气调试。

这个模式适用所有新协议/新服务:让 Claude 手摇一遍,过完测试后再让它换 gem。前后对比 diff 就是你的学习材料。

运行时按 Rails.env 切链,不要部署时手切

x402 initializer(config/initializers/x402.rb)直接写死规则:

X402.configure do |config|
  config.wallet_address = Rails.application.credentials.dig(:x402, :wallet_address)
  config.facilitator = Rails.application.credentials.dig(:x402, :facilitator_url) ||
                       "https://facilitator.payai.network"
  # Production → Base mainnet (real USDC). Dev/test → Base Sepolia (free testnet USDC).
  config.chain = Rails.env.production? ? "base" : "base-sepolia"
  config.currency = "USDC"
  config.version = 1
  config.optimistic = false  # 等 facilitator settle 返回再继续,才能同步拿到 tx_hash
end

同一份代码 dev 跑 base-sepolia(免费测试币),prod 跑 base mainnet。部署时不需要改任何东西。(这个原则前一篇让 Claude 做生产部署里讲过——凡是 dev 和 prod 不一样的配置,全部用 Rails.env 翻。)

optimistic = false 这一行很关键:gem 默认 optimistic 模式是先放行、后对账;我们关掉它是因为想在 action 返回前拿到 settlement_result.transaction(tx_hash),同步写进 Purchase 表。拿不到 tx_hash 的 Purchase 记录对用户无价值——他要能点开 BaseScan 看交易。

前端:一边托管,一边自己造轮子

Stripe 侧的"前端"是一行:

<%= button_to stripe_checkouts_subscription_path(plan: plan.key),
      class: "...",
      form: { class: "w-full", data: { turbo: false } } do %>
  <%= t("pricing.subscribe") %>
<% end %>

用户一点,浏览器跳到 checkout.stripe.com。你 0 前端代码。

x402 侧(93746d8)我让 Claude 写了个 Stimulus controller:

// app/javascript/controllers/x402_payment_controller.js
async pay() {
  // 懒加载,不塞 vendor
  const viem = await import("https://esm.run/viem@2")
  const { wrapFetchWithPayment } = await import("https://esm.run/[email protected]")

  const [account] = await window.ethereum.request({ method: "eth_requestAccounts" })
  const walletClient = viem.createWalletClient({ account, transport: viem.custom(window.ethereum) })
  const fetchWithPayment = wrapFetchWithPayment(fetch, walletClient)

  const res = await fetchWithPayment(this.endpointValue, {
    method: "POST",
    headers: { "Accept": "application/json" },
    body: new URLSearchParams(this.paramsValue)
  })
  // ...
}

值得注意的两点:

  1. 懒加载 viem + x402-fetch(第一次点按钮时才从 jsdelivr 拉包)。这两个包加起来很大,塞进 vendor 会让所有不付款的用户都下载。懒加载把它变成"想付款再下"。
  2. eth_requestAccounts 的结果,不用 selectedAddressselectedAddress 已 deprecated,大部分钱包返回的是过时值。Claude 写第一版的时候用了 selectedAddress(按 MDN 文档),我改成前者。

然后还要做一件事:错误码枚举化。钱包拒绝签名是 4001,链不对要切是 CHAIN_SWITCH,不付款要 402 重定向是 PAYMENT_REQUIRED。不要靠 error.message 做字符串匹配——不同钱包消息不一样,测试写不出来。

踩坑 #1:button_to + Turbo 静默吞掉 Stripe 302

527f700 这个 commit 是我盯着浏览器半小时才发现的。

症状:点 /pricing 的 Subscribe 按钮,页面啥都没发生。没有控制台错误,没有网络错误,Rails 日志显示 200 返回了 302 → checkout.stripe.com/c/pay/cs_xxx。但浏览器压根没跳转。

原因:button_to 会生成一个 <form method="post">,Turbo 会拦截这个 form 的提交,把响应当成 TURBO_STREAM 处理。TURBO_STREAM 不跟 cross-origin 的 302。响应默默被 Turbo 吞了,页面纹丝不动。

修复:

 <%= button_to stripe_checkouts_subscription_path(plan: plan.key),
       class: "...",
-      form: { class: "w-full" } do %>
+      form: { class: "w-full", data: { turbo: false } } do %>

涉及三个按钮:/pricing 的 Subscribe、/pricing 上"当前套餐"卡片里的 Manage(跳到 billing.stripe.com)、/accounts 的 Manage Subscription。给每个都加了 data-turbo=false,每个加了回归测试。

让 Claude 先调试这个问题的时候它怀疑过三个方向:Stripe 配置错(不是)、redirect_uri 白名单(不是)、CORS(错的方向)。Turbo 和 Stripe 的冲突不在 Stripe 文档里,也不在 Turbo 文档里——Claude 的训练数据里也几乎没有。这种坑你只能靠 network tab 看到 302 响应回来了,然后问自己"那为什么没跳"。

踩坑 #2:Failed to resolve module specifier 'x402-fetch'

装完 x402-rails gem 后前端控制台:

Uncaught TypeError: Failed to resolve module specifier 'x402-fetch'.

但我明明懒加载了 await import("https://esm.run/[email protected]") 啊——完整 URL,为什么会 "resolve module specifier" 失败?

根因:x402-rails gem 自己带了一个 Stimulus controller 依赖 @hotwired/stimulus。我 config/importmap.rb 里 pin 了这个包,但对应的 vendor 文件 vendor/javascript/@hotwired--stimulus.js 根本没下载。importmap 发现文件不存在,就静默把这个 pin 从生成的 importmap 里丢掉了。失败的不是我的 x402-fetch,是 gem 的 Stimulus 控制器。错误冒泡到最近的 import。

诊断:bin/importmap json 输出实际生成的 importmap,跟 config/importmap.rb 对比——如果有 pin 没出现在 json 里,对应 vendor 文件没下载。

修复:bin/importmap pin @hotwired/stimulus 重跑一遍,把文件真正拉下来。

Claude 写 gem 集成代码的时候不会主动 bin/importmap json 做健全性检查。这种事只能人看。如果你用 importmap,装任何带 Stimulus 控制器的 gem 之后,先跑一次 bin/importmap json 确认没有 pin 被悄悄丢掉。

踩坑 #3:YAML 把 0x... 钱包地址当整数

credentials 里存钱包地址:

x402:
  wallet_address: 0x1234abcd...

Rails 加载时,YAML 把 0x1234abcd... 解析成了整数(hex literal)。等 X402.configure 拿到这个值时,类型已经错了,gem 内部拼 paywall requirement 的时候生成奇怪的结构。

修复一个字符:加引号。

x402:
  wallet_address: "0x1234abcd..."

Claude 写 credentials 模板时没加引号——它的训练数据里 YAML 示例基本都是裸字符串。只有前缀恰好是 0x / true / false / 纯数字的时候才出问题。这类"YAML 特殊解析"的坑只有你填真值进去才会触发。

为什么一个应用要接两种支付

Stripe 覆盖 99% 的用户——信用卡/Apple Pay/Google Pay,月付 $9.99 那种场景体验最顺。

x402 覆盖剩下 1% 但重要的人:加密用户、想用稳定币的国际用户、写自动化 agent 的开发者(他们的 agent 需要能自己付钱访问付费 API——402 协议就是为此设计的)。

关键决策:月付套餐不接 x402。$9.99/月让人每月开钱包签一次字,体验烂。我们只在年付 $99 上开 x402,摩擦成本摊薄到一次/年。

<% if plan.interval == "year" %>
  <%= render "shared/x402_pay_button", ... %>
<% end %>

_plan_card.html.erb 里一行 if,决定了哪些卡片显示 USDC 付款按钮。就这么简单。


让 Claude 接支付,完整清单:

  1. 两种协议分开理解再让它写。Stripe 走 hosted Checkout + webhook,x402 走 HTTP 402 + 浏览器钱包——别指望 Claude 自己能分得清。
  2. record 方法放模型层。控制器只调一行,所有 field mapping 在模型里。加 inclusion: { in: %w[stripe x402] } 做类型闸。
  3. 新协议先手摇再迁 gem。前后 diff 就是你的学习材料。
  4. 运行时按 Rails.env 切链/切 mode。Stripe test/live、x402 base-sepolia/base,全部用 Rails.env.production? 翻。
  5. 所有 Stripe button_to 加 data-turbo=false。否则 302 到 cross-origin 被 Turbo 静默吞掉。
  6. 装任何带 Stimulus 控制器的 gem,跑 bin/importmap json 验证。importmap 会静默丢 vendor 文件缺失的 pin。
  7. 所有看起来像数字前缀的 credentials 加引号0x... / true / 07 这类 YAML 会特殊解析。

让 Claude 写支付的真正难点不在协议本身,在协议边界处的集成(Turbo 和 Stripe 的冲突、importmap 和 gem 的冲突、YAML 和钱包地址的冲突)。这些是你必须亲自坐在那盯的地方。