Stripe 支付实现方法详解(AI 视频下载总结器的全球付费)

跨国收单的自建成本,最终把选择推向了 Stripe Checkout 托管支付 + Webhook 验证。原因很简单:目标用户横跨国内和海外,自建收单要面对银行卡合规、3D 验证、风控一堆事,而 Checkout 把这些全托管了,我们不碰卡号,PCI 合规负担直接降到最低。下面的方案覆盖订阅、按次付费、退款、对账,都是线上跑通过的。

一、为什么选 Stripe

支付平台的选择直接决定海外用户能不能付上钱。我对比过 Stripe、支付宝、微信支付、PayPal 四家,重点看覆盖范围和接入成本:

支付平台 覆盖 优势 劣势
Stripe 全球 195 国 API 优秀、文档好 国内访问偶尔慢
支付宝 国内 国内普及 海外用户付不了
微信支付 国内 国内普及 海外用户付不了
PayPal 全球 老牌 费率比 Stripe 高

结论是 Stripe 覆盖面够广、API 体验好,国内用户偶尔慢的问题用备用支付渠道兜底,PayPal 费率偏高只作备选。平台目标用户有海外华人 + 国内用户,选 Stripe(PayPal 备选)。

选型时我还拉过一张更细的评分表,把文档质量、测试环境完备度、退款自动化、汇率支持逐项打分,Stripe 在每一项上都比自建收单省事。真正打动我的是它把 3D 验证、风控、拒付申诉这些脏活全部挡在托管页里,后端只负责在回调里改会员状态。最初我也想过直接对接卡组织收单,算完合规改造和人力成本就放弃了,托管方案省下的时间足够我们把产品体验打磨好几轮。

二、Stripe 核心概念

刚接触 Stripe 时最容易被一堆名词绕晕,理清楚之后其实是一条链:Customer 是用户,Product 是商品,Price 是定价,Subscription 把三者串起来,Webhook 负责把结果推回给我们。

概念 说明
Customer 客户
Product 商品(订阅产品)
Price 定价(月付/年付)
Subscription 订阅(关联 Customer + Price)
Checkout Session 托管支付页
Webhook 事件回调(支付成功/失败)
Customer Portal 用户自助管理订阅

记住一个原则:下单时后端只认 Price ID,金额由 Price 定义,绝不从客户端接收金额。这个原则后面踩坑部分还会展开。

三、订阅商品配置

商品在 Dashboard 里创建,这一步不需要写代码,但 Price ID 要记牢,后面下单全凭它。

Product: AI 视频下载总结器 VIP
├─ Price 1: ¥29 / 月 (recurring, monthly)
├─ Price 2: ¥299 / 年 (recurring, yearly)
└─ Price 3: ¥99 / 季度 (recurring, quarterly)

记录 Price ID:

price_monthly = 'price_xxx'
price_yearly = 'price_yyy'

我把三个 Price ID 放进环境变量而不是写死,这样 staging 和生产可以各自配置不同价格做测试。

四、创建 Checkout Session

下单接口的核心是创建一个 Checkout Session,把用户、商品、跳转地址都交给 Stripe:

import stripe
from fastapi import APIRouter

stripe.api_key = settings.STRIPE_SECRET_KEY

router = APIRouter()

@router.post('/api/payment/create-session')
async def create_session(
    plan: str,  # 'monthly' | 'yearly' | 'quarterly'
    user: User = Depends(get_current_user)
):
    price_id = {
        'monthly': settings.STRIPE_PRICE_MONTHLY,
        'yearly': settings.STRIPE_PRICE_YEARLY,
        'quarterly': settings.STRIPE_PRICE_QUARTERLY
    }[plan]
    
    # 已有 customer 则复用
    customer_id = await get_or_create_customer(user)
    
    try:
        session = stripe.checkout.Session.create(
            customer=customer_id,
            payment_method_types=['card'],
            line_items=[{
                'price': price_id,
                'quantity': 1
            }],
            mode='subscription',
            success_url=f'{settings.FRONTEND_URL}/payment/success'
                        f'?session_id={{CHECKOUT_SESSION_ID}}',
            cancel_url=f'{settings.FRONTEND_URL}/pricing',
            metadata={
                'user_id': str(user.id),
                'plan': plan
            }
        )
        return {'session_id': session.id, 
                'url': session.url}
    except stripe.error.StripeError as e:
        raise HTTPException(400, str(e))

session.url 直接返回给前端跳转,整个支付页、卡号输入、3D 验证都在 Stripe 托管页里完成,我们的服务器全程不接触卡号。

五、前端跳转支付

前端拿到 url 后整页跳转即可:

