在线支付系列(三):Stripe & 信用卡——一件跨境商品的卡支付之旅

在线支付系列(三):Stripe & 信用卡——一件跨境商品的卡支付之旅

$149.99 的限量球鞋,3 秒钟经历了什么?

纽约的 John 深夜刷手机,在一个中国独立站上发现了一双限量球鞋,标价 $149.99。他决定入手。

页面弹出支付框,他选择了信用卡支付,输入了 Visa 卡号、有效期、CVC。

突然,屏幕中间弹出一个窗口——他的银行要求输入手机验证码。他打开短信,输入 6 位数字,点击确认。

"Payment Successful."

这整个过程大约 10 秒。但在这 10 秒钟里,John 的 $149.99 经历了一段横跨太平洋的旅程:从他的 Chase 银行账户出发,穿过 Visa 网络,经过 Stripe 的收单系统,最终到达中国独立站商户的 Stripe 余额里。

中间那个弹窗是什么?为什么 John 的卡号没有直接传到商户的服务器?如果 John 过了三天说"我没买过这双鞋"怎么办?

这篇文章,就从这笔 $149.99 说起。


一、信用卡支付的特殊之处

在上一篇中,我们对接了支付宝和微信——它们的流程相对直接:创建订单、生成二维码、收到回调、更新状态。

信用卡支付的复杂度要高出一个量级,因为它多了几层东西:

  1. 授权与捕获可以分离——钱可以先"冻住",过几天再真正扣
  2. 3D Secure 身份验证——银行可能要求用户额外验证身份(就是 John 收到的那个短信验证码弹窗)
  3. PCI DSS 合规——你碰了信用卡号,就要遵守一套严格的安全标准
  4. 争议/拒付机制——持卡人可以在交易完成后反悔,向银行申请退款

好消息是:Stripe 帮你处理了绝大部分复杂度。但你仍然需要理解这些概念,才能做出正确的技术决策。


二、授权与捕获:先冻住,再扣款

让我们先理解信用卡支付中最核心的概念——授权(Authorization)和捕获(Capture)的分离

2.1 两步走的逻辑

当 John 输入卡号点击支付时,实际上发生了两件不同的事:

第一步:授权(Authorization)          第二步:捕获(Capture)
┌──────────────────────┐             ┌──────────────────────┐
│ Chase 银行冻结 $149.99 │             │ 实际从 John 卡里扣款   │
│ 返回授权码              │  ── 最多7天 → │ 资金进入清结算流程      │
│ 钱还没有真正转走         │             │ 商户开始等待到账        │
└──────────────────────┘             └──────────────────────┘

为什么要分两步?因为很多场景需要"先确认用户有钱,但暂时不扣":

  • 酒店:入住时授权 ¥2000,退房时按实际消费 ¥1200 捕获
  • 电商预售:下单时授权,发货时才捕获
  • 订阅试用:授权 $0.00 验证卡片有效,试用期结束后捕获第一笔月费

2.2 在 Stripe 中怎么用?

Stripe 的 Payment Intents 默认是授权后自动捕获——也就是说用户支付成功后立刻扣款。对于大多数场景,这就够了。

如果你需要手动控制捕获时机(比如电商需要发货后再扣款),只需要一个参数:

# 创建时设置手动捕获
intent = stripe.PaymentIntent.create(
    amount=14999,
    currency="usd",
    capture_method="manual",  # 改成手动捕获
)

# 发货后,手动捕获
stripe.PaymentIntent.capture("pi_xxx")

注意:授权有有效期(通常 7 天)。超过有效期没有捕获,授权会自动释放——钱回到用户卡里。


三、3D Secure:那个弹窗是怎么回事?

回到 John 的故事。他输入卡号后弹出了一个验证窗口,要求输入短信验证码。这就是 3D Secure(3DS)

3.1 3DS 是什么?

3DS 是 Visa、Mastercard 等卡组织推出的额外身份验证层。它在标准支付流程中插入了一步:把用户重定向到发卡行的验证页面,确认"正在用卡的人真的是卡的主人"。

标准流程:  John → 商户 → Stripe → Chase(发卡行)→ 扣款

3DS 增强:  John → 商户 → Stripe → 【Chase 验证页面:请输入短信验证码】→ 验证通过 → 扣款

