Stripe 支付接入全攻略:从零到一,前后端完整实战(2025 最新版)
本文基于 Stripe 官方 2025 年最新推荐方案编写,涵盖两种主流接入方式:
- 方案 A:Checkout Sessions(官方推荐)------Stripe 托管支付页面,几行代码搞定,适合快速上线
- 方案 B:Payment Element + PaymentIntent(自定义方案)------完全控制 UI,适合深度定制
两种方案都会讲解:单次支付、保存信用卡、后续扣款、订阅支付、退款、Apple Pay / Google Pay 检测等完整场景。读完即可上手,少走弯路。
写在前面
你有没有过这样的经历:
- 想给自己的项目加上支付功能,搜了一圈发现文档又臭又长
- 听说 Stripe 是"全球最好用的支付平台",但一看全英文文档一脸懵
- 照着教程做,代码跑起来了,但完全不理解为什么这么写
- 最大的坑:很多教程还在用旧版的 Card Element,Stripe 早就推荐用 Payment Element 了
这篇文章会帮你避开所有这些坑。
我会告诉你每一步背后的原理是什么,为什么要这么做。不是复制粘贴水文,而是让你真正理解 Stripe 支付的运作方式。
本文涵盖的内容
| 功能 | 说明 |
|---|---|
| ✅ 单次支付 | 用户付款买东西(一次性) |
| ✅ 支付时保存卡片 | 用户支付时勾选"记住我的卡" |
| ✅ 单独保存卡片 | 不扣款,只绑卡(类似支付宝绑卡) |
| ✅ 用已保存的卡扣款 | 后端用保存的卡发起扣款(用户不在线也行) |
| ✅ 订阅支付 | 按月/按年自动扣款 |
| ✅ 退款 | 全额/部分退款 |
| ✅ Webhook | 服务端异步接收支付结果 |
一、Stripe 是什么?为什么选它?
1.1 一句话解释
Stripe 就是一个"支付中间商"------你不用自己去对接各大银行、信用卡组织,只要调用 Stripe 的 API,它帮你搞定一切。
打个比方:
你开了一家网店,要收钱。你可以自己去跟 Visa、Mastercard、银联......一家一家谈合作(累死),也可以找个"支付服务商"(比如 Stripe),你只需要跟它对接,它帮你处理所有银行卡。
1.2 Stripe vs 国内支付方式
| 对比项 | Stripe | 支付宝/微信支付 |
|---|---|---|
| 接入难度 | ⭐⭐(简单) | ⭐⭐⭐⭐(复杂) |
| 文档质量 | 非常好,有代码示例 | 中文但组织混乱 |
| 支持的支付方式 | 100+ 种(卡、Apple Pay、Google Pay、银行转账等) | 主要国内支付 |
| 国际化 | ✅ 全球 195+ 国家 | ❌ 主要中国 |
| 保存卡片 | 原生支持,合规 | 需要单独对接 |
| 测试环境 | 完善的测试卡号 | 沙箱环境不稳定 |
| 手续费 | 2.9% + $0.30 | 0.6% |
适合用 Stripe 的场景:做海外业务、SaaS 产品、面向国际用户的项目。
二、准备工作:注册和获取密钥
2.1 注册 Stripe 账号
- 打开 https://stripe.com,点击 "Start now" 注册
- 填写邮箱、密码、国家等信息
- 邮箱验证后进入 Dashboard
💡 不用急着填写公司信息! 测试模式下不填也能用。等你真正要上线收款了再填。
2.2 获取 API 密钥
在 Dashboard 左下角找到 "Developers" → "API keys",你会看到两个密钥:
| 密钥 | 格式 | 用途 | 能放哪 |
|---|---|---|---|
| Publishable Key(公钥) | pk_test_... |
前端使用 | 可以公开,放前端代码里 |
| Secret Key(私钥) | sk_test_... |
后端使用 | 绝对不能泄露!只能放后端 |
⚠️ 重要提醒 :
sk_test_开头的是私钥,绝对不能放到前端代码里、不能提交到 Git!建议用.env文件管理。
2.3 测试卡号
开发阶段不需要真卡,Stripe 提供了测试卡号:
| 卡号 | 结果 | 说明 |
|---|---|---|
4242 4242 4242 4242 |
✅ 成功 | 最常用的测试卡 |
4000 0025 0000 3155 |
❌ 需要 3D 验证 | 测试 3D Secure |
4000 0000 0000 9995 |
❌ 余额不足 | 测试失败场景 |
4000 0000 0000 3220 |
✅ 3D 验证可选 | 测试 3DS 可选流程 |
其他字段随便填 :过期日期填未来日期(如 12/34),CVC 填任意 3 位数(如 123),邮编填任意 5 位。
三、核心概念(必须理解!)
在写代码之前,你需要理解 Stripe 的几个核心概念。这一步不能跳过! 不然后面写代码就是"盲人摸象"。
3.1 PaymentIntent(支付意图)
比喻:PaymentIntent 就像一张"欠条"。用户要付 20,你就先在 Stripe 那边创建一张"20 的欠条",然后把这张欠条的 ID 给前端,前端拿着它去问用户要钱。
- 每个 PaymentIntent 有唯一 ID,格式
pi_xxx - 包含金额、货币、状态等信息
- 状态流转 :
requires_payment_method→requires_confirmation→processing→succeeded
3.2 SetupIntent(设置意图)
比喻:SetupIntent 就像"登记卡片信息"。用户把卡号告诉你,但你现在不扣钱------只是"记住这张卡",以后需要的时候再扣。
- 用于保存卡片但不立即扣款的场景
- 比如用户绑定信用卡到账户,后续按需扣费
- 验证卡的有效性,确保卡是可用的
3.3 PaymentMethod(支付方式)
比喻:PaymentMethod 就是"一张卡的信息"。它不包含敏感数据(卡号等),而是一个 Token,代表"用户的一张卡"。
- 创建后会得到
pm_xxx格式的 ID - 可以附加到 Customer 上长期保存
- 保存后可以反复使用,用户不用重新输入卡号
3.4 Customer(客户)
比喻:Customer 就是"你的用户在 Stripe 系统中的档案"。把 PaymentMethod 绑到 Customer 上,就能长期保存。
3.5 Payment Element vs Card Element
这是很多人踩的坑!
| 对比 | Card Element(旧版) | Payment Element(新版 ✅) |
|---|---|---|
| 支持的支付方式 | 只有银行卡 | 100+ 种(卡、Apple Pay、Google Pay 等) |
| UI | 单一卡号输入框 | 自适应多种支付方式 |
| 官方推荐 | ❌ 已不推荐 | ✅ 官方推荐 |
| 未来支持 | 只做 bug 修复 | 持续更新新功能 |
结论:新项目请务必使用 Payment Element!
3.6 整体架构图
下面是完整的支付流程(请仔细看,后面写代码就是按这个流程来的):
用户点击"支付"
│
▼
前端 ──────────► 后端:请求创建 PaymentIntent
│
▼
后端调用 stripe.paymentIntents.create()
得到 client_secret
│
▼
前端 ◄────────── 后端:返回 client_secret
│
▼
前端用 Payment Element 渲染支付表单
用户输入卡号,点击确认
│
▼
Stripe.js 调用 confirmPayment()
│
▼
Stripe 服务器处理支付 ◄─────── 这是 Stripe 干的活
│
├──► 返回结果给前端(同步)
│
└──► 发 Webhook 给后端(异步,更可靠)
为什么需要 Webhook?
因为前端的结果可能被用户关闭页面而丢失。Webhook 是 Stripe 主动通知你后端的机制,更可靠。正式环境必须监听 Webhook!
3.7 Stripe 两种接入方案对比(重要!选前必读)
Stripe 提供两种主流接入方式:Checkout Sessions(Stripe 托管) 和 Payment Element + PaymentIntent(自定义 UI) 。
它们不是"新旧替代"关系,而是定位不同、功能互补的两套方案。下面从各个功能维度详细对比:
📊 总览对比表
| 对比项 | 🅰️ Checkout Sessions | 🅱️ Payment Element |
|---|---|---|
| 谁托管支付页面 | Stripe(跳转到 stripe.com) | 你自己(嵌入你的页面) |
| 前端代码量 | ⭐ 极少(一个按钮 + 跳转) | ⭐⭐⭐ 较多(初始化 + 确认 + 错误处理) |
| 后端代码量 | ⭐⭐ 中等(创建 Session) | ⭐⭐⭐ 较多(创建 PaymentIntent + SetupIntent) |
| UI 自定义程度 | 只能改颜色/Logo/按钮文字 | 完全自定义,想怎么画怎么画 |
| 上线速度 | 🚀 半天搞定 | 🏃 1-2 天 |
| 官方推荐度 | ⭐⭐⭐⭐⭐ 首选推荐 | ⭐⭐⭐⭐ 需要自定义 UI 时使用 |
🔍 逐项功能对比
1️⃣ 单次支付(One-time Payment)
| Checkout Sessions | Payment Element | |
|---|---|---|
| 后端 API | stripe.checkout.sessions.create({ mode: 'payment' }) |
stripe.paymentIntents.create() |
| 前端操作 | window.location.href = session.url 跳转 |
初始化 Element → 用户输入 → confirmPayment() |
| 用户看到什么 | 跳到 Stripe 漂亮的支付页面 | 留在你的页面,支付表单嵌入其中 |
| 支付完成 | 自动跳回你的 success_url |
前端收到结果,你决定显示什么 |
💡 结论:如果只需要简单收款,Checkout Sessions 完胜。
2️⃣ 保存信用卡(Save Card)
| Checkout Sessions | Payment Element | |
|---|---|---|
| 方式一:单独绑卡 | mode: 'setup',跳转到 Stripe 页面输入卡号 |
创建 SetupIntent,用 confirmSetup() 确认 |
| 方式二:支付+保存 | payment_method_data: { save_for_future: true } |
setup_future_usage: 'off_session' |
| 后续使用保存的卡 | 后端用 payment_method ID 创建新的 Session |
后端用 payment_method ID 创建新的 PaymentIntent |
| 管理已保存的卡 | Stripe Dashboard 或 API 列出 | 同左(存储方式相同,都是 Stripe 的 PaymentMethod) |
💡 结论 :两种方案都支持保存卡,但 Checkout Sessions 的
mode: 'setup'更简单。
3️⃣ 订阅支付(Subscription)
| Checkout Sessions | Payment Element | |
|---|---|---|
| 创建订阅 | mode: 'subscription' + line_items 指定价格 |
后端手动 stripe.subscriptions.create(),前端用 PaymentElement 确认 |
| 试用期 | subscription_data: { trial_period_days: 7 } |
后端在 subscription.create 中设置 |
| 换套餐 | 后端调用 subscription.update() |
同左(订阅管理逻辑完全一样) |
| 取消订阅 | 后端调用 subscription.cancel() |
同左 |
| 自动续费 | ✅ Stripe 自动处理 | ✅ Stripe 自动处理 |
| 用户看到订阅详情 | Stripe 托管页面自动展示 | 你需要自己做订阅管理 UI |
💡 结论 :Checkout Sessions 创建订阅更省事(
mode: 'subscription'一行搞定)。
4️⃣ 支付方式支持(Payment Methods)
| Checkout Sessions | Payment Element | |
|---|---|---|
| 信用卡/借记卡 | ✅ 自动支持 | ✅ 自动支持 |
| Apple Pay / Google Pay | ✅ 自动检测并显示按钮 | 需额外接入 Express Checkout Element |
| 银行转账/ACH | ✅ 自动支持(如已启用) | ✅ 自动支持(通过 PaymentElement) |
| 支付宝/微信 | ✅ 自动支持(如已启用) | ✅ 自动支持 |
| 新增支付方式 | 在 Dashboard 开启即可,无需改代码 | 同左 |
| 多支付方式共存 | 自动在一个页面展示所有可用方式 | 需要自己处理 UI 布局 |
💡 结论:Checkout Sessions 对多支付方式更"开箱即用",Apple/Google Pay 自动出现。
5️⃣ 支付页面外观 & 用户体验
| Checkout Sessions | Payment Element | |
|---|---|---|
| 页面域名 | checkout.stripe.com(跳转离开你的网站) |
你自己的域名(无跳转) |
| 自定义 Logo | ✅ 在 Dashboard 或 API 设置 | 你自己画 |
| 自定义颜色主题 | ✅ 支持品牌色 + 按钮样式 | 完全自定义 |
| 自定义布局 | ❌ 固定布局,不能改 | ✅ 随你怎么布局 |
| 自定义文案 | ❌ 不能改按钮/字段文字 | ✅ 完全控制 |
| 多语言 | ✅ 自动检测浏览器语言 | 你自己做国际化 |
| 品牌一致性 | ⚠️ 用户感知到"跳到了 Stripe" | ✅ 用户感觉一直在你的网站 |
💡 结论:品牌形象重要(如奢侈品电商)→ 方案 B;只管收款 → 方案 A 页面也很好看。
6️⃣ 折扣码 / 优惠券 / 运费
| Checkout Sessions | Payment Element | |
|---|---|---|
| 折扣码(Coupon) | ✅ discounts: [{ coupon: 'xxx' }] |
需自己算折扣金额,改 PaymentIntent 的 amount |
| 用户输入折扣码 | ✅ allow_promotion_codes: true 自动显示输入框 |
❌ 需自己做整个折扣码 UI + 验证逻辑 |
| 运费计算 | ✅ shipping_options 数组 |
需自己做运费选择 UI |
| 订单摘要展示 | ✅ 自动展示商品名、数量、单价、小计 | 需自己做订单摘要 UI |
💡 结论:需要折扣码和运费?Checkout Sessions 内置了这些功能,省去大量开发。
7️⃣ 安全 & 合规
| Checkout Sessions | Payment Element | |
|---|---|---|
| PCI 合规 | ✅ 由 Stripe 完全处理,你的 PCI 责任最低 | ✅ Element 是 iframe,同样不触碰卡号数据 |
| 3D Secure 验证 | ✅ Stripe 自动处理 | ✅ Stripe 自动处理 |
| 卡号是否经过你的服务器 | ❌ 不经过(直接到 Stripe) | ❌ 不经过(Element 是 Stripe 的 iframe) |
| 防欺诈(Radar) | ✅ 内置 | ✅ 内置 |
💡 结论:两种方案安全性相同,都很安全。卡号都不会经过你的服务器。
8️⃣ Webhook 事件
| Checkout Sessions | Payment Element | |
|---|---|---|
| 必须监听的事件 | checkout.session.completed |
payment_intent.succeeded |
| 额外事件 | checkout.session.expired(未支付过期) |
payment_intent.payment_failed(支付失败) |
| 数据获取方式 | Session 对象包含完整支付信息 | 需要从 PaymentIntent 关联获取 |
💡 结论:Checkout Sessions 的 Webhook 更简洁,一个事件搞定。
9️⃣ 开发者体验
| Checkout Sessions | Payment Element | |
|---|---|---|
| 学习曲线 | 🟢 低(看一遍就会) | 🟡 中(需理解 Element 生命周期) |
| 调试难度 | 🟢 低(Session 状态清晰) | 🟡 中(前后端都可能出错) |
| 灵活性 | 🔴 有限(只能在 Stripe 规则内操作) | 🟢 极高(完全控制每一步) |
| 错误处理 | Stripe 页面自动处理 | 需要自己处理各种错误场景 |
| React/Vue 组件化 | 不需要(跳转式) | 需要(@stripe/react-stripe-js) |
🎯 怎么选?一张图搞定
你的需求是什么?
│
├─ 快速上线,少写代码 ──────────► ✅ 方案 A(Checkout Sessions)
├─ 需要折扣码、运费计算 ────────► ✅ 方案 A
├─ 不想处理复杂的支付 UI ───────► ✅ 方案 A
├─ 订阅支付 ────────────────────► ✅ 方案 A(更简单)或 方案 B(更灵活)
│
├─ 支付页面要和网站风格一致 ────► ✅ 方案 B(Payment Element)
├─ 需要完全自定义支付流程 ──────► ✅ 方案 B
├─ 支付表单和用户信息一起收集 ─► ✅ 方案 B
├─ 多步骤结账流程 ──────────────► ✅ 方案 B
│
└─ 不确定? ────────────────────► ✅ 先用方案 A 上线,后续需要再迁移到方案 B
💡 实战建议:
- 新手 / MVP / 快速验证 → 选方案 A,半天上线
- 电商网站 / 需要折扣码和运费 → 选方案 A,内置功能齐全
- SaaS 产品 / 品牌要求高 → 选方案 B,完全控制体验
- 两种方案可以同时使用! 比如普通支付用方案 A,VIP 会员续费用方案 B
下面两章会分别讲解两种方案的完整实现。你可以只看自己需要的那种,也可以都学。
四、方案 A ------ Checkout Sessions(官方首选推荐)
Checkout Sessions 是 Stripe 官方首推的接入方式! 前端只需要一个按钮 + 一个跳转,支付页面完全由 Stripe 托管。你不需要写任何支付表单 UI。
4.1 Checkout Sessions 是怎么工作的?
用户点击"购买"
│
▼
前端 ──────► 后端:POST /api/checkout/create-session
│
▼
后端调用 stripe.checkout.sessions.create()
生成一个 Stripe 托管的支付页面 URL
│
▼
前端 ◄────── 后端:返回 session.url
│
▼
前端跳转到 Stripe 的支付页面(window.location.href = session.url)
│
▼
用户在 Stripe 页面上完成支付
│
├──► Stripe 跳回你的网站(success_url)
│
└──► Stripe 发 Webhook 通知你的后端
看到没?前端几乎不用写代码! 支付页面、表单验证、错误处理......全部由 Stripe 帮你搞定。
4.2 后端实现
javascript
// checkout-sessions.js
const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const router = express.Router();
// ==========================================
// 1️⃣ 单次支付 ------ 创建 Checkout Session
// ==========================================
router.post('/checkout/create-session', async (req, res) => {
try {
const { amount, currency = 'usd', productName = '商品名称' } = req.body;
// 创建 Checkout Session
// Stripe 会自动生成一个托管支付页面
const session = await stripe.checkout.sessions.create({
// 支付的商品信息
line_items: [{
price_data: {
currency: currency,
product_data: {
name: productName,
description: '感谢购买我们的产品!',
},
// 金额单位是"分"!$20.00 = 2000
unit_amount: Math.round(amount * 100),
},
quantity: 1,
}],
mode: 'payment', // ← 单次支付模式
// 支付成功后跳回你的网站
success_url: 'http://localhost:3000/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost:3000/cancel',
// 可选:自动收取客户信息
// customer_email: 'customer@example.com', // 预填邮箱
// 可选:传入你的内部订单号
// client_reference_id: 'order_12345',
// 可选:启用自动税计算(需要在 Dashboard 开启)
// automatic_tax: { enabled: true },
// 可选:允许优惠码
// allow_promotion_codes: true,
});
// 返回 Session URL,前端直接跳转
res.json({ url: session.url });
} catch (error) {
console.error('创建 Checkout Session 失败:', error);
res.status(500).json({ error: error.message });
}
});
// ==========================================
// 2️⃣ 支付时顺便保存卡片
// ==========================================
router.post('/checkout/create-session-save-card', async (req, res) => {
try {
const { amount, currency = 'usd', productName = '商品名称' } = req.body;
// 先创建一个 Customer(如果还没有的话)
// 实际项目中,你应该先检查用户是否已有 Customer
const customer = await stripe.customers.create({
email: req.body.email || 'customer@example.com',
name: req.body.name || 'Customer',
});
const session = await stripe.checkout.sessions.create({
customer: customer.id, // ← 绑定客户
line_items: [{
price_data: {
currency: currency,
product_data: { name: productName },
unit_amount: Math.round(amount * 100),
},
quantity: 1,
}],
mode: 'payment',
// ⭐ 关键参数!支付完成后保存卡片
// 'off_session' = 后端可以随时用这张卡扣款(用户不在线也行)
// 'on_session' = 用户下次在线时可以选择这张卡
payment_method_options: {
card: {
setup_future_usage: 'off_session', // ← 保存卡片供后续使用
},
},
success_url: 'http://localhost:3000/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost:3000/cancel',
});
res.json({ url: session.url });
} catch (error) {
console.error('创建 Session 失败:', error);
res.status(500).json({ error: error.message });
}
});
// ==========================================
// 3️⃣ 单独绑卡(不扣款)------ Setup 模式
// ==========================================
router.post('/checkout/setup-card', async (req, res) => {
try {
// 先创建或获取已有的 Customer
const customer = await stripe.customers.create({
email: req.body.email || 'customer@example.com',
});
// mode: 'setup' = 只绑卡,不扣钱
const session = await stripe.checkout.sessions.create({
customer: customer.id,
mode: 'setup', // ← 绑卡模式(不扣款)
// 指定要保存的支付方式类型
payment_method_types: ['card'],
success_url: 'http://localhost:3000/setup-success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost:3000/cancel',
});
res.json({ url: session.url });
} catch (error) {
console.error('创建 Setup Session 失败:', error);
res.status(500).json({ error: error.message });
}
});
// ==========================================
// 4️⃣ 订阅支付 ------ 按月/按年自动扣款
// ==========================================
router.post('/checkout/create-subscription', async (req, res) => {
try {
const { priceId, customerId } = req.body;
// priceId 是在 Stripe Dashboard 创建的 Price ID(如 price_1Abc2Def...)
const session = await stripe.checkout.sessions.create({
customer: customerId, // 可选:已有客户
mode: 'subscription', // ← 订阅模式
line_items: [{
price: priceId, // ← 使用 Dashboard 中创建的 Price
quantity: 1,
}],
// 订阅成功后跳转
success_url: 'http://localhost:3000/success?session_id={CHECKOUT_SESSION_ID}',
cancel_url: 'http://localhost:3000/cancel',
// 可选:允许用户切换月付/年付
// subscription_data: {
// trial_period_days: 7, // 7天免费试用
// },
});
res.json({ url: session.url });
} catch (error) {
console.error('创建订阅 Session 失败:', error);
res.status(500).json({ error: error.message });
}
});
// ==========================================
// 5️⃣ 查询已保存的卡片列表
// ==========================================
router.get('/customers/:customerId/payment-methods', async (req, res) => {
try {
const paymentMethods = await stripe.paymentMethods.list({
customer: req.params.customerId,
type: 'card',
});
res.json(paymentMethods.data);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// ==========================================
// 6️⃣ 用已保存的卡片扣款(用户不在线)
// ==========================================
router.post('/charge-saved-card', async (req, res) => {
try {
const { paymentMethodId, amount, currency = 'usd' } = req.body;
// 用保存的 PaymentMethod 创建 PaymentIntent
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100),
currency: currency,
payment_method: paymentMethodId, // ← 使用保存的卡片
off_session: true, // ← 声明:用户不在线
confirm: true, // ← 立即确认支付
automatic_payment_methods: {
enabled: true,
allow_redirects: 'never', // 离线扣款不需要重定向
},
});
res.json({
success: true,
paymentIntentId: paymentIntent.id,
status: paymentIntent.status,
});
} catch (error) {
console.error('扣款失败:', error);
res.status(500).json({ error: error.message });
}
});
// ==========================================
// 7️⃣ 获取 Session 详情(支付成功后查询)
// ==========================================
router.get('/checkout/session/:sessionId', async (req, res) => {
try {
const session = await stripe.checkout.sessions.retrieve(
req.params.sessionId,
{
expand: ['line_items', 'customer', 'payment_intent'], // 展开关联数据
}
);
res.json(session);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
4.3 前端实现(超级简单!)
Checkout Sessions 的前端真的只需要一个按钮和一个跳转:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Stripe Checkout 演示</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
max-width: 600px;
margin: 80px auto;
padding: 20px;
}
.product-card {
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 24px;
text-align: center;
margin-bottom: 20px;
}
.price { font-size: 32px; font-weight: 700; color: #635bff; }
.btn {
background: #635bff;
color: white;
border: none;
padding: 14px 32px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
width: 100%;
margin-top: 12px;
}
.btn:hover { background: #5851ea; }
.btn:disabled { background: #ccc; cursor: not-allowed; }
.btn-outline {
background: white;
color: #635bff;
border: 2px solid #635bff;
}
.btn-outline:hover { background: #f5f3ff; }
</style>
</head>
<body>
<h1>🛍️ 商品购买</h1>
<div class="product-card">
<h2>高级会员</h2>
<p>解锁全部功能,享受极速体验</p>
<div class="price">$19.99</div>
<!-- 1️⃣ 普通支付 -->
<button class="btn" onclick="checkout('payment')">
💳 立即购买
</button>
<!-- 2️⃣ 支付并保存卡片 -->
<button class="btn btn-outline" onclick="checkout('save-card')">
💳 购买并保存卡片
</button>
</div>
<div class="product-card">
<h2>绑定信用卡</h2>
<p>先绑卡,以后一键支付</p>
<!-- 3️⃣ 单独绑卡(不扣款) -->
<button class="btn btn-outline" onclick="setupCard()">
🔒 绑定信用卡
</button>
</div>
<div id="status"></div>
<script>
const API_BASE = 'http://localhost:4242/api';
// ========================================
// 1️⃣ 单次支付 / 支付并保存卡片
// ========================================
async function checkout(mode = 'payment') {
const statusEl = document.getElementById('status');
statusEl.textContent = '正在跳转到支付页面...';
try {
const endpoint = mode === 'save-card'
? '/checkout/create-session-save-card'
: '/checkout/create-session';
const res = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: 19.99,
currency: 'usd',
productName: '高级会员',
email: 'test@example.com',
}),
});
const data = await res.json();
if (data.url) {
// ⭐ 核心就这一行!跳转到 Stripe 托管的支付页面
window.location.href = data.url;
} else {
statusEl.textContent = '❌ 创建支付会话失败: ' + (data.error || '未知错误');
}
} catch (err) {
statusEl.textContent = '❌ 网络错误: ' + err.message;
}
}
// ========================================
// 2️⃣ 单独绑卡(不扣款)
// ========================================
async function setupCard() {
const statusEl = document.getElementById('status');
statusEl.textContent = '正在跳转到绑卡页面...';
try {
const res = await fetch(`${API_BASE}/checkout/setup-card`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'test@example.com',
}),
});
const data = await res.json();
if (data.url) {
// 跳转到 Stripe 的绑卡页面
window.location.href = data.url;
} else {
statusEl.textContent = '❌ 创建绑卡会话失败: ' + (data.error || '未知错误');
}
} catch (err) {
statusEl.textContent = '❌ 网络错误: ' + err.message;
}
}
</script>
<!-- 成功页面逻辑(success_url 跳转回来后) -->
<script>
// 检查 URL 中是否有 session_id(支付成功跳回后)
const urlParams = new URLSearchParams(window.location.search);
const sessionId = urlParams.get('session_id');
if (sessionId) {
// 有 session_id 说明支付成功跳回来了
fetch(`${API_BASE}/checkout/session/${sessionId}`)
.then(res => res.json())
.then(session => {
const statusEl = document.getElementById('status');
if (session.payment_status === 'paid') {
statusEl.innerHTML = `
<div style="color: #10b981; font-size: 18px; padding: 20px;
background: #ecfdf5; border-radius: 8px; margin-top: 20px;">
✅ 支付成功!<br>
订单号: ${session.id}<br>
金额: $${(session.amount_total / 100).toFixed(2)}
</div>
`;
}
})
.catch(err => console.error('查询订单失败:', err));
}
</script>
</body>
</html>
4.4 Checkout Sessions 常用参数速查表
| 参数 | 说明 | 示例 |
|---|---|---|
mode |
payment / setup / subscription |
mode: 'payment' |
line_items |
商品列表(payment/subscription 模式必填) | [{ price_data: {...}, quantity: 1 }] |
success_url |
支付成功后跳回的 URL | 'https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}' |
cancel_url |
用户取消支付后跳回的 URL | 'https://yoursite.com/cancel' |
customer |
绑定已有的 Customer ID | customer: 'cus_xxx' |
customer_email |
预填客户邮箱 | customer_email: 'user@example.com' |
client_reference_id |
你的内部订单号 | client_reference_id: 'order_12345' |
allow_promotion_codes |
允许使用优惠码 | allow_promotion_codes: true |
automatic_tax |
自动计算税费 | automatic_tax: { enabled: true } |
setup_future_usage |
保存卡片供后续使用(card 选项下) | payment_method_options: { card: { setup_future_usage: 'off_session' } } |
subscription_data |
订阅附加参数(如试用期) | subscription_data: { trial_period_days: 7 } |
expires_at |
Session 过期时间(Unix 时间戳,最短30分钟后) | expires_at: Math.floor(Date.now() / 1000) + 1800 |
4.5 Checkout Sessions 的优势总结
✅ 优点:
├── 前端代码极少(一个跳转搞定)
├── 自动支持所有已启用的支付方式(卡、Apple Pay、Google Pay 等)
├── 自动处理 3D Secure 验证
├── 自动处理支付错误和重试
├── 支持多语言、多币种
├── PCI 合规性由 Stripe 全权负责
└── 支持自定义品牌颜色和 Logo
❌ 局限:
├── 支付页面跳转到 Stripe 域名(stripe.com)
├── 自定义程度有限(不能完全控制 UI)
└── 24小时内未支付 Session 会过期
💡 如果你需要完全控制支付页面的 UI,请看下一章"方案 B:Payment Element"!
五、方案 B ------ Payment Element + PaymentIntent(自定义 UI 方案)
如果你需要把支付表单嵌入自己的页面,完全控制 UI 和交互,就用这个方案。 代码量比方案 A 多,但灵活性更强。
5.1 后端实现(Node.js / Express)
我们用 Node.js + Express 做后端,提供 RESTful API 供前端调用。
5.1 初始化项目
bash
mkdir stripe-payment-demo
cd stripe-payment-demo
npm init -y
npm install express stripe cors dotenv
| 依赖 | 作用 |
|---|---|
express |
Web 框架,搭建后端 API |
stripe |
Stripe 官方 Node.js SDK |
cors |
跨域支持(前后端分离需要) |
dotenv |
环境变量管理(安全存储密钥) |
5.2 配置环境变量
创建 .env 文件(这个文件绝对不能提交到 Git!):
env
STRIPE_SECRET_KEY=sk_test_你的私钥
STRIPE_WEBHOOK_SECRET=whsec_你的Webhook密钥
PORT=4242
💡 Webhook 密钥后面会讲怎么获取,先留空也行。
同时创建 .gitignore:
.env
node_modules/
4.3 后端核心代码(server.js)
这是完整的后端代码,包含所有需要的 API 接口。我会逐段解释:
javascript
// server.js
require('dotenv').config(); // 加载 .env 环境变量
const express = require('express');
const cors = require('cors');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const app = express();
// ========== 中间件 ==========
app.use(cors()); // 允许跨域
app.use(express.static('public')); // 托管前端静态文件
app.use(express.json()); // 解析 JSON 请求体
// ⚠️ Webhook 路由必须用 express.raw() 处理原始请求体(用于签名验证)
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
try {
// 验证 Webhook 签名 ------ 确保请求真的来自 Stripe,不是别人伪造的
const event = stripe.webhooks.constructEvent(
req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
);
// 处理不同类型的事件
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
console.log('✅ 支付成功!PaymentIntent ID:', paymentIntent.id);
// TODO: 更新你的数据库订单状态
break;
case 'payment_intent.payment_failed':
const failedIntent = event.data.object;
console.log('❌ 支付失败:', failedIntent.last_payment_error?.message);
// TODO: 处理支付失败逻辑
break;
case 'setup_intent.succeeded':
const setupIntent = event.data.object;
console.log('✅ 卡片保存成功!SetupIntent ID:', setupIntent.id);
// TODO: 把 PaymentMethod 绑定到你的用户记录
break;
case 'customer.created':
console.log('👤 新客户创建:', event.data.object.id);
break;
default:
console.log('未处理的事件类型:', event.type);
}
res.json({ received: true });
} catch (err) {
console.error('Webhook 签名验证失败:', err.message);
res.status(400).send(`Webhook Error: ${err.message}`);
}
});
为什么要验证 Webhook 签名? 防止别人冒充 Stripe 给你发假通知。Stripe 每次发 Webhook 都会带一个签名,你用 Webhook Secret 验证一下,就能确认"这条消息真的是 Stripe 发的"。
4.4 接口一:创建支付意图(单次支付)
javascript
// POST /api/create-payment-intent
// 前端点击"支付"按钮时调用这个接口
app.post('/api/create-payment-intent', async (req, res) => {
try {
const { amount, currency = 'usd' } = req.body;
// 参数校验
if (!amount || amount <= 0) {
return res.status(400).json({ error: '请提供有效的金额' });
}
// 💡 Stripe 的金额单位是"最小货币单位"
// 美元:$10.00 = 1000(美分)
// 人民币:¥10.00 = 1000(分)
// 日元:¥1000 = 1000(日元没有小数位)
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // $10.00 → 1000
currency: currency,
automatic_payment_methods: {
enabled: true, // 自动启用所有支持的支付方式
},
// 🔑 如果你想在支付的同时保存卡片,加下面两行:
// setup_future_usage: 'off_session', // 保存卡片供以后使用
// customer: customerId, // 关联到 Stripe 客户
});
console.log('创建 PaymentIntent:', paymentIntent.id);
// 把 client_secret 返回给前端
// 前端用这个 secret 来初始化 Payment Element
res.json({
clientSecret: paymentIntent.client_secret,
paymentIntentId: paymentIntent.id,
});
} catch (error) {
console.error('创建 PaymentIntent 失败:', error);
res.status(500).json({ error: error.message });
}
});
逐行解析:
| 行 | 说明 |
|---|---|
amount: Math.round(amount * 100) |
Stripe 用最小单位。$10 → 1000 美分。Math.round 防浮点误差 |
automatic_payment_methods: enabled: true |
这是新版推荐写法! 自动根据用户地区启用支持的支付方式,不用手动指定 |
setup_future_usage: 'off_session' |
支付完成后自动保存卡片,后续可以"离线扣款" |
client_secret |
返回给前端的"钥匙",前端用它初始化 Payment Element |
4.5 接口二:创建设置意图(保存卡片,不扣款)
这个接口用于"单独绑卡"场景------用户只想绑一张卡,现在不付款。
javascript
// POST /api/create-setup-intent
// 用于保存卡片信息(不扣款)
app.post('/api/create-setup-intent', async (req, res) => {
try {
const { customerId } = req.body;
if (!customerId) {
return res.status(400).json({ error: '请提供 customerId' });
}
// 创建 SetupIntent ------ 告诉 Stripe:"我要保存一张卡,但不扣钱"
const setupIntent = await stripe.setupIntents.create({
customer: customerId, // 关联到哪个客户
automatic_payment_methods: {
enabled: true,
},
});
console.log('创建 SetupIntent:', setupIntent.id);
res.json({
clientSecret: setupIntent.client_secret,
setupIntentId: setupIntent.id,
});
} catch (error) {
console.error('创建 SetupIntent 失败:', error);
res.status(500).json({ error: error.message });
}
});
PaymentIntent vs SetupIntent 的区别:
PaymentIntent:要扣钱。"用户要付 $20"SetupIntent:不扣钱。"用户要绑一张卡,以后再扣"两者的前端代码几乎一模一样,只是后端创建的对象不同。
4.6 接口三:创建/查询 Stripe 客户
javascript
// POST /api/create-customer
// 首次使用时创建 Stripe 客户(一个用户对应一个 Customer)
app.post('/api/create-customer', async (req, res) => {
try {
const { email, name, userId } = req.body;
const customer = await stripe.customers.create({
email: email,
name: name,
metadata: {
userId: userId, // 把你系统的用户 ID 存到 metadata,方便关联
},
});
console.log('创建 Customer:', customer.id);
res.json({
customerId: customer.id,
});
} catch (error) {
console.error('创建 Customer 失败:', error);
res.status(500).json({ error: error.message });
}
});
// GET /api/customer/:customerId/payment-methods
// 查询某个客户保存的所有卡片
app.get('/api/customer/:customerId/payment-methods', async (req, res) => {
try {
const { customerId } = req.params;
const paymentMethods = await stripe.paymentMethods.list({
customer: customerId,
type: 'card', // 只查银行卡
});
// 返回卡片列表(已脱敏,只有最后4位和品牌信息)
const cards = paymentMethods.data.map(pm => ({
id: pm.id,
brand: pm.card.brand, // visa, mastercard 等
last4: pm.card.last4, // 最后4位,如 "4242"
expMonth: pm.card.exp_month,
expYear: pm.card.exp_year,
isDefault: pm.metadata?.isDefault === 'true',
}));
res.json({ cards });
} catch (error) {
console.error('查询卡片失败:', error);
res.status(500).json({ error: error.message });
}
});
💡 metadata 是什么? 就是你可以自定义的键值对,Stripe 不会用它,只是帮你存着。比如你可以存
userId,方便后续根据你系统的用户 ID 找到对应的 Stripe Customer。
4.7 接口四:用已保存的卡片扣款
javascript
// POST /api/charge-saved-card
// 用之前保存的卡片进行扣款(用户不需要在前端操作)
app.post('/api/charge-saved-card', async (req, res) => {
try {
const { customerId, paymentMethodId, amount, currency = 'usd' } = req.body;
if (!customerId || !paymentMethodId || !amount) {
return res.status(400).json({ error: '缺少必要参数' });
}
// 创建 PaymentIntent,指定用哪张卡扣款
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100),
currency: currency,
customer: customerId,
payment_method: paymentMethodId, // 使用保存的卡片
off_session: true, // 表示用户不在线
confirm: true, // 立即确认支付
// returnUrl 用于某些支付方式需要重定向时使用
return_url: 'http://localhost:4242/payment-result',
});
console.log('使用保存卡片扣款成功:', paymentIntent.id);
res.json({
success: true,
paymentIntentId: paymentIntent.id,
status: paymentIntent.status,
});
} catch (error) {
console.error('扣款失败:', error);
res.status(500).json({ error: error.message });
}
});
这段代码的厉害之处:
off_session: true+confirm: true:后端直接扣款,用户不需要在前端做任何操作- 这就是"保存卡片"的核心价值:用户不在的时候也能扣钱
- 典型场景:自动续费、 delayed charge(先用服务后付费)、 parent payment(家长代付)
4.8 接口五:删除已保存的卡片
javascript
// POST /api/detach-payment-method
// 解绑(删除)一张已保存的卡片
app.post('/api/detach-payment-method', async (req, res) => {
try {
const { paymentMethodId } = req.body;
const paymentMethod = await stripe.paymentMethods.detach(
paymentMethodId
);
console.log('卡片已解绑:', paymentMethodId);
res.json({
success: true,
message: '卡片已解绑',
});
} catch (error) {
console.error('解绑失败:', error);
res.status(500).json({ error: error.message });
}
});
// 启动服务器
const PORT = process.env.PORT || 4242;
app.listen(PORT, () => {
console.log(`🚀 服务器已启动: http://localhost:${PORT}`);
});
4.9 后端接口总结
| 接口 | 方法 | 用途 |
|---|---|---|
/api/create-payment-intent |
POST | 创建支付(单次付款) |
/api/create-setup-intent |
POST | 创建绑卡(保存卡片,不扣款) |
/api/create-customer |
POST | 创建 Stripe 客户 |
/api/customer/:id/payment-methods |
GET | 查询客户保存的卡片 |
/api/charge-saved-card |
POST | 用保存的卡片扣款 |
/api/detach-payment-method |
POST | 解绑卡片 |
/webhook |
POST | 接收 Stripe 异步通知 |
六、前端实现(Payment Element ------ 方案 B 的前端部分)
前端是用户直接交互的部分。我们使用 Stripe 官方最新的 Payment Element 组件,它能自动适配多种支付方式(银行卡、Apple Pay、Google Pay 等),比旧版的 Card Element 强大很多。
5.1 页面结构(HTML)
创建 public/index.html:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stripe 支付演示</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<h1>🛒 支付演示</h1>
<!-- ========== 场景一:单次支付 ========== -->
<div id="payment-section" class="section">
<h2>场景一:单次支付</h2>
<p class="desc">购买商品,完成一次性付款</p>
<!-- 商品信息 -->
<div class="product-card">
<h3>超级会员月卡</h3>
<p class="price">$9.99</p>
</div>
<!-- 保存卡片选项 -->
<label class="save-card-option">
<input type="checkbox" id="save-card-checkbox">
记住我的卡片,方便下次支付
</label>
<!-- Payment Element 会被渲染到这个 div 里 -->
<div id="payment-element"></div>
<!-- 报错/提示信息 -->
<div id="payment-message" class="message hidden"></div>
<!-- 提交按钮 -->
<button id="submit-btn" class="pay-button">
<span id="button-text">支付 $9.99</span>
<span id="spinner" class="spinner hidden">⏳</span>
</button>
</div>
<!-- ========== 场景二:单独绑卡(不扣款)========== -->
<div id="setup-section" class="section" style="display:none;">
<h2>场景二:绑定信用卡</h2>
<p class="desc">保存卡片信息,以后再扣款</p>
<!-- Setup Element(绑卡表单)会被渲染到这里 -->
<div id="setup-element"></div>
<div id="setup-message" class="message hidden"></div>
<button id="setup-btn" class="pay-button">
<span id="setup-button-text">绑定卡片</span>
<span id="setup-spinner" class="spinner hidden">⏳</span>
</button>
</div>
<!-- ========== 场景三:已保存的卡片 ========== -->
<div id="saved-cards-section" class="section" style="display:none;">
<h2>场景三:已保存的卡片</h2>
<p class="desc">用已保存的卡片快速支付</p>
<div id="saved-cards-list"></div>
<button id="charge-saved-btn" class="pay-button" disabled>
用选中的卡片支付 $9.99
</button>
<button id="detach-card-btn" class="pay-button danger" disabled>
解绑选中卡片
</button>
</div>
<!-- 场景切换 -->
<div class="scene-switcher">
<button class="scene-btn active" data-scene="payment">单次支付</button>
<button class="scene-btn" data-scene="setup">绑定卡片</button>
<button class="scene-btn" data-scene="saved">已保存卡片</button>
</div>
</div>
<!-- ⚠️ 必须引入 Stripe.js ------ 这是 Stripe 的前端 SDK -->
<script src="https://js.stripe.com/v3/"></script>
<script src="app.js"></script>
</body>
</html>
💡 三个场景说明:
- 场景一:用户第一次来,输入卡号支付,可以勾选"记住卡片"
- 场景二:用户只想绑一张卡,现在不付钱(比如注册时绑卡)
- 场景三:用户已经有保存的卡片,选一张直接扣款
5.2 前端核心逻辑(app.js)
这是最重要的文件,请仔细看注释:
javascript
// public/app.js
// ==========================================
// 1. 初始化 Stripe
// ==========================================
// ⚠️ 用你的公钥(pk_test_ 开头的那个)
// 这个密钥是公开的,放前端没问题
const stripe = Stripe('pk_test_你的公钥');
let elements; // Stripe Elements 实例
let customerId; // 当前客户的 Stripe Customer ID
// ==========================================
// 2. 场景一:单次支付(Payment Element)
// ==========================================
async function initializePayment() {
// ① 先请求后端创建 PaymentIntent
const response = await fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: 9.99, // $9.99
currency: 'usd',
}),
});
const { clientSecret, error } = await response.json();
if (error) {
showMessage(error, 'payment-message');
return;
}
// ② 用 clientSecret 初始化 Stripe Elements
// ⭐ 这就是 Payment Element ------ 官方最新推荐的方式
const appearance = {
theme: 'stripe', // 可选: 'stripe' | 'flat' | 'night' | 'flat'
variables: {
colorPrimary: '#635bff', // Stripe 紫色
},
};
elements = stripe.elements({
clientSecret, // ← 关键!用后端返回的 client_secret
appearance,
});
// ③ 创建 Payment Element 并挂载到页面
const paymentElement = elements.create('payment', {
layout: 'tabs', // 选项卡布局:银行卡 | 其他支付方式
// 可选配置:
// wallets: { applePay: 'auto', googlePay: 'auto' },
});
paymentElement.mount('#payment-element');
}
这三步就是 Payment Element 的核心:
① 后端创建 PaymentIntent → 拿到 clientSecret
② stripe.elements({ clientSecret }) → 创建 Elements 实例
③ elements.create('payment').mount('#xxx') → 渲染支付表单
为什么需要 clientSecret? 因为安全性。clientSecret 是"一次性钥匙",证明"前端有权操作这个 PaymentIntent"。没有这个钥匙,谁都不能操作支付。
继续写支付确认逻辑:
javascript
// 用户点击"支付"按钮
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
// ⭐ 调用 stripe.confirmPayment ------ 这是触发支付的关键方法
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
// 支付完成后跳转到哪个页面
return_url: window.location.origin + '/payment-result.html',
},
// 如果想在支付的同时保存卡片:
// redirect: 'if_required', // 不跳转,在当前页面处理结果
});
if (error) {
// 支付失败,显示错误信息
showMessage(error.message, 'payment-message');
} else {
// 支付成功(会自动跳转到 return_url)
showMessage('支付成功!', 'payment-message');
}
setLoading(false);
}
💡
confirmPayment做了什么?
- 收集用户在 Payment Element 中输入的卡号信息
- 发送给 Stripe 服务器处理
- 如果需要 3D Secure 验证(弹窗让用户输密码),自动处理
- 支付完成后跳转到
return_url
5.3 场景二:单独绑卡(Setup Element)
这个场景是用户只想保存卡片,不扣钱。
javascript
// ==========================================
// 3. 场景二:单独绑卡(SetupIntent)
// ==========================================
let setupElements;
async function initializeSetup() {
// ① 确保 Customer 存在
if (!customerId) {
const resp = await fetch('/api/create-customer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: 'test@example.com',
name: '张三',
userId: 'user_123',
}),
});
const data = await resp.json();
customerId = data.customerId;
}
// ② 请求后端创建 SetupIntent
const response = await fetch('/api/create-setup-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customerId }),
});
const { clientSecret, error } = await response.json();
if (error) {
showMessage(error, 'setup-message');
return;
}
// ③ 用 clientSecret 初始化 Elements
const appearance = { theme: 'stripe' };
setupElements = stripe.elements({
clientSecret,
appearance,
});
// ④ 创建 Payment Element(和支付用的是同一个组件!)
const setupElement = setupElements.create('payment', {
layout: 'tabs',
});
setupElement.mount('#setup-element');
}
// 用户点击"绑定卡片"
async function handleSetupSubmit(e) {
e.preventDefault();
setSetupLoading(true);
// ⭐ 调用 stripe.confirmSetup ------ 确认保存卡片
const { error } = await stripe.confirmSetup({
elements: setupElements,
confirmParams: {
return_url: window.location.origin + '/setup-result.html',
},
});
if (error) {
showMessage(error.message, 'setup-message');
} else {
showMessage('✅ 卡片保存成功!', 'setup-message');
}
setSetupLoading(false);
}
支付 vs 绑卡的代码区别:
支付 绑卡 后端创建 PaymentIntent后端创建 SetupIntent前端调用 confirmPayment()前端调用 confirmSetup()会扣钱 不会扣钱,只验证卡片有效性 前端代码几乎一样 前端代码几乎一样
5.4 场景三:使用已保存的卡片
javascript
// ==========================================
// 4. 场景三:已保存的卡片
// ==========================================
let selectedCardId = null;
async function loadSavedCards() {
if (!customerId) return;
const response = await fetch(`/api/customer/${customerId}/payment-methods`);
const { cards, error } = await response.json();
if (error) {
showMessage(error, 'saved-cards-message');
return;
}
const listDiv = document.getElementById('saved-cards-list');
listDiv.innerHTML = '';
if (cards.length === 0) {
listDiv.innerHTML = '<p class="no-cards">还没有保存的卡片,请先绑卡</p>';
return;
}
cards.forEach(card => {
const cardDiv = document.createElement('div');
cardDiv.className = 'card-item';
cardDiv.innerHTML = `
<input type="radio" name="card" value="${card.id}"
${card.isDefault ? 'checked' : ''}>
<span class="card-brand">${card.brand.toUpperCase()}</span>
<span class="card-last4">**** **** **** ${card.last4}</span>
<span class="card-exp">${card.expMonth}/${card.expYear}</span>
`;
cardDiv.addEventListener('click', () => {
selectedCardId = card.id;
document.getElementById('charge-saved-btn').disabled = false;
document.getElementById('detach-card-btn').disabled = false;
});
listDiv.appendChild(cardDiv);
});
}
// 用保存的卡片扣款
async function chargeSavedCard() {
if (!selectedCardId) return;
const response = await fetch('/api/charge-saved-card', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customerId,
paymentMethodId: selectedCardId,
amount: 9.99,
}),
});
const data = await response.json();
if (data.success) {
alert('✅ 扣款成功!PaymentIntent ID: ' + data.paymentIntentId);
loadSavedCards(); // 刷新卡片列表
} else {
alert('❌ 扣款失败: ' + data.error);
}
}
// 解绑卡片
async function detachCard() {
if (!selectedCardId) return;
if (!confirm('确定要解绑这张卡片吗?')) return;
const response = await fetch('/api/detach-payment-method', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentMethodId: selectedCardId }),
});
const data = await response.json();
if (data.success) {
alert('✅ 卡片已解绑');
selectedCardId = null;
loadSavedCards();
} else {
alert('❌ 解绑失败: ' + data.error);
}
}
5.5 工具函数和事件绑定
javascript
// ==========================================
// 5. 工具函数
// ==========================================
function showMessage(message, elementId) {
const msgEl = document.getElementById(elementId);
msgEl.classList.remove('hidden');
msgEl.textContent = message;
}
function setLoading(isLoading) {
const btn = document.getElementById('submit-btn');
const text = document.getElementById('button-text');
const spinner = document.getElementById('spinner');
btn.disabled = isLoading;
text.textContent = isLoading ? '处理中...' : '支付 $9.99';
spinner.classList.toggle('hidden', !isLoading);
}
function setSetupLoading(isLoading) {
const btn = document.getElementById('setup-btn');
const text = document.getElementById('setup-button-text');
const spinner = document.getElementById('setup-spinner');
btn.disabled = isLoading;
text.textContent = isLoading ? '处理中...' : '绑定卡片';
spinner.classList.toggle('hidden', !isLoading);
}
// ==========================================
// 6. 事件绑定和初始化
// ==========================================
document.addEventListener('DOMContentLoaded', () => {
// 初始化场景一
initializePayment();
// 支付按钮
document.getElementById('submit-btn').addEventListener('click', handleSubmit);
document.getElementById('setup-btn').addEventListener('click', handleSetupSubmit);
// 用保存卡片扣款
document.getElementById('charge-saved-btn').addEventListener('click', chargeSavedCard);
document.getElementById('detach-card-btn').addEventListener('click', detachCard);
// 场景切换
document.querySelectorAll('.scene-btn').forEach(btn => {
btn.addEventListener('click', () => {
const scene = btn.dataset.scene;
// 切换按钮高亮
document.querySelectorAll('.scene-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// 切换显示区域
document.getElementById('payment-section').style.display =
scene === 'payment' ? 'block' : 'none';
document.getElementById('setup-section').style.display =
scene === 'setup' ? 'block' : 'none';
document.getElementById('saved-cards-section').style.display =
scene === 'saved' ? 'block' : 'none';
// 切到绑卡场景时初始化
if (scene === 'setup' && !setupElements) {
initializeSetup();
}
// 切到已保存卡片场景时加载列表
if (scene === 'saved') {
loadSavedCards();
}
});
});
});
七、支付时顺便保存卡片(setup_future_usage)
上一章讲了"单独绑卡",但更常见的场景是:用户第一次支付时,勾选"记住我的卡片",以后就不用再输入了。
这个功能只需要在后端创建 PaymentIntent 时加一个参数!
6.1 后端改动
javascript
// POST /api/create-payment-intent
app.post('/api/create-payment-intent', async (req, res) => {
try {
const { amount, currency = 'usd', saveCard = false, customerId } = req.body;
if (!amount || amount <= 0) {
return res.status(400).json({ error: '请提供有效的金额' });
}
const params = {
amount: Math.round(amount * 100),
currency: currency,
automatic_payment_methods: {
enabled: true,
},
};
// ⭐ 如果用户勾选了"保存卡片" 且 提供了 customerId
if (saveCard && customerId) {
params.customer = customerId;
// 🔑 关键参数!告诉 Stripe:支付完成后把这张卡保存下来
params.setup_future_usage = 'off_session';
// 'off_session' 表示:保存后,以后可以在用户不在线时扣款
// 'on_session' 表示:保存后,只能用户在场时使用
}
const paymentIntent = await stripe.paymentIntents.create(params);
res.json({
clientSecret: paymentIntent.client_secret,
paymentIntentId: paymentIntent.id,
});
} catch (error) {
console.error('创建 PaymentIntent 失败:', error);
res.status(500).json({ error: error.message });
}
});
6.2 前端改动
javascript
// app.js 中的 initializePayment 函数修改
async function initializePayment() {
// 获取复选框状态
const saveCardCheckbox = document.getElementById('save-card-checkbox');
const response = await fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: 9.99,
currency: 'usd',
saveCard: saveCardCheckbox.checked, // ← 传给后端
customerId: customerId, // ← 需要先创建 Customer
}),
});
// ... 后续代码不变
}
// ⚠️ 如果保存卡片,复选框变化时需要重新初始化
document.getElementById('save-card-checkbox').addEventListener('change', () => {
// 清空当前的 Payment Element
document.getElementById('payment-element').innerHTML = '';
// 重新初始化
initializePayment();
});
💡
setup_future_usage两个值的区别:
值 含义 适用场景 off_session保存后可"离线扣款"(用户不在也能扣) 自动续费、延迟扣款 on_session保存后只能用户在场时使用 快捷支付、记住卡片 大部分场景用
off_session,因为它功能更全。
6.3 支付结果页面处理
支付完成后,Stripe 会跳转到 return_url,URL 上会带上参数。我们需要解析这些参数来确认结果:
javascript
// public/payment-result.html
document.addEventListener('DOMContentLoaded', async () => {
const urlParams = new URLSearchParams(window.location.search);
const paymentIntentId = urlParams.get('payment_intent');
const redirectStatus = urlParams.get('redirect_status');
const messageEl = document.getElementById('result-message');
if (redirectStatus === 'succeeded') {
messageEl.className = 'message success';
messageEl.textContent = `✅ 支付成功!订单号: ${paymentIntentId}`;
} else if (redirectStatus === 'processing') {
messageEl.className = 'message';
messageEl.textContent = '⏳ 支付处理中,请稍后查看结果...';
} else {
messageEl.className = 'message error';
messageEl.textContent = `❌ 支付未完成,状态: ${redirectStatus}`;
}
});
八、用 Vue 3 接入 Stripe(Composition API)
如果你的项目用的是 Vue 3,这里是完整的接入代码:
7.1 安装依赖
bash
npm install @stripe/stripe-js
7.2 Stripe 支付组件
vue
<template>
<div class="stripe-payment">
<h2>{{ isSetup ? '绑定信用卡' : '支付 $9.99' }}</h2>
<!-- 保存卡片选项(仅支付时显示) -->
<label v-if="!isSetup" class="save-option">
<input type="checkbox" v-model="saveCard">
记住我的卡片,方便下次
</label>
<!-- Payment Element 挂载点 -->
<div ref="elementRef" class="stripe-element"></div>
<!-- 错误/成功信息 -->
<div v-if="message" :class="['message', messageType]">
{{ message }}
</div>
<!-- 提交按钮 -->
<button
@click="handleSubmit"
:disabled="loading"
class="pay-button"
>
{{ loading ? '处理中...' : (isSetup ? '绑定卡片' : '支付 $9.99') }}
</button>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { loadStripe } from '@stripe/stripe-js';
// ⚠️ 替换成你的公钥
const stripePromise = loadStripe('pk_test_你的公钥');
const props = defineProps({
isSetup: { type: Boolean, default: false }, // true=绑卡模式
customerId: { type: String, default: '' },
});
const emit = defineEmits(['success', 'error']);
const elementRef = ref(null);
const saveCard = ref(false);
const loading = ref(false);
const message = ref('');
const messageType = ref('');
let elements = null;
let stripe = null;
onMounted(async () => {
stripe = await stripePromise;
// 请求后端获取 clientSecret
const endpoint = props.isSetup
? '/api/create-setup-intent'
: '/api/create-payment-intent';
const body = props.isSetup
? { customerId: props.customerId }
: { amount: 9.99, saveCard: saveCard.value, customerId: props.customerId };
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const { clientSecret, error } = await resp.json();
if (error) {
message.value = error;
messageType.value = 'error';
return;
}
// 创建并挂载 Payment Element
elements = stripe.elements({
clientSecret,
appearance: { theme: 'stripe' },
});
const paymentElement = elements.create('payment', {
layout: 'tabs',
});
paymentElement.mount(elementRef.value);
});
const handleSubmit = async () => {
loading.value = true;
message.value = '';
try {
// 根据模式调用不同方法
const confirmFn = props.isSetup
? stripe.confirmSetup.bind(stripe)
: stripe.confirmPayment.bind(stripe);
const { error } = await confirmFn({
elements,
confirmParams: {
return_url: window.location.origin + '/payment-result',
},
});
if (error) {
message.value = error.message;
messageType.value = 'error';
emit('error', error);
}
} catch (err) {
message.value = '发生未知错误';
messageType.value = 'error';
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.stripe-payment {
max-width: 500px;
margin: 0 auto;
padding: 24px;
}
.stripe-element {
min-height: 200px;
margin: 20px 0;
}
.save-option {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
cursor: pointer;
}
.pay-button {
width: 100%;
padding: 14px;
background: #635bff;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
}
.pay-button:disabled { opacity: 0.5; }
.message { padding: 12px; border-radius: 8px; margin: 12px 0; }
.message.error { background: #fee; color: #c00; }
.message.success { background: #efe; color: #0a0; }
</style>
九、检测 Apple Pay / Google Pay 支持 & Express Checkout Element
这是 2025 年 Stripe 官方最新推荐的方式! 旧版的
PaymentRequestButton已废弃,请使用ExpressCheckoutElement。
8.1 为什么需要检测?
你有没有发现:
- 在 iPhone 上购物时,有些网站会显示"Apple Pay"按钮,有些不会
- 在 Android 手机上,有些网站会显示"Google Pay"按钮,有些不会
- 在电脑浏览器上,大部分网站都没有这些按钮
原因很简单:Apple Pay 和 Google Pay 需要设备支持才能使用。 比如你在一台没有 NFC 的 Windows 电脑上,显然用不了 Apple Pay。
所以前端需要先检测当前设备支持哪些支付方式,再决定是否显示对应的按钮。
8.2 Stripe 提供的方案:Express Checkout Element
Stripe 官方提供的 Express Checkout Element 可以自动帮你搞定这一切:
- ✅ 自动检测设备是否支持 Apple Pay、Google Pay、Link、PayPal 等
- ✅ 支持就显示按钮,不支持就不显示(零配置!)
- ✅ 你还可以监听
availablePaymentMethods事件,在代码里知道具体支持哪些 - ✅ 统一的
confirm事件处理,不用为每种支付方式写不同逻辑
💡 通俗理解:
Express Checkout Element 就像一个"智能收银台",它知道顾客身上带了哪些"钱包"(Apple Pay、Google Pay...),自动摆出对应的按钮。你不用操心判断逻辑。
8.3 支持的支付方式与浏览器要求
| 支付方式 | 支持的浏览器 / 环境 | 说明 |
|---|---|---|
| Apple Pay | Safari(iOS / macOS)、Chrome(macOS,需设置 applePay: 'always') |
需要设备已添加银行卡到 Wallet |
| Google Pay | Chrome(Android / 桌面)、Edge | 需要设备已登录 Google 账号并添加卡片 |
| Link | 所有现代浏览器 | Stripe 自家的一键支付,用户只需记住邮箱 |
| PayPal | 所有现代浏览器 | 需在 Dashboard 开启 PayPal |
| Amazon Pay | 所有现代浏览器 | 需在 Dashboard 开启 |
⚠️ 重要前提: 你必须在 Stripe Dashboard → Settings → Payment methods 中先开启对应的支付方式,否则前端检测不到!
8.4 完整前端代码(原生 JS)
html
<!-- 在你的 HTML 中添加 Express Checkout 区域 -->
<div id="express-checkout-section" style="display:none; margin: 20px 0;">
<h3>🚀 快捷支付</h3>
<p style="color:#888; font-size:13px;">支持 Apple Pay / Google Pay / Link</p>
<!-- Express Checkout Element 会自动渲染到这里 -->
<div id="express-checkout-element"></div>
</div>
<div id="express-checkout-fallback" style="display:none; color:#888; font-size:13px; padding:10px;">
当前设备不支持任何快捷支付方式,请使用下方卡片支付。
</div>
javascript
// ========== Express Checkout Element 初始化 ==========
// 1. 创建 Express Checkout Element
const expressCheckoutElement = elements.create('expressCheckout', {
// 配置支持的支付方式(不写则默认全部开启)
paymentMethods: {
applePay: 'auto', // 'auto' = 自动检测 | 'always' = 强制显示(仅用于测试)
googlePay: 'auto',
link: 'auto',
paypal: 'auto', // 需在 Dashboard 开启
amazonPay: 'auto', // 需在 Dashboard 开启
},
// 按钮样式配置
buttonTheme: {
applePay: 'black', // 'black' | 'white' | 'white-outline'
googlePay: 'black', // 'black' | 'white' | 'fill' | 'outline'
},
// 按钮类型(影响按钮文字)
buttonType: {
applePay: 'plain', // 'plain' | 'buy' | 'donate' | 'check-out' | 'book' | 'subscribe'
googlePay: 'buy', // 'buy' | 'donate' | 'book' | 'checkout' | 'pay' | 'plain' | 'order' | 'subscribe'
},
// 布局:单行显示多个按钮
layout: 'accordion', // 'accordion' | 'tabs' | 'column'
});
// 2. 挂载到 DOM
expressCheckoutElement.mount('#express-checkout-element');
// ========================================
// 3. 【核心】监听 availablePaymentMethods 事件
// 这个事件告诉你:当前设备到底支持哪些支付方式
// ========================================
expressCheckoutElement.on('ready', (event) => {
console.log('Express Checkout Element 就绪');
});
expressCheckoutElement.on('availablePaymentMethods', (event) => {
const { applePay, googlePay, link, paypal } = event.availablePaymentMethods;
console.log('=== 设备支付能力检测结果 ===');
console.log(`Apple Pay: ${applePay ? '✅ 支持' : '❌ 不支持'}`);
console.log(`Google Pay: ${googlePay ? '✅ 支持' : '❌ 不支持'}`);
console.log(`Link: ${link ? '✅ 支持' : '❌ 不支持'}`);
console.log(`PayPal: ${paypal ? '✅ 支持' : '❌ 不支持'}`);
// 只要支持任意一种,就显示 Express Checkout 区域
const anySupported = applePay || googlePay || link || paypal;
if (anySupported) {
document.getElementById('express-checkout-section').style.display = 'block';
document.getElementById('express-checkout-fallback').style.display = 'none';
} else {
document.getElementById('express-checkout-section').style.display = 'none';
document.getElementById('express-checkout-fallback').style.display = 'block';
}
// 你还可以根据支持的类型,做更精细的 UI 展示
if (applePay) {
console.log('🍎 可以显示 Apple Pay 按钮');
}
if (googlePay) {
console.log('🤖 可以显示 Google Pay 按钮');
}
});
// ========================================
// 4. 处理支付确认事件
// 用户点击 Apple Pay / Google Pay 按钮后触发
// ========================================
expressCheckoutElement.on('confirm', async (event) => {
console.log(`用户选择了: ${event.expressPaymentType}`);
// event.expressPaymentType 可能的值: 'apple_pay', 'google_pay', 'link', 'paypal'
// 调用 confirmPayment 完成支付(和普通支付一样的流程)
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: window.location.origin + '/payment-result',
},
});
if (error) {
// 显示错误信息
showMessage(error.message, 'error');
}
});
// 5. 处理取消事件
expressCheckoutElement.on('cancel', () => {
console.log('用户取消了快捷支付');
});
// 6. 处理点击事件(可以在这里做金额确认等)
expressCheckoutElement.on('click', (event) => {
console.log('用户点击了 Express Checkout 按钮');
// 你可以在这里阻止默认行为,先做一些验证
// event.preventDefault();
});
8.5 关键 API 解析
elements.create('expressCheckout', options)
| 参数 | 类型 | 说明 |
|---|---|---|
paymentMethods.applePay |
'auto' | 'always' |
auto = 自动检测;always = 强制显示(仅测试用) |
paymentMethods.googlePay |
'auto' | 'always' |
同上 |
paymentMethods.link |
'auto' | 'always' |
同上 |
buttonTheme.applePay |
string |
Apple Pay 按钮颜色主题 |
buttonTheme.googlePay |
string |
Google Pay 按钮颜色主题 |
buttonType.applePay |
string |
Apple Pay 按钮文字(buy / pay / donate...) |
buttonType.googlePay |
string |
Google Pay 按钮文字 |
layout |
string |
多按钮的布局方式 |
availablePaymentMethods 事件返回值
javascript
{
availablePaymentMethods: {
applePay: true/false, // 当前设备是否支持 Apple Pay
googlePay: true/false, // 当前设备是否支持 Google Pay
link: true/false, // 当前设备是否支持 Link
paypal: true/false, // 当前设备是否支持 PayPal
amazonPay: true/false, // 当前设备是否支持 Amazon Pay
}
}
💡 注意:
availablePaymentMethods事件是异步的!因为 Stripe 需要去 Apple/Google 的服务器查询设备是否注册了支付功能。所以不要在同步代码里判断,必须通过事件监听获取结果。
8.6 与 Payment Element 配合使用的最佳实践
推荐做法:页面上同时放 Payment Element 和 Express Checkout Element。
┌─────────────────────────────────────┐
│ Express Checkout │ ← 快捷支付(有设备支持才显示)
│ [🍎 Apple Pay] [🤖 Google Pay] │
├─────────────────────────────────────┤
│ Payment Element │ ← 传统卡片支付(始终显示)
│ [卡号] [过期日期] [CVC] │
│ □ 记住我的卡片 │
│ [确认支付 $9.99] │
└─────────────────────────────────────┘
javascript
// 最佳实践:先检测,再决定布局
expressCheckoutElement.on('availablePaymentMethods', (event) => {
const { applePay, googlePay, link } = event.availablePaymentMethods;
const hasExpress = applePay || googlePay || link;
if (hasExpress) {
// 有快捷支付 → 显示分隔线,区分两种方式
document.getElementById('express-checkout-section').style.display = 'block';
document.getElementById('divider').style.display = 'block'; // "--- 或 ---"
} else {
// 没有快捷支付 → 只显示传统卡片支付
document.getElementById('express-checkout-section').style.display = 'none';
document.getElementById('divider').style.display = 'none';
}
});
对应的 HTML:
html
<div class="container">
<!-- 快捷支付区域 -->
<div id="express-checkout-section" style="display:none;">
<div id="express-checkout-element"></div>
</div>
<!-- 分隔线 -->
<div id="divider" class="divider" style="display:none;">
<span>或使用卡片支付</span>
</div>
<!-- 传统卡片支付区域(始终显示) -->
<form id="payment-form">
<div id="payment-element"><!-- Payment Element --></div>
<button type="submit">确认支付 $9.99</button>
</form>
</div>
8.7 Vue 3 版本
vue
<template>
<div>
<!-- Express Checkout -->
<div v-if="expressAvailable" class="express-section">
<div ref="expressCheckoutRef"></div>
</div>
<!-- 分隔线 -->
<div v-if="expressAvailable" class="divider">或使用卡片支付</div>
<!-- Payment Element(始终显示) -->
<div ref="paymentRef"></div>
<button @click="handlePay" :disabled="loading">
{{ loading ? '处理中...' : '确认支付' }}
</button>
<!-- 设备支持信息(可选,用于调试) -->
<div v-if="showDebug" class="debug-info">
<p>设备支付能力检测结果:</p>
<ul>
<li>🍎 Apple Pay: {{ paymentCapabilities.applePay ? '✅' : '❌' }}</li>
<li>🤖 Google Pay: {{ paymentCapabilities.googlePay ? '✅' : '❌' }}</li>
<li>🔗 Link: {{ paymentCapabilities.link ? '✅' : '❌' }}</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue';
import { loadStripe } from '@stripe/stripe-js';
const stripe = await loadStripe('pk_test_你的公钥');
const expressCheckoutRef = ref(null);
const expressAvailable = ref(false);
const showDebug = ref(false); // 设为 true 可看调试信息
const paymentCapabilities = reactive({
applePay: false,
googlePay: false,
link: false,
paypal: false,
});
let elements;
let expressCheckoutElement;
onMounted(async () => {
// 1. 先从后端获取 clientSecret(代码省略,同前面章节)
const clientSecret = await fetchClientSecret();
// 2. 创建 Elements 实例
elements = stripe.elements({ clientSecret });
// 3. 创建 Payment Element
const paymentElement = elements.create('payment');
paymentElement.mount(paymentRef.value);
// 4. 创建 Express Checkout Element
expressCheckoutElement = elements.create('expressCheckout', {
paymentMethods: {
applePay: 'auto',
googlePay: 'auto',
link: 'auto',
},
buttonTheme: {
applePay: 'black',
googlePay: 'black',
},
buttonType: {
applePay: 'pay',
googlePay: 'pay',
},
});
// 5. 监听设备支持情况
expressCheckoutElement.on('availablePaymentMethods', (event) => {
const methods = event.availablePaymentMethods;
paymentCapabilities.applePay = methods.applePay || false;
paymentCapabilities.googlePay = methods.googlePay || false;
paymentCapabilities.link = methods.link || false;
paymentCapabilities.paypal = methods.paypal || false;
// 只要有任意一种快捷支付可用,就显示
expressAvailable.value = methods.applePay || methods.googlePay || methods.link;
});
// 6. 处理快捷支付确认
expressCheckoutElement.on('confirm', async (event) => {
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: window.location.origin + '/payment-result',
},
});
if (error) {
alert(error.message);
}
});
// 7. 挂载
expressCheckoutElement.mount(expressCheckoutRef.value);
});
</script>
8.8 常见问题
Q: 为什么 availablePaymentMethods 全是 false?
检查以下几点:
- ✅ 是否在 Stripe Dashboard → Payment methods 中开启了对应的支付方式
- ✅ 是否在 HTTPS 环境下测试(Apple Pay / Google Pay 要求 HTTPS,
localhost例外) - ✅ 设备是否真的支持(比如在 Windows Chrome 上测 Apple Pay,肯定是 false)
- ✅ Apple Pay 需要设备至少添加了一张卡片到 Apple Wallet
Q: 本地开发怎么测试?
- 本地
localhost可以测试 Google Pay(Chrome 浏览器) - Apple Pay 需要在真机 Safari 上测试,或者 macOS Safari + 已配对 Apple Watch
- 你也可以设置
applePay: 'always'/googlePay: 'always'强制显示按钮(仅限测试环境)
Q: Express Checkout Element 和 Payment Element 有什么区别?
| 对比项 | Payment Element | Express Checkout Element |
|---|---|---|
| 用途 | 传统卡片支付表单 | 快捷支付按钮(Apple/Google Pay等) |
| 显示方式 | 始终显示 | 检测到设备支持才显示 |
| 需要用户输入 | 需要(卡号等) | 不需要(调用系统钱包) |
| 能否单独使用 | 可以 | 可以(但建议和 Payment Element 搭配) |
十、CSS 样式(美化页面)
创建 public/style.css,让页面好看一点:
css
/* public/style.css */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f6f9fc;
color: #32325d;
line-height: 1.6;
}
.container {
max-width: 600px;
margin: 40px auto;
padding: 0 20px;
}
h1 { text-align: center; margin-bottom: 30px; color: #635bff; }
.section {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
margin-bottom: 20px;
}
.product-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 10px;
margin: 16px 0;
text-align: center;
}
.product-card h3 { margin-bottom: 8px; }
.product-card .price { font-size: 28px; font-weight: bold; }
.save-card-option {
display: flex;
align-items: center;
gap: 8px;
margin: 16px 0;
cursor: pointer;
font-size: 14px;
}
.pay-button {
width: 100%;
padding: 14px 24px;
background: #635bff;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-top: 20px;
transition: background 0.2s;
}
.pay-button:hover:not(:disabled) { background: #5851ea; }
.pay-button:disabled { opacity: 0.5; cursor: not-allowed; }
.pay-button.danger { background: #e74c3c; margin-top: 10px; }
.pay-button.danger:hover:not(:disabled) { background: #c0392b; }
.message {
padding: 12px 16px;
border-radius: 8px;
margin-top: 16px;
font-size: 14px;
}
.message.error { background: #fee; color: #c00; }
.message.success { background: #efe; color: #0a0; }
.message.hidden { display: none; }
.spinner { margin-left: 8px; }
.scene-switcher {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 20px;
}
.scene-btn {
padding: 10px 20px;
border: 2px solid #635bff;
background: transparent;
color: #635bff;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.scene-btn.active {
background: #635bff;
color: white;
}
.card-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px;
border: 2px solid #e0e0e0;
border-radius: 8px;
margin: 8px 0;
cursor: pointer;
transition: border-color 0.2s;
}
.card-item:hover { border-color: #635bff; }
.card-brand { font-weight: 600; color: #635bff; }
.card-last4 { font-family: monospace; font-size: 16px; }
.card-exp { color: #888; font-size: 13px; }
.no-cards { text-align: center; color: #888; padding: 20px; }
.desc { color: #666; margin-bottom: 16px; font-size: 14px; }
十一、订阅支付(按月自动扣款)
很多 SaaS 产品需要"按月/按年"自动续费。Stripe 的订阅功能非常强大。
9.1 在 Dashboard 创建产品和价格
- 打开 Stripe Dashboard → "Products" → "Add product"
- 填写名称(如"超级会员")
- 添加价格方案:
- 月付:$9.99/月(Recurring)
- 年付:$99.99/年(Recurring)
创建后会得到一个 Price ID ,格式类似 price_1Abc2Def3Ghi4Jkl。
💡 Product vs Price?
Product 是"卖什么"(超级会员),Price 是"怎么卖"(月付9.99、年付99.99)。一个 Product 可以有多个 Price。
9.2 后端:创建订阅
javascript
// POST /api/create-subscription
app.post('/api/create-subscription', async (req, res) => {
try {
const { customerId, priceId } = req.body;
// 创建订阅
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }], // 用哪个价格方案
payment_behavior: 'default_incomplete', // 支付方式未确认时先挂起
payment_settings: {
save_default_payment_method: 'on_subscription', // 自动保存默认卡片
},
expand: ['latest_invoice.payment_intent'], // 展开获取 PaymentIntent
});
res.json({
subscriptionId: subscription.id,
clientSecret: subscription.latest_invoice.payment_intent.client_secret,
status: subscription.status,
});
} catch (error) {
console.error('创建订阅失败:', error);
res.status(500).json({ error: error.message });
}
});
9.3 后端:取消订阅
javascript
// POST /api/cancel-subscription
app.post('/api/cancel-subscription', async (req, res) => {
try {
const { subscriptionId } = req.body;
const subscription = await stripe.subscriptions.update(
subscriptionId,
{ cancel_at_period_end: true } // 到期后取消,不是立即取消
);
res.json({
success: true,
status: subscription.status,
cancelAt: subscription.cancel_at,
message: `订阅将在 ${new Date(subscription.current_period_end * 1000).toLocaleDateString()} 到期后取消`,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
为什么用
cancel_at_period_end而不是立即取消? 用户体验更好。用户已经付了这个月的钱,应该让他用到月底。如果用户改主意了,你还可以把cancel_at_period_end改回false来"撤销取消"。
9.4 前端:订阅支付
javascript
// 前端创建订阅的流程和单次支付几乎一模一样
async function subscribe(priceId) {
// 1. 创建订阅
const resp = await fetch('/api/create-subscription', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customerId, priceId }),
});
const { clientSecret, subscriptionId } = await resp.json();
// 2. 用 clientSecret 初始化 Payment Element
const elements = stripe.elements({
clientSecret,
appearance: { theme: 'stripe' },
});
const paymentElement = elements.create('payment');
paymentElement.mount('#subscription-element');
// 3. 确认支付(和单次支付一样)
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: window.location.origin + '/subscription-result' },
});
if (error) {
console.error('订阅支付失败:', error.message);
}
}
十二、退款
退款是线上支付最常见的需求之一。
10.1 后端退款 API
javascript
// POST /api/refund
app.post('/api/refund', async (req, res) => {
try {
const { paymentIntentId, amount, reason = 'requested_by_customer' } = req.body;
const params = {
payment_intent: paymentIntentId,
reason: reason,
};
// 如果指定了金额,就是部分退款;不指定就是全额退款
if (amount) {
params.amount = Math.round(amount * 100); // 退 $5.00 → 500
}
const refund = await stripe.refunds.create(params);
res.json({
success: true,
refundId: refund.id,
amount: refund.amount,
status: refund.status,
});
} catch (error) {
console.error('退款失败:', error);
res.status(500).json({ error: error.message });
}
});
| 参数 | 说明 |
|---|---|
payment_intent |
要退的哪笔支付 |
amount |
不传 = 全额退款;传了 = 部分退款(单位:最小货币单位) |
reason |
退款原因:duplicate(重复)、fraudulent(欺诈)、requested_by_customer(客户要求) |
退款时间线 :Stripe 提交退款后,银行一般需要 5-10 个工作日 把钱退回到用户卡上。
十三、Webhook 事件完整处理
正式环境必须监听 Webhook。以下是常用事件的完整处理模板:
javascript
// server.js 中的 Webhook 处理
switch (event.type) {
// ===== 支付相关 =====
case 'payment_intent.succeeded':
console.log('✅ 支付成功:', event.data.object.id);
// 更新订单状态为"已支付"
break;
case 'payment_intent.payment_failed':
console.log('❌ 支付失败:', event.data.object.last_payment_error?.message);
// 更新订单状态为"支付失败"
break;
case 'payment_intent.canceled':
console.log('🚫 支付已取消:', event.data.object.id);
break;
// ===== 保存卡片相关 =====
case 'setup_intent.succeeded':
const setupIntent = event.data.object;
console.log('✅ 卡片保存成功!');
console.log(' Customer:', setupIntent.customer);
console.log(' PaymentMethod:', setupIntent.payment_method);
// TODO: 更新数据库,记录用户的新卡片
break;
case 'setup_intent.setup_failed':
console.log('❌ 保存卡片失败:', event.data.object.last_setup_error?.message);
break;
// ===== 订阅相关 =====
case 'customer.subscription.created':
console.log('📝 新订阅创建:', event.data.object.id);
break;
case 'customer.subscription.updated':
const sub = event.data.object;
console.log('📝 订阅更新:', sub.id, '状态:', sub.status);
break;
case 'customer.subscription.deleted':
console.log('🗑️ 订阅已取消:', event.data.object.id);
break;
case 'invoice.paid':
console.log('💰 续费成功:', event.data.object.id);
// 订阅自动续费成功时触发
break;
case 'invoice.payment_failed':
console.log('⚠️ 续费失败:', event.data.object.id);
// 订阅自动续费失败,可以发邮件通知用户
break;
// ===== 退款相关 =====
case 'charge.refunded':
console.log('💸 退款完成:', event.data.object.id);
break;
// ===== 支付方式相关 =====
case 'payment_method.attached':
console.log('💳 新卡片绑定到客户:', event.data.object.customer);
break;
case 'payment_method.detached':
console.log '💳 卡片已解绑:', event.data.object.id);
break;
default:
console.log('未处理的事件:', event.type);
}
Webhook 本地测试
正式的 Webhook 需要 HTTPS 地址(Stripe 只往 HTTPS 发请求)。本地开发时,用 Stripe CLI:
bash
# 1. 安装 Stripe CLI(参考官方文档)
# 2. 登录
stripe login
# 3. 转发 Webhook 到本地
stripe listen --forward-to localhost:4242/webhook
# 4. 另一个终端,触发测试事件
stripe trigger payment_intent.succeeded
stripe trigger setup_intent.succeeded
运行 stripe listen 后,它会给你一个 whsec_xxx 密钥,把它填到 .env 的 STRIPE_WEBHOOK_SECRET 里。
十四、安全最佳实践
| 原则 | 说明 | 错误示范 ❌ | 正确做法 ✅ |
|---|---|---|---|
| 私钥不外泄 | sk_live/sk_test 只能在后端 | 前端代码里写 sk_test_xxx | 只在后端用,前端只用 pk_test |
| 验证 Webhook 签名 | 确保请求来自 Stripe | req.body 直接用 |
constructEvent 验签后再用 |
| 后端校验金额 | 别信前端传的金额 | 前端传多少就扣多少 | 后端从数据库/Session查价格 |
| 幂等性处理 | 同一请求不重复处理 | Webhook 每次都新建订单 | 用 PaymentIntent ID 去重 |
| HTTPS | 全站 HTTPS | HTTP 部署 | Let's Encrypt 免费证书 |
| 日志审计 | 记录所有支付操作 | console.log 随便打 |
正式日志系统 + 异常告警 |
十五、常见问题与排错
Q1: Payment Element 不显示?
可能原因:
- 公钥写错了(检查是不是
pk_test_开头) clientSecret为空(检查后端是否正确返回)- 页面还没加载完就调用了
mount()
调试方法:打开浏览器控制台(F12),看有没有红色报错。
Q2: 测试卡号有哪些?
| 卡号 | 效果 |
|---|---|
4242 4242 4242 4242 |
支付成功 |
4000 0025 0000 3155 |
需要 3D Secure 验证 |
4000 0000 0000 0002 |
支付被拒绝(卡被拒) |
4000 0000 0000 3220 |
3D Secure 后成功 |
任意未来的过期日期 + 任意 CVC + 任意邮编即可。
Q3: setup_future_usage 设了但卡片没保存?
- 必须同时传
customer参数。没有 customer,Stripe 不知道保存给谁。 - 检查 PaymentIntent 状态是否是
succeeded。如果失败了自然不会保存。 - 用 Dashboard → Customers → 选择客户 → Payment Methods 查看。
Q4: Webhook 收不到?
- 本地开发用
stripe listen --forward-to localhost:4242/webhook - 正式环境确保 URL 是 HTTPS 且返回 200
- Dashboard → Webhooks 里可以看到发送记录和失败原因
Q5: 前端报 "IntegrationError: Invalid clientSecret"?
- 检查
clientSecret格式:PaymentIntent 的是pi_xxx_secret_yyy,SetupIntent 的是seti_xxx_secret_yyy - 确保
stripe.elements({ clientSecret })传的是字符串
Q6: 保存的卡片后续扣款失败?
- 确保创建 PaymentIntent 时
setup_future_usage设的是off_session - 后端扣款时需要传
off_session: true - 某些卡片/银行不支持离线扣款,用测试卡
4242验证
十六、完整项目结构
stripe-payment-demo/
├── server.js # 后端入口
├── .env # 环境变量(不提交 Git)
├── .gitignore
├── package.json
├── public/
│ ├── index.html # 首页(三种支付场景)
│ ├── payment-result.html # 支付结果页
│ ├── app.js # 前端核心逻辑
│ └── style.css # 样式
└── README.md
.gitignore 内容:
.env
node_modules/
.DS_Store
十七、新旧方案对比
很多教程还在用旧版的 Card Element,这里做个对比,帮大家理解为什么要迁移到 Payment Element:
| 对比项 | 旧版 Card Element ❌ | 新版 Payment Element ✅ |
|---|---|---|
| 支持的支付方式 | 只有银行卡 | 银行卡 + Apple Pay + Google Pay + 更多 |
| 组件数量 | 多个(card, expiry, cvc 分开) | 一个组件搞定 |
| UI 定制 | 有限 | 支持主题(theme)和 CSS 变量 |
| 国际化 | 手动处理 | 自动适配用户语言和货币 |
| 保存卡片 | 需要额外写很多代码 | setup_future_usage 一行搞定 |
| 官方维护 | 已停止更新 | 持续更新 |
如果你还在用 Card Element,强烈建议迁移! 官方已经不再推荐使用旧版。
十八、总结
这篇文章覆盖了 Stripe 支付接入的完整流程,我们来回顾一下核心知识点:
| 知识点 | 一句话总结 |
|---|---|
| Payment Element | Stripe 官方最新前端组件,一个组件支持所有支付方式 |
| PaymentIntent | "我要扣钱"------后端创建,前端确认 |
| SetupIntent | "我要绑卡不扣钱"------保存卡片信息 |
| clientSecret | 前后端之间的"一次性钥匙" |
| setup_future_usage | 支付时顺便保存卡片,一个参数搞定 |
| off_session | 保存的卡片可以离线扣款 |
| Webhook | Stripe 主动通知你的后端,比前端结果更可靠 |
| Stripe CLI | 本地开发 Webhook 的必备工具 |
三个核心安全原则:
- 🔒 私钥只在后端使用,永不暴露到前端
- ✅ Webhook 必须验证签名
- 💰 后端校验金额,不信任前端传的数字
如果这篇文章对你有帮助,请点赞 + 收藏 + 关注三连! 🙏
有任何问题欢迎在评论区留言,我会一一回复。
祝你接入顺利,早日收到第一笔付款!💰