从 PDP 按钮到订单生成,中间发生了什么?一个前端工程师需要知道的支付链路全貌

16 阅读7分钟

这篇文章是我梳理支付这条链路的过程,目标是:读完之后,你能知道怎么在一个已有的商品详情页或课程详情页里,接入一个完整的支付模块。


先建立心智模型:支付链路全貌

在写任何代码之前,先把这张图看懂:

前端(你的页面)      你的后端        支付网关(Stripe)      银行
      |                  |                   |                  |
      | 1. 点击"购买"    |                   |                  |
      |----------------->|                   |                  |
      |                  | 2. 创建支付意向    |                  |
      |                  |------------------>|                  |
      |                  |<-- client_secret --|                  |
      |<-- client_secret-|                   |                  |
      |                  |                   |                  |
      | 3. 拉起支付界面  |                   |                  |
      |------------------------------------ >|                  |
      |                  |                   | 4. 和银行通信     |
      |                  |                   |----------------->|
      |                  |                   |<-- 授权结果 ------|
      |<-- 重定向回页面--|                   |                  |
      |                  |                   |                  |
      |                  |<-- 5. Webhook 通知(payment.succeeded)
      |                  | 6. create order   |                  |
      |                  | 7. 扣款(Capture)|----------------->|

几个关键认知:

前端不碰卡号。 信用卡信息直接进入 Stripe 的表单,你的代码完全接触不到。这不是限制,是设计——如果卡号经过你的服务器,你需要通过 PCI DSS 合规认证,成本极高。Stripe 帮你把这个问题消灭了。

订单在支付成功之后才创建。 不是用户点击"购买"时创建,而是收到 Stripe 的 Webhook 通知后才创建。钱到了,才有订单——订单是交易完成的凭证,不是交易的开始。

授权和扣款是两步。 Stripe 先向银行确认"这张卡有没有这笔钱"(Authorization),真正把钱划走(Capture)可以延迟到发货时。这对实物电商很重要,对数字商品通常合并成一步。


前端只需要做三件事

理解了全貌,前端的工作其实很聚焦:初始化 SDK、拉起支付、处理回调。

第一件事:初始化 Stripe SDK

// 环境:Next.js,需安装 @stripe/stripe-js
// 在应用入口处初始化,避免重复加载

import { loadStripe } from '@stripe/stripe-js'

// NEXT_PUBLIC_ 前缀表示这是可以暴露给前端的公钥
// 私钥只能在后端使用,绝对不能出现在前端代码里
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_KEY!)

loadStripe 会异步加载 Stripe 的 JS 文件,返回一个 Promise。把它定义在模块级别(组件外部),确保整个应用只初始化一次。

第二件事:拉起支付界面

点击"购买"按钮时,需要先告诉后端"我要买什么",后端创建支付意向,前端拿到凭证后拉起 Stripe:

// 环境:React 组件
// 场景:PDP 或课程详情页的购买按钮

async function handlePurchase(productId: string, price: number) {
  try {
    // 第一步:让后端创建支付意向
    // 后端调用 Stripe API,返回 sessionId
    const response = await fetch('/api/checkout/create-session', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ productId, price }),
    })

    if (!response.ok) {
      throw new Error('创建支付会话失败')
    }

    const { sessionId } = await response.json()

    // 第二步:跳转到 Stripe 托管的支付页面
    const stripe = await stripePromise
    const { error } = await stripe!.redirectToCheckout({ sessionId })

    // 只有跳转失败才会执行到这里
    if (error) {
      console.error('跳转支付页面失败:', error.message)
    }
  } catch (err) {
    console.error('支付流程出错:', err)
  }
}

// 在组件里使用
function ProductDetailPage({ product }: { product: Product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>¥{product.price}</p>
      <button onClick={() => handlePurchase(product.id, product.price)}>
        立即购买
      </button>
    </div>
  )
}

第三件事:处理支付回调

用户在 Stripe 完成支付后,Stripe 会把用户重定向回你配置的页面。通常需要两个 URL:

// 环境:Next.js API Route(后端部分)
// 场景:创建 Stripe Checkout Session

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const { productId, price } = await request.json()

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    line_items: [
      {
        price_data: {
          currency: 'cny',
          product_data: { name: productId },
          unit_amount: price * 100, // Stripe 使用最小货币单位(分)
        },
        quantity: 1,
      },
    ],
    mode: 'payment',
    // 支付成功后跳回的页面
    success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/order/success?session_id={CHECKOUT_SESSION_ID}`,
    // 用户取消支付后跳回的页面
    cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/checkout/cancelled`,
  })

  return Response.json({ sessionId: session.id })
}

成功页面读取 session_id,展示订单确认信息:

// 环境:Next.js,pages/order/success.tsx
// 场景:支付成功回调页面

export default function OrderSuccess() {
  const router = useRouter()
  const { session_id } = router.query

  // 用 session_id 查询订单状态,展示确认信息
  // 真正的订单创建发生在后端 Webhook 里,不在这里
  return (
    <div>
      <h1>支付成功</h1>
      <p>订单正在处理中,稍后会收到确认邮件</p>
    </div>
  )
}