3.2 两种 3DS 流程

现在主流的是 3DS 2.0,它有两种流程:

Frictionless Flow(无感验证):发卡行在后台分析了 John 的设备指纹、消费习惯、地理位置等数据,判断这是一笔低风险交易,自动通过。John 完全没感知到 3DS 的存在。

Challenge Flow(挑战验证):发卡行觉得有风险(比如 John 突然从一个从没用过的设备、在凌晨、购买了一件高价商品),弹出验证窗口,要求 John 输入短信验证码或进行指纹认证。

对开发者来说最好的消息是:在 Stripe 中,3DS 是自动处理的。 你调用 stripe.confirmCardPayment() 后,如果发卡行要求 3DS,Stripe.js 会自动弹出验证窗口。你不需要写任何额外代码。

3.3 欧洲 SCA 合规:不是可选的

如果你的用户在欧洲(EEA),3DS 不是"建议使用",而是法律强制要求

欧洲的 PSD2 法规要求所有在线支付必须进行强客户认证(Strong Customer Authentication,SCA)。SCA 要求至少两种认证因素(你知道的 + 你拥有的 + 你本身的),3DS 2.0 是最主要的合规手段。

但也有一些豁免条件:

条件 是否需要 SCA
消费者和商户都在 EEA ✅ 必须
金额 < €30 且近 5 笔总额 < €100 ❌ 豁免
商户被用户加入白名单 ❌ 豁免
收单机构判定为低风险交易 ❌ 豁免
订阅的首次支付 ✅ 必须
订阅的后续自动扣款 ❌ 豁免

好消息:Stripe 会自动为你处理 SCA 的触发和豁免判断。这也是为什么推荐使用 Payment Intents API(而不是旧版 Charges API)的核心原因。


四、PCI DSS:为什么卡号不能经过你的服务器?

John 在网页上输入了他的 Visa 卡号 4242 4242 4242 4242。这个卡号是怎么传给 Stripe 的?

如果你以为是"浏览器 → 你的服务器 → Stripe",那你就要面对一个大麻烦了。

4.1 PCI DSS 是什么?

PCI DSS(Payment Card Industry Data Security Standard)是由 Visa、Mastercard 等组织联合制定的信用卡数据安全标准。只要你的系统存储、处理或传输了信用卡数据,就必须遵守。

合规等级从低到高:

集成方式 卡号是否经过你的服务器 合规等级 工作量
Stripe Elements(推荐) ❌ 不经过 SAQ A-EP 填一张问卷
Stripe.js + Token ❌ 不经过 SAQ A-EP 填一张问卷
自建表单 + 直接调 API ✅ 经过 SAQ D 300+ 项安全审计

4.2 Stripe Elements 的安全设计

当你使用 Stripe Elements 时,页面上的卡号输入框实际上是一个 Stripe 托管的 iframe。John 输入的卡号直接从他的浏览器发送到 Stripe 的服务器——完全不经过你的服务器

John 的浏览器
┌──────────────────────────────────────────┐
│  你的网页                                 │
│  ┌──────────────────────────────────────┐│
│  │ Stripe Elements (iframe)              ││
│  │  卡号:4242 4242 4242 4242            ││ ──直接发送──→ Stripe 服务器
│  │  有效期:12/28    CVC:123            ││               ↓
│  └──────────────────────────────────────┘│          Token 化
│                                          │               ↓
│  [支付 $149.99]                          │          tok_xxxx
│                                          │               ↓
└──────────────────────────────────────────┘     返回给你的服务器
                                                (只有 Token,没有卡号)

你的服务器拿到的只是一个 Token(比如 tok_1MqLRJ2eZvKYlo...),用这个 Token 去调用 Stripe API 扣款。即使这个 Token 泄露了,攻击者也无法用它做任何事——因为 Token 只能在你的账户内使用一次。

这就是为什么用 Stripe Elements 只需要填一张问卷就能合规,而自建表单要做 300+ 项审计——因为你的服务器从未碰过真实卡号


五、动手:Stripe 信用卡支付全流程

理论讲完了,让我们来写代码。Stripe 的对接分为前端和后端两部分。

5.1 接入准备

第一步:注册 Stripe 账号(stripe.com)
    ▼
