Stripe 支付接入完整指南:从零到一,通俗易懂(2025最新版·含 Checkout Sessions + Payment Element 双方案)

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 账号

  1. 打开 https://stripe.com,点击 "Start now" 注册
  2. 填写邮箱、密码、国家等信息
  3. 邮箱验证后进入 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_methodrequires_confirmationprocessingsucceeded

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 做了什么?

  1. 收集用户在 Payment Element 中输入的卡号信息
  2. 发送给 Stripe 服务器处理
  3. 如果需要 3D Secure 验证(弹窗让用户输密码),自动处理
  4. 支付完成后跳转到 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?

检查以下几点:

  1. ✅ 是否在 Stripe Dashboard → Payment methods 中开启了对应的支付方式
  2. ✅ 是否在 HTTPS 环境下测试(Apple Pay / Google Pay 要求 HTTPS,localhost 例外)
  3. ✅ 设备是否真的支持(比如在 Windows Chrome 上测 Apple Pay,肯定是 false)
  4. ✅ 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 创建产品和价格

  1. 打开 Stripe Dashboard → "Products" → "Add product"
  2. 填写名称(如"超级会员")
  3. 添加价格方案:
    • 月付:$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 密钥,把它填到 .envSTRIPE_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 的必备工具

三个核心安全原则

  1. 🔒 私钥只在后端使用,永不暴露到前端
  2. ✅ Webhook 必须验证签名
  3. 💰 后端校验金额,不信任前端传的数字

如果这篇文章对你有帮助,请点赞 + 收藏 + 关注三连! 🙏

有任何问题欢迎在评论区留言,我会一一回复。

祝你接入顺利,早日收到第一笔付款!💰

相关推荐
隔窗听雨眠1 小时前
AI开发者的网络卡点:Anthropic连接超时实战避坑
网络·人工智能
星恒讯工业路由器1 小时前
6G FR3深度解析
网络·无线通信·6g·通感一体化·fr3频谱
@insist1231 小时前
信息安全工程师-网站安全主动防御体系构建与政务网站合规实践
网络·安全·软考·信息安全工程师·政务·软件水平考试
笑中取栗2 小时前
华为HCSA-传输接入H19-473题库
网络·华为·题库·hcsa
云飞云共享云桌面2 小时前
硬件采购省50%、设计效率提40%——通过云飞云共享云桌面一台云主机拖10人的真实跑法
运维·服务器·网络·人工智能·自动化
L1624762 小时前
Nginx Stream 四层代理 TLS 类漏洞修复完整版
网络·nginx·安全
.千余2 小时前
【Linux】网络基础2---Socket编程预备
linux·网络·php
HMS工业网络2 小时前
使用电脑快速测试PROFIBUS 设备通讯
网络·网络协议·profibus·主站·设备通讯
tjjingpan2 小时前
HCIP-Datacom Core Technology V1.0_18 IGMP原理与配置
网络