让 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 协议扩展层面直接完成协议握手。
这个差异决定了后面所有架构决策。
最早控制器里塞满了字段映射:
# ❌ 早期写法
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] }。人写这种代码容易"先跑通再说",字段就散落控制器里出不来了。
b2f0333 我第一次让 Claude 写 x402 集成,它手摇了三个类:
X402::PaymentHandler — build 402 requirements、解 PAYMENT-SIGNATURE headerX402::FacilitatorClient — 包 x402.org/facilitator 的 /verify + /settleapp/controllers/concerns/content_gate.rb — 检测 402 header 返回 PAYMENT-REQUIRED449 行代码,能跑,测试也过。
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 就是你的学习材料。
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)
})
// ...
}
值得注意的两点:
eth_requestAccounts 的结果,不用 selectedAddress。selectedAddress 已 deprecated,大部分钱包返回的是过时值。Claude 写第一版的时候用了 selectedAddress(按 MDN 文档),我改成前者。然后还要做一件事:错误码枚举化。钱包拒绝签名是 4001,链不对要切是 CHAIN_SWITCH,不付款要 402 重定向是 PAYMENT_REQUIRED。不要靠 error.message 做字符串匹配——不同钱包消息不一样,测试写不出来。
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 响应回来了,然后问自己"那为什么没跳"。
装完 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 被悄悄丢掉。
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 接支付,完整清单:
inclusion: { in: %w[stripe x402] } 做类型闸。Rails.env.production? 翻。data-turbo=false。否则 302 到 cross-origin 被 Turbo 静默吞掉。bin/importmap json 验证。importmap 会静默丢 vendor 文件缺失的 pin。0x... / true / 07 这类 YAML 会特殊解析。让 Claude 写支付的真正难点不在协议本身,在协议边界处的集成(Turbo 和 Stripe 的冲突、importmap 和 gem 的冲突、YAML 和钱包地址的冲突)。这些是你必须亲自坐在那盯的地方。