async function subscribe(plan) {
    const { data } = await api.post('/api/payment/create-session', { plan })
    // 跳转到 Stripe 托管支付页
    window.location.href = data.url
}

Stripe Checkout 托管卡号输入、3D 验证、风控,前端不接触敏感数据。注意别用内嵌框架的方式打开支付页,直接整页跳转最省心。

六、支付成功回调

支付完成后 Stripe 通过 Webhook 通知我们,这是真正激活 VIP 的地方。核心是先验签再处理:

@router.post('/api/payment/webhook')
async def stripe_webhook(request: Request):
    payload = await request.body()
    sig_header = request.headers.get('stripe-signature')
    
    try:
        # 验证签名(关键!防伪造)
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except ValueError:
        raise HTTPException(400, "Invalid payload")
    except stripe.error.SignatureVerificationError:
        raise HTTPException(400, "Invalid signature")
    
    # 处理事件
    if event['type'] == 'checkout.session.completed':
        await handle_checkout_completed(event['data']['object'])
    elif event['type'] == 'customer.subscription.updated':
        await handle_subscription_updated(event['data']['object'])
    elif event['type'] == 'customer.subscription.deleted':
        await handle_subscription_deleted(event['data']['object'])
    elif event['type'] == 'invoice.payment_failed':
        await handle_payment_failed(event['data']['object'])
    
    return {'received': True}

async def handle_checkout_completed(session):
    """支付成功,激活 VIP"""
    user_id = int(session['metadata']['user_id'])
    subscription_id = session['subscription']
    customer_id = session['customer']
    
    # 查询订阅详情
    subscription = stripe.Subscription.retrieve(subscription_id)
    
    user = await user_service.get(user_id)
    user.is_vip = True
    user.vip_expires_at = datetime.fromtimestamp(
        subscription.current_period_end
    )
    user.stripe_customer_id = customer_id
    user.stripe_subscription_id = subscription_id
    await db.commit()
    
    # 发邮件通知
    await email_service.send_vip_active(user)

验签必须用原始报文,先解析 JSON 再验会失败,这一步我栽过跟头,安全章节还会细讲。收到事件先回 200,业务处理放在验签通过之后,避免 Stripe 一直重试。

七、Webhook 幂等

Stripe 的投递是”至少一次”,同一个事件可能收到两遍,所以幂等去重是必须的:

async def handle_event(event):
    event_id = event['id']
    
    # 检查是否处理过
    if await redis.exists(f'stripe_event:{event_id}'):
        return  # 已处理,跳过
    
    # 标记为已处理
    await redis.setex(f'stripe_event:{event_id}', 86400, '1')
    
    # 业务逻辑
    if event['type'] == '...':
        await do_something(event)

去重键用事件 id,处理前先查、处理后写入,重复投递直接跳过。这一招避免了”支付两次、VIP 激活两次”的事故。

八、用户订阅查询

用户在前端看自己的会员状态,不用维护本地状态,直接以 Stripe 为事实源查询:

@router.get('/api/payment/subscription')
async def get_subscription(user: User = Depends(get_current_user)):
    if not user.stripe_subscription_id:
        return {'is_vip': False}
    
    try:
        sub = stripe.Subscription.retrieve(user.stripe_subscription_id)
        return {
            'is_vip': sub.status == 'active',
            'plan': sub.items.data[0].price.nickname,
            'current_period_end': sub.current_period_end,
            'cancel_at_period_end': sub.cancel_at_period_end
        }
    except stripe.error.InvalidRequestError:
        return {'is_vip': False, 'error': 'Subscription not found'}

以 Stripe 的订阅状态为唯一事实源,本地数据库只做展示缓存,状态不一致时靠后台每日对账兜底。

九、用户自助管理

改卡、退订、查账单这类操作,自己写一套既费劲又容易出错,Stripe Customer Portal 直接托管。后端只需创建一个 portal session:

@router.post('/api/payment/customer-portal')
async def create_portal_session(user: User = Depends(get_current_user)):
    """创建 Stripe Customer Portal session"""
    if not user.stripe_customer_id:
        raise HTTPException(400, "No subscription")
    
    session = stripe.billing_portal.Session.create(
        customer=user.stripe_customer_id,
        return_url=f'{settings.FRONTEND_URL}/account'
    )
    return {'url': session.url}

前端按钮同样只要跳转 url:

async function manageSubscription() {
    const { data } = await api.post('/api/payment/customer-portal')
    window.location.href = data.url
}

Stripe Portal 让用户自己改卡、退订、查账单,后端零代码。

十、按次付费

除了订阅,还要支持不常来的用户按次付费。区别只在 mode 从 subscription 换成 payment:

@router.post('/api/payment/one-time')
async def create_one_time_payment(
    user: User = Depends(get_current_user)
):
    customer_id = await get_or_create_customer(user)
    
    session = stripe.checkout.Session.create(
        customer=customer_id,
        payment_method_types=['card'],
        line_items=[{
            'price_data': {
                'currency': 'cny',
                'unit_amount': 100,  # ¥1.00
                'product_data': {'name': '单次视频总结'}
            },
            'quantity': 1
        }],
        mode='payment',  # 注意:不是 subscription
        success_url=f'{settings.FRONTEND_URL}/payment/success'
                    f'?session_id={{CHECKOUT_SESSION_ID}}',
        cancel_url=f'{settings.FRONTEND_URL}/pricing',
        metadata={'user_id': str(user.id), 'type': 'one_time'}
    )
    return {'url': session.url}

Webhook checkout.session.completed 加积分:

if session.metadata.get('type') == 'one_time':
    user.credits += 10  # 10 次
    await db.commit()

加积分同样走事件去重,积分归额度系统管,和订阅是两条独立链路。

十一、退款处理

退款是客服场景的高频需求,我按四步走:

  1. 用 PaymentIntent.retrieve 查出这笔支付;
  2. 校验 customer 是否属于当前用户;
  3. 校验是否还在 7 天退款窗口内;
  4. 执行 Refund.create 并撤销 VIP。

对应代码如下:

@router.post('/api/payment/refund')
async def request_refund(
    payment_id: str,
    user: User = Depends(get_current_user)
):
    """用户申请退款(仅 VIP 订阅 7 天内)"""
    # 1) 查 payment
    payment = stripe.PaymentIntent.retrieve(payment_id)
    
    # 2) 校验是否是本人的支付
    if payment.customer != user.stripe_customer_id:
        raise HTTPException(403, "Not your payment")
    
    # 3) 校验时间(7 天内)
    if datetime.now() - datetime.fromtimestamp(payment.created) > timedelta(days=7):
        raise HTTPException(400, "Refund window expired")
    
    # 4) 退款
    refund = stripe.Refund.create(payment_intent=payment_id)
    
    # 5) 撤销 VIP
    user.is_vip = False
    await db.commit()
    
    return {'refund_id': refund.id}

退款后同步撤销 VIP,避免用户既拿到钱又继续用会员。

退款流程里我额外加了两道闸:第一步校验这笔支付是不是当前用户的,线上确实碰到过拿着别人订单号来申请退款的请求,customer 不匹配直接 403 挡回去;第二步卡 7 天窗口,这个期限是产品侧定的,和 Stripe 无关,哪天想放宽改一处常量就行。退款接口上线前我在测试模式用真实卡号退了一遍,确认金额原路退回、会员状态同步关闭,才敢放给客服用。

十二、税务与发票

Stripe 自动处理:

  • 各国税率(VAT/GST/销售税);
  • 客户发票(用户在 Stripe Portal 下载);
  • 中国大陆暂不支持自动开票,需手动。

税务这块的边界在于:各国税率 Stripe 能自动代收,客户发票能在 Portal 自助下载,但中国大陆的自动开票还没铺开,需要手动对账,所以我们把月度交易导出来给财务:

# 每月导出交易给财务
def export_monthly_transactions(year: int, month: int):
    """导出月度交易给财务"""
    since = datetime(year, month, 1).timestamp()
    until = datetime(year, month + 1, 1).timestamp() if month < 12 else \
           datetime(year + 1, 1, 1).timestamp()
    
    charges = stripe.Charge.list(
        created={'gte': since, 'lt': until},
        limit=100
    )
    
    df = pd.DataFrame([{
        'date': datetime.fromtimestamp(c.created),
        'amount': c.amount / 100,
        'currency': c.currency,
        'user_email': c.billing_details.email
    } for c in charges.data])
    
    df.to_excel(f'transactions_{year}_{month}.xlsx')

字段保持最简,够财务对账用就行,别在报表里塞一堆没人看的列。

十三、安全最佳实践

支付安全没有侥幸,下面几条是从线上事故和代码审计里沉淀出来的。

四条里有两条来自线上事故,两条来自接入初期的自查。Webhook 验签那条最不能省:webhook 端点对公网开放,攻击者只要照着文档拼一个 checkout.session.completed 事件,把 type 字段填对,再伪造一个合法用户 id,就能在完全不付钱的情况下把会员刷出来。验签是挡在这条路中间的唯一一道门,签名过不了直接 400,不带任何业务处理。

1. Webhook 验签

# 关键!必须验签防伪造
event = stripe.Webhook.construct_event(
    payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)

