如何添加加密的 HTML 支付信息输入区域?
结论
不要用普通 HTML 表单直接收集、加密或保存银行卡号、有效期和 CVV。HTML 无法安全加密支付数据,保存在前端 JavaScript 中的密钥也无法保密。
更稳妥的方式是接入合规的支付服务商,并选择以下方案之一:
- 托管结账页:用户跳转到支付服务商的页面完成付款,接入最简单。
- 托管支付组件:支付服务商通过 iframe 或专用 SDK 提供卡号输入框,商家页面只接收支付令牌。
- 数字钱包:例如 Apple Pay、Google Pay,付款仍由支付服务商处理。
HTTPS 只能保护数据传输,不能保证自行存储的银行卡信息安全。除非已经具备完整的 PCI DSS 合规能力,否则服务器和数据库都不应接触原始卡号或 CVV。
推荐的支付流程
可以把预订和支付分为以下步骤:
- 用户选择日期、票种和数量。
- 浏览器将预订参数提交给自己的服务器。
- 服务器重新检查日期、库存和价格,并计算应付金额。
- 服务器调用支付服务商 API 创建支付会话。
- 浏览器跳转到托管结账页,或加载服务商提供的支付组件。
- 付款完成后,支付服务商通过 webhook 通知服务器。
- 服务器验证 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、签名验证和合规的数据处理流程。
备注:内容仅供参考。