第二步:获取 API 密钥
         ├── Publishable Key(pk_test_xxx)—— 前端使用,可公开
         └── Secret Key(sk_test_xxx)—— 后端使用,绝不暴露到前端
    ▼
第三步:配置 Webhook Endpoint + 获取 Signing Secret(whsec_xxx)
    ▼
第四步:开始沙盒开发(test 模式密钥自动启用沙盒)

Stripe 的密钥体系比支付宝/微信清晰得多:

密钥类型 前缀 用途 安全级别
Publishable Key pk_test_ / pk_live_ 前端初始化 Stripe.js 可公开
Secret Key sk_test_ / sk_live_ 后端 API 调用 🔴 绝密
Restricted Key rk_test_ / rk_live_ 后端,限定权限 🔴 绝密
Webhook Secret whsec_ 验证 Webhook 签名 🔴 绝密

5.2 整体流程

Stripe 的 Payment Intents 流程和支付宝/微信有一个关键区别:前端直接和 Stripe 通信,后端只负责创建 PaymentIntent 和处理 Webhook。

John 的浏览器              你的后端服务器           Stripe API
    │                         │                      │
    │ ① 点击「支付」           │                      │
    │────────────────────────→│                      │
    │                         │ ② 创建 PaymentIntent   │
    │                         │─────────────────────→│
    │                         │ ③ 返回 client_secret   │
    │                         │←─────────────────────│
    │ ④ 拿到 client_secret    │                      │
    │←────────────────────────│                      │
    │                         │                      │
    │ ⑤ Stripe.js 收集卡号,直接发给 Stripe           │
    │─────────────────────────────────────────────→│
    │                         │                      │
    │ ⑥ 如需 3DS,自动弹出验证窗口                    │
    │←─────────────────────────────────────────────│
    │ ⑦ John 完成验证                                │
    │─────────────────────────────────────────────→│
    │                         │                      │
    │ ⑧ 前端收到支付结果       │                      │
    │←─────────────────────────────────────────────│
    │                         │ ⑨ Webhook 通知        │
    │                         │←─────────────────────│
    │                         │ ⑩ 更新订单状态         │

关键点:卡号从始至终不经过你的服务器。你的后端只处理 client_secret 和 Webhook。

5.3 后端实现(Python + FastAPI)

import stripe
import os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET")

@app.post("/api/pay/stripe/create-payment-intent")
async def create_payment_intent(request: Request):
    """John 点击支付后,后端创建一个 PaymentIntent"""
    body = await request.json()
    
    try:
        intent = stripe.PaymentIntent.create(
            amount=int(float(body["amount"]) * 100),  # 和微信一样,单位是分!
            currency=body.get("currency", "usd"),
            payment_method_types=["card"],
            metadata={
                "order_id": body["order_id"],      # 你的订单号
                "user_id": body.get("user_id"),
            },
            # 如果需要手动捕获(先授权后扣款),加上:
            # capture_method="manual",
        )
        
        # 返回 client_secret 给前端
        # ⚠️ client_secret 可以安全地传给前端,它只能用来确认这一笔支付
        return {
            "client_secret": intent.client_secret,
            "payment_intent_id": intent.id,
        }
    except stripe.error.StripeError as e:
        raise HTTPException(status_code=400, detail=str(e))

5.4 前端实现(Stripe.js + Elements)

前端是 Stripe 对接中最精彩的部分——看看不到 50 行代码怎么实现一个安全的信用卡支付:

<!-- 引入 Stripe.js —— 必须从 Stripe CDN 加载,这是 PCI 合规要求 -->
<script src="https://js.stripe.com/v3/"></script>

<div id="payment-form">
    <!-- 这个 div 会被 Stripe Elements 接管,变成一个安全的卡号输入框 -->
    <div id="card-element"></div>
    <button id="pay-button">支付 $149.99</button>
    <div id="payment-result"></div>
</div>
// 1. 初始化 Stripe.js(用 Publishable Key,可公开)
const stripe = Stripe('pk_test_your_publishable_key');
const elements = stripe.elements();

// 2. 创建卡号输入组件(这是一个 Stripe 托管的 iframe)
const cardElement = elements.create('card', {
    style: {
        base: {
            fontSize: '16px',
            color: '#32325d',
            '::placeholder': { color: '#aab7c4' },
        },
    },
});
cardElement.mount('#card-element');