签名验不过直接 400,不让攻击者拿伪造事件刷 VIP。

2. 不用前端传 amount

# ❌ 前端传 amount
amount = request.json['amount']

# ✅ 后端根据 plan 查价格
amount = PLAN_PRICES[plan]

金额永远以服务端 Price 为准,前端传来的数字一个字都不能信。

3. HTTPS 强制

支付页面强制 HTTPS,Token 不在 HTTP 环境传输。

4. PCI 合规

用 Stripe Checkout 后,平台不接触卡号,SAQ A 级别合规(最简)。

Checkout 模式下我们全程不接触卡号,走的是 PCI 里最轻的一档;真要自建卡号采集,合规成本完全不是一个量级。

十四、监控

支付链路挂了用户直接付不了钱,我埋了几组关键指标:

metrics.counter('stripe.checkout.create', 
    'plan', plan, 'result', 'success').increment()
metrics.counter('stripe.webhook', 
    'event', event_type, 'result', 'success').increment()
metrics.counter('stripe.webhook', 
    'event', event_type, 'result', 'fail', 'reason', 'invalid_signature').increment()

监控异常事件、签名失败(可能是攻击)。

签名失败次数飙高多半是有人试伪造事件,这类告警我给的优先级比较高。

十五、踩过的坑

这些坑基本是每接一个就踩一个,整理如下:

  • Webhook 接收地址必须 HTTPS:开发时用 stripe listen --forward-to localhost:8000/api/payment/webhook。
  • Webhook 重放:同一事件可能多次发送,幂等去重必须。
  • 货币单位:Stripe 用最小单位(分),不要传 29.00 而要 2900。
  • Customer Portal 需要配置:Stripe Dashboard → Settings → Billing → Customer Portal 启用。
  • 中国大陆访问 Stripe:偶尔慢。需要备用支付(支付宝)。
  • 失败支付恢复:invoice.payment_failed 时给用户宽限期(3 天),期间还能用。
  • 订阅升级/降级:用户从月付改年付,Stripe 自动按比例结算。
  • 税务:B2C 销售平台通常要代扣税。Stripe Tax 自动化但要开通。

这份清单是按踩坑成本排的序,代价大的案例是货币单位那次:staging 里把 ¥29 写成了 29 而不是 2900,账单金额直接错了两个数量级,好在是测试模式没有真实扣款。现在所有涉及金额的断言在测试里都锁死最小单位,谁再拿元当分传,测试直接报红,这类事故从此没再出现过。

十六、测试模式

开发期全程用测试模式,密钥 sk_test 开头,配测试卡号模拟各种结果:

# 测试密钥
stripe.api_key = 'sk_test_xxx'

# 测试卡号
# 4242 4242 4242 4242  -- 成功
# 4000 0000 0000 9995  -- 余额不足
# 4000 0000 0000 0069  -- 过期

平台在 staging 用测试模式,生产用正式密钥。两套环境彻底隔离,防止测试数据混进真实账目。

测试模式里我习惯把关键事件主动触发一遍再上生产:用成功卡号走完整条订阅链路,再用余额不足卡号验证 invoice.payment_failed 分支,确认宽限期逻辑真的生效。开发时用 stripe CLI 的 listen 命令把事件直接打到本地,配合日志逐条看处理路径,比拍脑袋猜逻辑高效得多。测试卡号对应的场景在文档里有清单,每个分支都过一遍,心里才有底。

十七、未来优化

付费能力按需演进,后续打算按这个顺序加:

  1. 多种支付方式:加 Apple Pay / Google Pay / 支付宝;
  2. 优惠券系统:用 Stripe Coupon;
  3. 按用量计费:用 Stripe Meters(API 调用次数);
  4. 自动对账:每日拉取交易,邮件给财务。

每加一项都先在测试模式验证再上生产,这是接入 Stripe 后我给自己定的规矩。

加新支付方式成本大头不在代码,而在每种方式的测试流程和退款差异,所以每一项我都先灰度,让一小撮用户先走新通道,确认成功率稳定再全量放开。这条顺序执行下来,付费功能没有因为上线新通道出过事故,用户侧的支付成功率也一直稳定在预期范围内。

常见问题(FAQ)

Q1:Stripe 收手续费多少?

国内卡 2.9% + 0.3 USD/笔,海外卡 3.5% + 0.3 USD/笔。

Q2:能不用 Webhook 吗?

不能。Webhook 是支付成功的可靠通知,redirect URL 不靠谱(用户可能关掉)。

Q3:怎么防止 webhook 漏单?

用幂等表 + 每日对账脚本(拉所有成功 charge 对比订单)。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
赵其鑫的头像赵其鑫管理团队

相关推荐

返回顶部