PAYATHON 2026

Checkout payment 前端实现:支付弹窗与凭证说明

支付老李

结论

支付按钮和支付弹窗可以放在前端,但完整的 Checkout payment 流程不能只靠前端完成。

前端可以处理:

  • 显示订单金额和“Pay”按钮
  • 调用支付服务商提供的 SDK 或托管支付组件
  • 打开支付弹窗、跳转到收银台或渲染安全输入框
  • 向用户显示支付结果

后端需要处理:

  • 按照可信的商品价格创建订单
  • 使用 client_secret 或其他私钥调用支付 API
  • 确认、捕获或查询支付状态
  • 验证 Webhook 签名
  • 防止金额被篡改、重复支付或支付结果被伪造

不要用普通表单自行接收和传输银行卡号、CVV 等敏感数据。应优先采用支付服务商提供的 Hosted Fields、Elements、Checkout SDK 或托管收银台,让敏感数据直接进入支付服务商的系统。

client_id 和 client_secret 是什么

client_id

client_id 是应用的公开标识,用来告诉支付平台“当前请求来自哪个应用”。

有些支付 SDK 允许浏览器使用 client_id,所以它通常不属于密码。不过,仍应通过配置统一管理,不要把它随意硬编码到多个文件中。

client_secret

client_secret 是应用的机密凭证,作用类似密码。后端通常用它来:

  • 换取访问令牌
  • 创建或捕获支付订单
  • 调用需要身份认证的支付 API

绝对不要把 client_secret 写入前端 JavaScript、HTML、移动端安装包或公开仓库。即使用构建工具的环境变量注入,只要它最终进入浏览器代码,用户就能查看。

Frontend:
client_id     可能允许使用,取决于支付服务商
client_secret 禁止使用

Backend:
client_id     可以使用
client_secret 必须仅保存在服务端

具体权限以所选支付服务商的文档为准。

推荐的支付流程

常见的安全支付流程是:

  1. 用户点击“Pay”。
  2. 前端请求自己的后端创建支付订单。
  3. 后端从数据库读取商品价格,不接受前端提交的金额作为可信依据。
  4. 后端使用私密凭证调用支付服务商 API。
  5. 后端将支付订单 ID 或短期客户端令牌返回给前端。
  6. 前端通过支付 SDK 打开弹窗或托管收银台。
  7. 用户完成支付授权。
  8. 后端确认或捕获支付,并记录最终状态。
  9. 收到 Webhook 后,后端再次核对订单号、金额、币种和支付状态。
Browser
   │  POST /api/checkout/orders
   ▼
Your backend
   │  使用 client_secret 创建支付订单
   ▼
Payment provider
   │
   └── 返回 orderId / client token
               │
               ▼
       前端 SDK 弹窗或托管页面

前端示例

下面的示例不对应某个具体支付平台。实际项目中,需要把 openProviderCheckout 替换成支付服务商 SDK 提供的方法。

<button id="pay-button" type="button">Pay</button>
<p id="payment-status" role="status"></p>

<script type="module">
  const payButton = document.querySelector("#pay-button");
  const statusElement = document.querySelector("#payment-status");

  payButton.addEventListener("click", async () => {
    payButton.disabled = true;
    statusElement.textContent = "正在创建支付订单…";

    try {
      const response = await fetch("/api/checkout/orders", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        credentials: "same-origin",
        body: JSON.stringify({
          productId: "product_123",
          quantity: 1
        })
      });

      if (!response.ok) {
        throw new Error("创建支付订单失败");
      }

      const { orderId, clientToken } = await response.json();

      // 此函数代表支付服务商提供的官方 SDK。
      // 不要在普通输入框中自行采集银行卡号或 CVV。
      const result = await openProviderCheckout({
        orderId,
        clientToken
      });

      if (result.status === "approved") {
        const captureResponse = await fetch(
          `/api/checkout/orders/${encodeURIComponent(orderId)}/capture`,
          {
            method: "POST",
            credentials: "same-origin"
          }
        );

        if (!captureResponse.ok) {
          throw new Error("支付确认失败");
        }

        const payment = await captureResponse.json();
        statusElement.textContent =
          payment.status === "completed"
            ? "支付成功"
            : "支付正在处理中";
      }
    } catch (error) {
      console.error(error);
      statusElement.textContent = "支付未完成,请稍后重试。";
    } finally {
      payButton.disabled = false;
    }
  });