// 3. 处理支付
document.getElementById('pay-button').addEventListener('click', async () => {
    // 3a. 先从你的后端获取 client_secret
    const res = await fetch('/api/pay/stripe/create-payment-intent', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            amount: 149.99,
            currency: 'usd',
            order_id: 'ORD_20260404001',
        }),
    });
    const { client_secret } = await res.json();
    
    // 3b. Stripe.js 确认支付——卡号直接从 iframe 发送到 Stripe
    //     如果需要 3DS,Stripe.js 会自动弹出验证窗口
    const { paymentIntent, error } = await stripe.confirmCardPayment(
        client_secret,
        {
            payment_method: {
                card: cardElement,
                billing_details: {
                    name: 'John Doe',
                    email: 'john@example.com',
                },
            },
        }
    );
    
    // 3c. 处理结果
    if (error) {
        document.getElementById('payment-result').textContent = error.message;
    } else if (paymentIntent.status === 'succeeded') {
        document.getElementById('payment-result').textContent = '支付成功!🎉';
    }
});

就这么多。Stripe.js 在背后帮你处理了卡号传输、Token 化、3DS 弹窗——你的代码只需要关心 client_secret 和最终结果。

5.5 Webhook 处理

和支付宝/微信一样,Stripe 也通过异步通知(Webhook)告诉你支付结果。但 Stripe 的验签简单得多——用 HMAC-SHA256,SDK 一行代码搞定:

@app.post("/api/pay/stripe/webhook")
async def stripe_webhook(request: Request):
    """Stripe Webhook 处理"""
    payload = await request.body()
    sig_header = request.headers.get("Stripe-Signature")
    
    # 1. 验签——一行代码搞定
    try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, WEBHOOK_SECRET
        )
    except (ValueError, stripe.error.SignatureVerificationError):
        raise HTTPException(status_code=400, detail="Invalid signature")
    
    # 2. 处理不同事件
    if event["type"] == "payment_intent.succeeded":
        intent = event["data"]["object"]
        order_id = intent["metadata"]["order_id"]
        
        # 幂等检查
        order = await get_order(order_id)
        if order.status == "PAID":
            return {"status": "ok"}
        
        await update_order_status(
            order_id, "PAID",
            transaction_id=intent["id"],
            amount=intent["amount"] / 100,  # 分 → 元
            currency=intent["currency"]
        )
    
    elif event["type"] == "payment_intent.payment_failed":
        intent = event["data"]["object"]
        order_id = intent["metadata"]["order_id"]
        error_msg = intent.get("last_payment_error", {}).get("message", "Unknown")
        await update_order_status(order_id, "FAILED", error=error_msg)
    
    return {"status": "ok"}

对比一下三大渠道的验签复杂度:

  • 支付宝:RSA 非对称验签,需要公钥证书(★★★☆)
  • 微信支付:RSA 验签 + AES-GCM 解密,最复杂(★★★★)
  • Stripe:HMAC-SHA256,SDK 一行代码(★★☆☆)

Stripe 的回调重试策略:在 72 小时内使用指数退避重试。你的 Webhook 端点需要在收到通知后返回 HTTP 200。


六、Payment Intents 状态机

理解 Payment Intents 的状态流转,能帮你在调试时快速定位问题。

Stripe PaymentIntent 状态流转:

    requires_payment_method    ← 刚创建,等待用户选择支付方式
        │
        ▼
    requires_confirmation      ← 等待前端确认
        │
        ├── 无需 3DS ─────────→ succeeded(或 requires_capture)
        │
        └── 需要 3DS ─────────→ requires_action
                                    │
                                    ▼
                            Stripe.js 自动弹出 3DS 验证窗口
                                    │
                                    ├── 验证成功 → succeeded ✅
                                    └── 验证失败 → requires_payment_method ❌

当你设置了 capture_method="manual" 时,支付成功后状态会停在 requires_capture 而不是 succeeded,等你手动调用 capture 后才变为 succeeded


七、争议与拒付:信用卡最大的风险

让我们回到 John 的故事。假设 John 收到鞋子后,过了一周向 Chase 银行发起了一个争议——"我没在这个网站买过东西"(也许是他的室友偷用了他的卡,也许他就是想白嫖)。

