PAYATHON 2026

如何在不离开网站的情况下接受信用卡付款?

支付小周

结论

顾客可以留在当前网站完成信用卡付款,但你不应自行收集、存储或处理完整卡号。通常可以选择支付服务商提供的以下方案:

  • 嵌入式支付表单(Hosted Fields / Payment Element)
  • 由支付服务商托管并通过 iframe 嵌入的结账组件
  • 前端令牌化(Tokenization),后端只接收支付令牌并创建付款

采用这些方案后,页面仍可使用你的域名和设计,但卡号、有效期和安全码会直接提交给支付服务商,不经过你的服务器。

部分交易触发 3-D Secure、银行验证或钱包授权时,仍可能出现弹窗、内嵌验证窗口,甚至短暂跳转。具体行为取决于发卡行、所在地区和支付服务商,无法保证所有付款都不出现外部验证界面。

推荐的支付流程

较安全的典型流程是:

  1. 顾客在你的网站确认商品和金额。
  2. 你的服务器根据订单创建支付会话或支付意图。
  3. 浏览器加载支付服务商提供的 JavaScript SDK。
  4. SDK 在当前页面渲染由服务商托管的信用卡输入框。
  5. 顾客填写卡片信息。
  6. 浏览器将卡片信息直接发送给支付服务商,并转换为令牌。
  7. 你的服务器使用令牌确认付款。
  8. 支付服务商通过 Webhook 通知最终付款结果。
  9. 你的服务器验证通知,并将订单标记为已付款。

订单金额必须由服务器根据商品、数量、折扣和运费重新计算,不能信任浏览器提交的金额。

前端示例

各家支付服务商使用的 SDK 和方法名不同。下面是一个通用结构示例,其中的 PaymentProvider、接口路径和事件名称需要替换为所选服务商的实际实现:

<form id="payment-form">
  <div id="card-element"></div>
  <p id="payment-error" role="alert"></p>
  <button id="pay-button" type="submit">立即付款</button>
</form>

<script src="https://js.payment-provider.example/sdk.js"></script>
<script>
  const provider = PaymentProvider({
    publicKey: "pk_public_key"
  });

  const cardElement = provider.createCardElement();
  cardElement.mount("#card-element");

  const form = document.querySelector("#payment-form");
  const button = document.querySelector("#pay-button");
  const errorElement = document.querySelector("#payment-error");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();

    button.disabled = true;
    errorElement.textContent = "";

    try {
      const sessionResponse = await fetch("/api/payments/session", {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          orderId: "ORDER_ID"
        })
      });

      if (!sessionResponse.ok) {
        throw new Error("无法创建支付会话");
      }

      const { clientSecret } = await sessionResponse.json();

      const result = await provider.confirmCardPayment(clientSecret, {
        paymentMethod: {
          card: cardElement
        }
      });

      if (result.error) {
        throw new Error(result.error.message || "付款失败");
      }

      // 此处只能显示“正在确认”。
      // 订单是否付款成功,应以后端查询结果或 Webhook 为准。
      window.location.href = `/orders/ORDER_ID/status`;
    } catch (error) {
      errorElement.textContent = error.message;
      button.disabled = false;
    }
  });
</script>

信用卡输入框即使看起来属于你的页面,通常也是支付服务商生成的独立 iframe,因此卡片信息不会进入你的 DOM、应用日志或后端接口。

后端示例

下面的 Node.js 示例说明了前后端的基本职责划分。支付服务商的方法名仅作为占位:

import express from "express";
import PaymentSDK from "payment-provider-sdk";

const app = express();
const payment = new PaymentSDK(process.env.PAYMENT_SECRET_KEY);

app.use(express.json());

app.post("/api/payments/session", async (req, res) => {
  const { orderId } = req.body;

  // 必须从数据库读取订单,不能采用前端提供的金额。
  const order = await findOrderById(orderId);

  if (!order || order.status !== "pending") {
    return res.status(400).json({
      error: "订单不存在或状态不允许付款"
    });
  }

  const amount = calculateOrderAmount(order);

  const session = await payment.createPayment({
    amount,
    currency: order.currency,
    metadata: {
      orderId: order.id
    },
    idempotencyKey: `pay:${order.id}`
  });

  res.json({
    clientSecret: session.clientSecret
  });
});

私钥只能保存在服务器环境变量或密钥管理系统中,不能写入网页、移动端代码或公开仓库。前端只能使用服务商明确标注为可以公开的公钥。

使用 Webhook 确认付款

浏览器显示付款成功,并不表示订单一定已经结算。顾客可能关闭页面,网络响应也可能丢失,所以应以服务商发送的服务器通知或后端主动查询的结果为准。

app.post(
  "/webhooks/payment",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;

    try {
      event = payment.verifyWebhook({
        payload: req.body,
        signature: req.headers["payment-signature"],
        secret: process.env.PAYMENT_WEBHOOK_SECRET
      });
    } catch {
      return res.sendStatus(400);
    }

    if (event.type === "payment.succeeded") {
      const orderId = event.data.metadata.orderId;

      // 更新操作应可重复执行,避免重复通知导致重复发货。
      await markOrderPaidOnce(orderId, event.data.paymentId);
    }

    res.sendStatus(200);
  }
);

实际项目中必须按照支付服务商的文档保留 Webhook 原始请求体,并使用签名密钥验证来源。不要只凭请求中的 orderId 或 status 更新订单。

不建议直接制作信用卡输入框

如果使用普通 <input> 收集卡号并提交到你的服务器,系统就会直接接触持卡人数据。这通常会明显扩大 PCI DSS 合规范围,并带来以下风险:

  • 卡号进入访问日志、错误日志或分析系统
  • 调试工具、浏览器扩展或第三方脚本读取表单
  • 数据库备份意外保存敏感信息
  • 应用漏洞导致信用卡信息泄露
  • 安全审计和合规成本大幅增加

尤其不要存储 CVV/CVC。即使自行加密了卡号,也不代表已经满足支付行业的安全和合规要求。

选择支付服务商时要确认什么

评估支付方案时,可以直接查找以下关键词:

  • Hosted Fields
  • Embedded Checkout
  • Payment Element
  • Tokenization
  • JavaScript SDK
  • 3-D Secure
  • Webhook
  • PCI DSS

还要确认服务商是否支持你的注册国家或地区、结算币种、退款、拒付处理、订阅付款以及当地法规要求。

嵌入式组件通常可以缩小 PCI DSS 合规范围,但能否适用某种特定的自我评估问卷,例如 SAQ A,取决于支付集成方式、页面中的第三方脚本以及服务商的具体要求。不能仅凭使用了 iframe 就自行认定。

上线前的关键检查

  • 整个结账页面强制使用 HTTPS。
  • 卡号和 CVV 不经过你的服务器。
  • 私钥只保存在后端。
  • 后端独立计算订单金额和币种。
  • 验证 Webhook 签名。
  • 使用幂等键防止重复扣款。
  • 防止同一订单被重复发货或重复入账。
  • 不在日志中记录支付令牌、卡片数据或完整请求体。
  • 正确处理付款处理中、失败、取消、退款和拒付状态。
  • 使用服务商提供的测试环境和测试卡完成异常流程测试。

要实现“不离开网站付款”,正确做法是在当前页面嵌入支付服务商托管的输入组件,再由后端配合令牌化、支付会话和 Webhook 完成交易。这样既能让结账过程保持连贯,也能避免应用直接处理最敏感的卡片数据。

备注:内容仅供参考。