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 必须仅保存在服务端
具体权限以所选支付服务商的文档为准。
推荐的支付流程
常见的安全支付流程是:
- 用户点击“Pay”。
- 前端请求自己的后端创建支付订单。
- 后端从数据库读取商品价格,不接受前端提交的金额作为可信依据。
- 后端使用私密凭证调用支付服务商 API。
- 后端将支付订单 ID 或短期客户端令牌返回给前端。
- 前端通过支付 SDK 打开弹窗或托管收银台。
- 用户完成支付授权。
- 后端确认或捕获支付,并记录最终状态。
- 收到 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 等合规要求。使用支付服务商的托管组件通常可以缩小合规范围,但具体责任仍需根据服务商和业务所在地区确认。
备注:内容仅供参考。