这就是信用卡特有的机制——拒付(Chargeback)

7.1 争议处理流程

John 向银行投诉 → Chase 冻结 $149.99 → Stripe 通知你 → 你提交证据 → Chase 裁决
                                                        ↑
                                                 45 天内提交证据
                                          (发货证明、签收记录、聊天记录等)

Stripe 的争议状态会经历这些阶段:

  • warning_needs_response:预警,还没正式立案
  • needs_response:需要你在截止日期前提交证据
  • under_review:银行审核中
  • won:你赢了,钱回来了 🎉
  • lost:你输了,钱没了 😢

7.2 争议的代价

每次争议,Stripe 会收取 $15 的争议处理费。好消息是:如果你胜诉,这 $15 会退还。败诉则不退。

更大的风险是:如果你的争议率超过 1%(每 100 笔交易有超过 1 笔争议),Stripe 可能会限制甚至关闭你的账户。Visa 和 Mastercard 也会把你列入高风险商户名单。

7.3 如何减少争议

✅ 开启 3D Secure——经过 3DS 验证的交易,责任转移给发卡行
✅ 让你的商户名在银行账单上清晰可辨(别让 John 看到一个他不认识的名字)
✅ 发货后及时提供物流追踪号
✅ 遇到退款请求时,快速处理而不是拖着——拖到用户找银行投诉就变成争议了
✅ 在显眼位置展示退换货政策

八、退款

相比争议,退款要简单得多——你主动把钱退给用户。

# 全额退款
refund = stripe.Refund.create(
    payment_intent="pi_xxx",
    reason="requested_by_customer"
)

# 部分退款(比如只退 $50)
refund = stripe.Refund.create(
    payment_intent="pi_xxx",
    amount=5000,  # 单位:分
)

Stripe 退款的几个特点:

  • 没有时间限制(不像支付宝 3 个月、微信 1 年)
  • 手续费退还(不像 PayPal 退款不退手续费)
  • 到账时间:5~10 个工作日(比国内慢,因为要走国际清算网络)
  • 有 Webhook 通知charge.refunded 事件

九、Stripe vs Adyen:怎么选?

如果你做国际信用卡收单,除了 Stripe,另一个绑不开的名字是 Adyen。简单对比:

选 Stripe 如果:你是初创到中大型企业,看重开发体验和文档质量,主要做线上业务。Stripe 的文档堪称业界标杆——几乎所有问题都能在文档里找到答案。费率透明(2.9% + $0.30),在 46 个国家/地区可注册。

选 Adyen 如果:你是中大型到超大型企业,需要线上线下一体化(POS + 在线),需要接入 200+ 种支付方式,或者交易量足够大可以谈到更优惠的阶梯定价。

对于大多数开发者和初创公司,Stripe 是默认选择


十、测试

Stripe 提供了一套完善的测试卡号,你不需要真实信用卡就能在沙盒环境中测试:

卡号 场景
4242 4242 4242 4242 正常支付成功
4000 0025 0000 3155 触发 3DS 验证
4000 0000 0000 9995 支付被拒(余额不足)
4000 0000 0000 0002 支付被拒(卡被拒绝)

所有测试卡的有效期填任意未来日期,CVC 填任意 3 位数字即可。


本系列文章导读

篇目 标题 你将学到
第 1 篇 概览篇——一笔订单触发的支付之旅 四方模型、支付生命周期、市场格局、成本分析、选型决策
第 2 篇 支付宝 & 微信支付——一杯咖啡的扫码之旅 签名机制、回调处理、AES-GCM 解密、完整对接代码
第 3 篇 Stripe & 信用卡——一件跨境商品的卡支付之旅(本文) Payment Intents、3D Secure、PCI DSS、前后端代码
第 4 篇 PayPal——一位海外买家的安全支付之旅 OAuth 认证、Smart Buttons、争议保护、Webhook
第 5 篇 统一支付网关——当四条河流汇入一片海 三层架构、策略模式、幂等设计、对账补偿

参考来源


欢迎关注公众号 coft,获取更多深度技术文章。下一篇,我们跟着伦敦的 Emma,看看 PayPal 是怎么让她敢在一个陌生网站上花 $89 买手工包的。

posted @ 2026-04-04 20:44  warm3snow  阅读(7)  评论(0)    收藏  举报