PAYATHON 2026

如何添加加密的 HTML 支付信息输入区域?

支付老李

结论

不要用普通 HTML 表单直接收集、加密或保存银行卡号、有效期和 CVV。HTML 无法安全加密支付数据,保存在前端 JavaScript 中的密钥也无法保密。

更稳妥的方式是接入合规的支付服务商,并选择以下方案之一:

  • 托管结账页:用户跳转到支付服务商的页面完成付款,接入最简单。
  • 托管支付组件:支付服务商通过 iframe 或专用 SDK 提供卡号输入框,商家页面只接收支付令牌。
  • 数字钱包:例如 Apple Pay、Google Pay,付款仍由支付服务商处理。

HTTPS 只能保护数据传输,不能保证自行存储的银行卡信息安全。除非已经具备完整的 PCI DSS 合规能力,否则服务器和数据库都不应接触原始卡号或 CVV。

推荐的支付流程

可以把预订和支付分为以下步骤:

  1. 用户选择日期、票种和数量。
  2. 浏览器将预订参数提交给自己的服务器。
  3. 服务器重新检查日期、库存和价格,并计算应付金额。
  4. 服务器调用支付服务商 API 创建支付会话。
  5. 浏览器跳转到托管结账页,或加载服务商提供的支付组件。
  6. 付款完成后,支付服务商通过 webhook 通知服务器。
  7. 服务器验证 webhook 签名,将订单标记为已支付,然后生成门票。

不能仅凭浏览器跳转到”支付成功”页面来确认订单,因为跳转参数可能被伪造。应以通过签名验证的 webhook,或服务端查询到的支付状态为准。

改进预订表单

下面的页面只收集预订信息,不提供银行卡输入框。用户提交表单后,服务器创建支付会话并返回结账地址。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>主题公园门票预订</title>
  <link
    rel="stylesheet"
    href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
  >
  <style>
    body {
      background: #f4f6f8;
      font-family: Arial, sans-serif;
    }

    .booking-card {
      max-width: 680px;
      margin: 48px auto;
      border: 0;
      border-radius: 12px;
      box-shadow: 0 8px 28px rgba(0, 0, 0, 0.08);
    }

    .booking-card .card-header {
      padding: 24px;
      background: #fff;
      border-bottom: 1px solid #e9ecef;
    }

    .booking-card .card-body {
      padding: 28px;
    }

    .section-title {
      margin-bottom: 20px;
      font-size: 1.1rem;
      font-weight: 600;
    }

    .form-text {
      color: #6c757d;
    }

    .btn-pay {
      min-height: 48px;
      font-weight: 600;
    }
  </style>
</head>
<body>
  <main class="container">
    <section class="card booking-card">
      <header class="card-header">
        <h1 class="h3 mb-2">预订主题公园门票</h1>
        <p class="text-muted mb-0">
          选择门票后,下一步将在安全结账页面完成付款。
        </p>
      </header>

      <div class="card-body">
        <form id="booking-form">
          <h2 class="section-title">1. 行程信息</h2>

          <div class="form-group">
            <label for="visit-date">游玩日期</label>
            <input
              id="visit-date"
              name="date"
              type="date"
              class="form-control"
              required
            >
          </div>

          <div class="form-group">
            <label for="ticket-type">门票类型</label>
            <select
              id="ticket-type"
              name="ticket_type"
              class="form-control"
              required
            >
              <option value="" selected disabled>请选择门票类型</option>
              <option value="child">儿童票</option>
              <option value="adult">成人票</option>
              <option value="senior">老年票</option>
            </select>
          </div>

          <div class="form-group">
            <label for="ticket-count">门票数量</label>
            <input
              id="ticket-count"
              name="num_tickets"
              type="number"
              class="form-control"
              min="1"
              max="20"
              value="1"
              inputmode="numeric"
              required
            >
            <small class="form-text">单次最多购买 20 张。</small>
          </div>

          <hr class="my-4">

          <h2 class="section-title">2. 安全付款</h2>
          <p class="text-muted">
            点击后将进入支付服务商托管的加密结账页面。本网站不保存完整银行卡信息。
          </p>

          <div
            id="form-message"
            class="alert alert-danger d-none"
            role="alert"
            aria-live="polite"
          ></div>

          <button
            id="checkout-button"
            type="submit"
            class="btn btn-primary btn-block btn-pay"
          >
            继续安全付款
          </button>
        </form>
      </div>
    </section>
  </main>

  <script>
    const form = document.getElementById("booking-form");
    const button = document.getElementById("checkout-button");
    const message = document.getElementById("form-message");
    const dateInput = document.getElementById("visit-date");

    // 浏览器端限制仅用于改善体验,服务器仍必须再次验证。
    const today = new Date();
    const localToday = new Date(
      today.getTime() - today.getTimezoneOffset() * 60000
    ).toISOString().split("T")[0];

    dateInput.min = localToday;

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

      if (!form.reportValidity()) {
        return;
      }

      button.disabled = true;
      button.textContent = "正在创建安全结账页面…";
      message.classList.add("d-none");

      try {
        const booking = Object.fromEntries(new FormData(form).entries());

        const response = await fetch("/api/checkout-session", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "X-CSRF-Token": getCsrfToken()
          },
          credentials: "same-origin",
          body: JSON.stringify(booking)
        });

        const result = await response.json();

        if (!response.ok || !result.checkoutUrl) {
          throw new Error(result.message || "暂时无法开始付款,请稍后重试。");
        }

        window.location.assign(result.checkoutUrl);
      } catch (error) {
        message.textContent = error.message;
        message.classList.remove("d-none");
        button.disabled = false;
        button.textContent = "继续安全付款";
      }
    });

    function getCsrfToken() {
      return document
        .querySelector('meta[name="csrf-token"]')
        ?.getAttribute("content") || "";
    }
  </script>