</script>

这里的 clientToken 只是后端生成的短期令牌示例,并非 client_secret。不同支付平台可能返回 orderId、paymentIntentClientSecret、审批 URL 或其他值,具体处理方式应遵循相应 SDK 的要求。

后端示例

下面用 Node.js 和 Express 说明前后端的职责划分。paymentProvider 代表支付服务商的服务端 SDK,不是某个真实的软件包。

import express from "express";

const app = express();

app.use(express.json());

app.post("/api/checkout/orders", async (req, res) => {
  try {
    const { productId, quantity } = req.body;

    // 必须从数据库读取商品价格,不能直接使用前端提交的金额。
    const product = await database.products.findById(productId);

    if (!product || !Number.isInteger(quantity) || quantity < 1) {
      return res.status(400).json({ error: "Invalid order data" });
    }

    const amount = product.unitPrice * quantity;

    const order = await paymentProvider.orders.create({
      amount,
      currency: product.currency,
      reference: crypto.randomUUID()
    });

    res.status(201).json({
      orderId: order.id,
      clientToken: order.clientToken
    });
  } catch (error) {
    console.error("Failed to create payment order", error);
    res.status(502).json({ error: "Unable to create payment order" });
  }
});

app.post("/api/checkout/orders/:orderId/capture", async (req, res) => {
  try {
    const payment = await paymentProvider.orders.capture(
      req.params.orderId
    );

    // 返回结果前还应核对金额、币种、订单归属及当前状态。
    res.json({
      status: payment.status
    });
  } catch (error) {
    console.error("Failed to capture payment", error);
    res.status(502).json({ error: "Unable to confirm payment" });
  }
});

服务端凭证应从受保护的环境变量或密钥管理系统中读取:

const paymentProvider = createPaymentClient({
  clientId: process.env.PAYMENT_CLIENT_ID,
  clientSecret: process.env.PAYMENT_CLIENT_SECRET
});

服务端环境变量不要使用前端公开前缀,例如某些构建工具中的 VITE_*、NEXT_PUBLIC_* 或 PUBLIC_*。否则,这些变量可能被打包进浏览器代码。

是否需要自己实现支付弹窗

通常不需要。支付弹窗需要处理敏感数据、验证规则、3-D Secure、错误状态和合规要求,自行实现的成本很高。

可以根据需求选择:

  • 托管收银台:用户跳转到支付服务商的页面,集成最简单。
  • 官方弹窗或按钮 SDK:支付流程基本留在当前页面。
  • Hosted Fields 或 Elements:界面可以定制,但敏感字段仍由支付服务商安全托管。

收货地址、优惠码或发票信息可以放在自己的 dialog 中。银行卡号、CVV 等支付凭证则应由官方支付组件处理。

需要特别注意的事项

  • 全程使用 HTTPS。
  • 不要信任前端传来的金额、币种或支付成功状态。
  • 不要记录银行卡号、CVV、访问令牌或 client_secret。
  • 创建订单和捕获支付时应设置幂等机制,避免用户重复点击导致重复扣款。
  • 用户点击支付按钮后,应暂时禁用按钮,并显示加载、取消和失败提示。
  • 发货或开通服务前,后端必须查询支付状态或验证 Webhook。
  • Webhook 必须验证签名,还要处理重复通知和乱序通知。
  • 测试环境和生产环境应使用不同的凭证。
  • 如果 client_secret 曾经进入前端代码、日志或公开仓库,应立即在支付平台将其撤销并重新生成。
  • 处理银行卡数据可能涉及 PCI DSS 等合规要求。使用支付服务商的托管组件通常可以缩小合规范围,但具体责任仍需根据服务商和业务所在地区确认。

备注:内容仅供参考。