注意:这个页面只用来展示"支付成功"的反馈,不要在这里 create order。原因是用户可能关掉页面、网络中断,导致这个页面根本不会被执行。真正可靠的 order 创建,必须在 Webhook 里做。


Webhook:支付链路里最容易被忽视的环节

很多教程到"跳转成功页"就结束了,但这是不完整的。

Webhook 是 Stripe 主动通知你的后端"支付成功了"的机制。不管用户的浏览器发生了什么,这个通知都会到达:

// 环境:Next.js API Route
// 场景:接收 Stripe Webhook 通知

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: Request) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')!

  let event: Stripe.Event

  try {
    // 验证这个请求确实来自 Stripe,而不是伪造的
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return new Response('Webhook 签名验证失败', { status: 400 })
  }

  // 根据事件类型处理
  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object as Stripe.Checkout.Session
      // 在这里 create order,触发库存扣减、发送确认邮件等
      await createOrder(session)
      break

    case 'payment_intent.payment_failed':
      // 支付失败,通知用户
      await handlePaymentFailure(event.data.object)
      break
  }

  return new Response('ok', { status: 200 })
}

两种接入方式:跳转 vs 内嵌

上面的代码用的是 Hosted Checkout——用户跳到 Stripe 的页面填卡。这是集成最简单的方式,适合快速上线。

另一种方式是 Stripe Elements——把卡号表单嵌在你自己的页面里,用户不需要跳出去:

// 环境:React,需安装 @stripe/react-stripe-js
// 场景:在自己页面内嵌入支付表单

import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js'

function CheckoutForm() {
  const stripe = useStripe()
  const elements = useElements()

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!stripe || !elements) return

    const cardElement = elements.getElement(CardElement)!

    // confirmCardPayment 把卡信息直接发给 Stripe,不经过你的服务器
    const { error, paymentIntent } = await stripe.confirmCardPayment(
      clientSecret, // 从后端获取
      { payment_method: { card: cardElement } }
    )

    if (error) {
      console.error('支付失败:', error.message)
    } else if (paymentIntent.status === 'succeeded') {
      // 支付成功,等待 Webhook 创建订单
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <CardElement />  {/* Stripe 的安全 iframe,你无法读取其中的卡号 */}
      <button type="submit">确认支付</button>
    </form>
  )
}

// 用 Elements Provider 包裹
function PaymentPage({ clientSecret }: { clientSecret: string }) {
  return (
    <Elements stripe={stripePromise} options={{ clientSecret }}>
      <CheckoutForm />
    </Elements>
  )
}

怎么选:

Hosted CheckoutStripe Elements
集成复杂度低,几行代码中,需要处理表单状态
用户体验跳出页面,有跳出感留在你的页面,体验流畅
样式定制有限完全可定制
适合场景快速上线、MVP对体验要求高的产品

国内场景:支付宝和微信支付

如果你的用户在国内,流程是一样的,只是 SDK 和 API 名字不同:

Stripe 概念          →   国内对应
─────────────────────────────────────────
loadStripe()         →   wx.config() / AlipayJSBridge.ready()
Payment Intent       →   统一下单接口(prepay_id / trade_no)
redirectToCheckout   →   wx.requestPayment() / 跳转支付宝收银台
Webhook              →   支付结果异步通知(需要验签)
client_secret        →   prepay_id(微信)/ out_trade_no(支付宝)

国内支付比 Stripe 复杂的地方在于多端适配——同一个支付功能,在 PC 浏览器、手机浏览器、微信内 H5、微信小程序里,拉起支付的方式都不同:

// 场景:判断环境,选择对应的支付拉起方式

function launchWechatPay(prepayId: string) {
  const ua = navigator.userAgent.toLowerCase()
  const isWechat = ua.includes('micromessenger')
  const isMiniProgram = window.__wxjs_environment === 'miniprogram'

  if (isMiniProgram) {
    // 小程序环境
    wx.requestPayment({ /* 支付参数 */ })
  } else if (isWechat) {
    // 微信浏览器,使用 JSAPI
    WeixinJSBridge.invoke('getBrandWCPayRequest', { /* 参数 */ })
  } else {
    // 普通浏览器,跳转收银台或展示二维码
    window.location.href = `https://wx.tenpay.com/...`
  }
}

这也是国内电商前端比较有挑战的部分——同一套支付逻辑要处理很多种环境。


小结

从 PDP 按钮到订单生成,前端实际需要理解和处理的只有几个关键点:

  1. 不碰卡号——所有敏感信息交给 SDK 处理
  2. 后端创建支付意向——前端只是发起请求,拿凭证
  3. Webhook 才是可靠的订单创建时机——不要依赖前端回调
  4. 授权和扣款是两步——理解这个,才能处理各种异常情况

支付链路本身不复杂,复杂的是各种异常情况的处理:支付超时、重复支付、退款、对账。这些是下一个层次的话题,也是真实电商项目里花时间最多的地方。


还没想清楚的地方

支付完成的那一刻,是整条电商链路里信息密度最高的时刻——用户真实购买意图第一次被确认,品类偏好、价格敏感度、购买时机全部显现。

这份数据,AI 能做什么?

是在支付成功页推荐关联商品,还是在 Webhook 触发时更新用户画像,还是用来预测下一次复购时机?


参考资料