</body>
</html>

如果后端尚未使用 CSRF token,应根据所用框架的机制补上保护,不能直接将相关代码删除。采用 SameSite Cookie 时,也需要结合实际的登录方式和跨站跳转流程进行配置。

服务端创建支付会话

下面是一个不依赖特定支付服务商的结构示例。具体的 SDK 方法和字段,请以所选服务商的当前文档为准:

app.post("/api/checkout-session", requireCsrf, async (req, res) => {
  const { date, ticket_type, num_tickets } = req.body;

  const quantity = Number.parseInt(num_tickets, 10);

  if (
    !isValidVisitDate(date) ||
    !["child", "adult", "senior"].includes(ticket_type) ||
    !Number.isInteger(quantity) ||
    quantity < 1 ||
    quantity > 20
  ) {
    return res.status(400).json({
      message: "预订信息无效,请检查后重试。"
    });
  }

  // 必须从服务器端价格表读取,不能采用浏览器传来的价格。
  const unitAmount = await getTicketPrice(ticket_type, date);
  const amount = unitAmount * quantity;

  const order = await createPendingOrder({
    date,
    ticketType: ticket_type,
    quantity,
    amount,
    currency: "CNY"
  });

  const session = await paymentProvider.createCheckoutSession({
    amount,
    currency: "CNY",
    description: `主题公园门票 × ${quantity}`,
    merchantReference: order.id,
    successUrl: `${PUBLIC_ORIGIN}/booking/result?order=${order.id}`,
    cancelUrl: `${PUBLIC_ORIGIN}/booking?order=${order.id}`,
    idempotencyKey: `checkout:${order.id}`
  });

  await saveProviderSessionId(order.id, session.id);

  res.json({
    checkoutUrl: session.url
  });
});

这里的 paymentProvider.createCheckoutSession() 只是抽象示例,并不是可以直接运行的通用 API。实际项目需要安装对应支付服务商的服务器端 SDK,并将密钥保存在服务器环境变量或密钥管理系统中。

浏览器只能使用支付服务商明确标记为”可公开”的公钥。私钥不能写入 HTML、前端 JavaScript、Git 仓库或移动端安装包。

通过 webhook 确认付款

app.post(
  "/api/payment-webhook",
  rawBodyMiddleware,
  async (req, res) => {
    let event;

    try {
      event = paymentProvider.verifyWebhook({
        rawBody: 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.merchantReference;
      const order = await findOrder(orderId);

      if (
        order &&
        order.status === "pending" &&
        event.data.amount === order.amount &&
        event.data.currency === order.currency
      ) {
        await markOrderAsPaid(order.id, event.data.paymentId);
        await issueTicketsOnce(order.id);
      }
    }

    res.sendStatus(200);
  }
);

rawBodyMiddleware、签名请求头和事件字段的具体形式由支付服务商决定。很多服务商要求使用原始请求体进行验签,因此不能在验签前重新序列化请求体。

webhook 可能被重复投递,所以订单更新和出票逻辑必须具备幂等性,防止重复出票或重复发送邮件。

如果需要在当前页面显示银行卡输入区

应使用支付服务商提供的 Hosted Fields、Elements 或同类组件,不要自行创建这些输入框:

<!-- 不建议自行处理这些敏感字段 -->
<input name="card_number">
<input name="expiry">
<input name="cvv">

页面只需预留组件的挂载容器:

<div class="form-group">
  <label>银行卡信息</label>
  <div id="provider-card-element"></div>
  <div id="payment-error" role="alert" aria-live="polite"></div>
</div>

再通过服务商的官方前端 SDK 挂载支付组件。卡片数据会直接发送给支付服务商,自己的服务器只接收令牌、支付方式 ID 或确认结果。不要尝试读取 iframe 内容,也不要将敏感字段复制到自己的表单中。

安全注意事项

  • 全站强制使用 HTTPS 并启用 HSTS,不能只保护付款页面。
  • 服务端必须重新验证票数、日期、库存、币种和金额。
  • 不要记录银行卡号、CVV、支付 SDK 的敏感请求体或完整身份信息。
  • 授权完成后不得保存 CVV,即使经过加密也不能保存。
  • 数据库只保存订单号、支付服务商交易 ID、支付状态,以及业务确实需要的卡片品牌和尾号。
  • 密钥应保存在环境变量或专用密钥管理系统中,并定期轮换。
  • 创建订单和处理 webhook 时,应设置幂等键或唯一约束。
  • webhook 处理程序需要验证签名、金额、币种、订单状态和商户引用。
  • 设置 Content Security Policy,只允许加载必要的支付服务商脚本、frame 和网络地址。
  • 不要自行编写密码学代码,也不要在浏览器中使用固定 AES 密钥”加密卡号”。
  • 使用支付服务商的测试环境和测试卡,测试付款成功、失败、取消、超时和重复通知等情况。
  • 正式上线前,需要确认支付服务商支持的地区和币种、退款规则,以及适用的 PCI DSS 合规范围。

优化表单样式可以减少填写错误,但支付安全仍取决于服务端校验、支付服务商托管组件、TLS、签名验证和合规的数据处理流程。

备注:内容仅供参考。