如何在应用中集成信用卡支付?
结论
不要让应用服务器直接接收、保存或记录信用卡号、有效期和 CVV。建议接入具备 PCI DSS 合规能力的支付服务商,如 Stripe、Adyen、Checkout.com 或当地持牌收单机构,通过服务商的托管支付页或安全支付组件采集卡片信息。
常见的支付流程是:
- 用户选择账单并发起支付。
- 后端根据账单数据创建支付订单。
- 前端使用支付服务商的安全组件收集信用卡信息。
- 支付服务商处理验证、3D Secure 和扣款。
- 后端通过 Webhook 确认最终结果。
- 支付成功后更新账单状态,并向用户展示支付凭证。
前端显示的”支付成功”不能作为入账依据。只有后端收到并验证 Webhook 后,才能更新账单状态。
接入前需要准备什么
1. 选择支付服务商
选择服务商时,需要确认以下事项:
- 是否支持你的注册国家或地区。
- 是否支持目标用户使用的银行卡和结算币种。
- 手续费、退款费、拒付费和跨境费用如何计算。
- 是否支持 3D Secure、Apple Pay、Google Pay 等支付方式。
- 结算周期、最低结算金额和保证金要求。
- 是否提供测试环境、Webhook、退款和对账 API。
- 是否支持平台型业务。
如果分类应用只向用户收取自身提供的服务费,普通商户账户通常就够用。如果应用代多个商家收款并向商家分账,则属于平台支付场景,需要使用服务商提供的 Connect、Marketplace 或 Split Payment 能力,还可能涉及商户入驻、身份审核和资金流合规。
2. 完成商户审核
支付服务商通常会要求提交:
- 企业或个人经营主体资料。
- 法定代表人或实际控制人的身份信息。
- 银行结算账户。
- 网站域名和业务说明。
- 商品或服务价格。
- 服务条款、隐私政策和退款政策。
- 客服联系方式。
- 发货或服务交付说明。
具体要求由服务商、业务类型和经营地区决定。数字商品、订阅、跨境业务和部分高风险行业可能需要额外审核。
3. 使用 HTTPS
生产环境中的付款页面和接口必须使用 HTTPS,同时需要做好这些基础配置:
- 将密钥存放在环境变量或密钥管理服务中。
- 分开管理测试环境和生产环境。
- 不要在前端代码中暴露服务端密钥。
- 限制后台支付接口的访问权限。
- 不要在日志、监控数据或错误信息中记录敏感支付数据。
推荐的系统结构
支付集成至少由四部分组成:
- 前端支付页面:展示账单并加载支付服务商的安全组件。
- 应用后端:校验账单、计算金额并创建支付订单。
- 支付服务商:采集卡片信息,执行身份验证和扣款。
- Webhook 接口:接收异步支付结果并更新账单。
金额必须由后端根据数据库中的账单计算,不能采用前端提交的金额。例如,前端只提交 billId,后端根据 billId 查询应付金额:
浏览器 -> 应用后端:billId
应用后端 -> 数据库:查询账单及应付金额
应用后端 -> 支付服务商:创建支付订单
支付服务商 -> 浏览器:完成信用卡验证
支付服务商 -> Webhook:通知最终支付结果
Webhook -> 数据库:将账单标记为已支付
集成步骤
1. 建立本地订单
建议先创建本地支付订单,再调用支付服务商。订单至少可以包含:
payment_order
- id
- bill_id
- user_id
- amount
- currency
- status
- provider
- provider_payment_id
- created_at
- paid_at
status 可以根据业务需要设为:
pending
processing
paid
failed
refunded
partially_refunded
金额应以最小货币单位保存,例如人民币使用分,美元使用美分。不要用浮点数保存金额。
2. 后端创建支付对象
下面以 Stripe Payment Intents 为例。其他服务商使用的名称和参数可能不同,但整体流程相近。
安装依赖:
npm install express stripe dotenv
创建支付接口:
import express from "express";
import Stripe from "stripe";
import "dotenv/config";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.use(express.json());
app.post("/api/payments", async (req, res) => {
try {
const userId = req.user.id;
const { billId } = req.body;
// 必须从数据库读取账单,不能使用前端提交的金额。
const bill = await findBillById(billId);
if (!bill || bill.userId !== userId) {
return res.status(404).json({ error: "Bill not found" });
}
if (bill.status === "paid") {
return res.status(409).json({ error: "Bill already paid" });
}
const paymentIntent = await stripe.paymentIntents.create(
{
amount: bill.amountInMinorUnit,
currency: bill.currency.toLowerCase(),
automatic_payment_methods: {
enabled: true
},
metadata: {
billId: String(bill.id),
userId: String(userId)
}
},
{
idempotencyKey: `bill-${bill.id}`
}
);
await savePaymentOrder({
billId: bill.id,
userId,
amount: bill.amountInMinorUnit,
currency: bill.currency,
provider: "stripe",
providerPaymentId: paymentIntent.id,
status: "pending"
});
res.json({
clientSecret: paymentIntent.client_secret
});
} catch (error) {
console.error("Create payment failed:", error);
res.status(500).json({ error: "Unable to create payment" });
}
});
示例中的 req.user、findBillById() 和 savePaymentOrder() 需要按照应用现有的认证系统和数据库实现。
幂等键可以降低重复请求导致重复扣款的风险。实际项目需要根据业务规则确定幂等范围。如果同一账单在支付失败后可以重新创建支付对象,就不能永久复用同一个键。
3. 前端确认付款
前端应使用支付服务商提供的 SDK 或托管组件,不要自行创建普通 <input> 来采集完整卡号和 CVV。
以 Stripe Payment Element 为例:
npm install @stripe/stripe-js @stripe/react-stripe-js
初始化支付组件:
import { Elements } from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import CheckoutForm from "./CheckoutForm";
const stripePromise = loadStripe(
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY
);
export default function PaymentPage({ clientSecret }) {
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm />
</Elements>
);
}
提交付款:
import { PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { useState } from "react";
export default function CheckoutForm() {
const stripe = useStripe();
const elements = useElements();
const [message, setMessage] = useState("");
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event) {
event.preventDefault();
if (!stripe || !elements || submitting) {
return;
}
setSubmitting(true);
setMessage("");
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/payment/result`
}
});
if (error) {
setMessage(error.message || "支付未完成,请稍后重试");
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button type="submit" disabled={!stripe || submitting}>
{submitting ? "处理中…" : "确认支付"}
</button>
{message && <p role="alert">{message}</p>}
</form>
);
}
可发布密钥可以放在前端,服务端密钥则只能保存在后端。支付 SDK 的初始化方式可能随服务商版本变化,上线前需要核对所选服务商当前的官方文档。
4. 处理 Webhook
银行卡支付可能涉及异步验证,所以浏览器的跳转结果未必是最终状态。后端必须验证 Webhook 签名,并根据支付服务商返回的结果更新账单。
Stripe Webhook 示例:
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (error) {
return res.status(400).send(`Invalid webhook: ${error.message}`);
}
try {
if (event.type === "payment_intent.succeeded") {
const paymentIntent = event.data.object;
const billId = paymentIntent.metadata.billId;
await markBillPaidOnce({
billId,
providerPaymentId: paymentIntent.id,
paidAmount: paymentIntent.amount_received,
currency: paymentIntent.currency
});
}
if (event.type === "payment_intent.payment_failed") {
const paymentIntent = event.data.object;
await markPaymentFailed({
providerPaymentId: paymentIntent.id
});
}
res.sendStatus(200);
} catch (error) {
console.error("Webhook processing failed:", error);
res.sendStatus(500);
}
}
);
使用 Express 时,Webhook 路由需要读取原始请求体。如果全局 express.json() 先解析了该请求体,签名验证可能失败。可以先注册 Webhook 路由,再注册 JSON 中间件:
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
stripeWebhookHandler
);
app.use(express.json());
Webhook 可能重复发送,也可能乱序到达,因此 markBillPaidOnce() 必须保证幂等。更新账单前还要核对:
- 支付对象是否属于当前系统。
billId是否存在。- 实收金额是否与订单金额相同。
- 币种是否一致。
- 该支付是否已经处理。
- 支付对象是否已关联其他账单。
支付成功后的处理
确认支付成功后,可以在同一个数据库事务中完成以下操作:
- 将支付订单状态更新为
paid。 - 将账单更新为已支付。
- 保存服务商支付编号和实收金额。
- 记录支付时间。
- 写入不可随意修改的审计记录。
邮件、短信和站内通知可以交给异步任务队列处理。即使通知发送失败,也不会回滚已经确认的支付结果。
建议提供单独的支付结果查询接口:
app.get("/api/bills/:billId/payment-status", async (req, res) => {
const bill = await findBillForUser(req.params.billId, req.user.id);
if (!bill) {
return res.status(404).json({ error: "Bill not found" });
}
res.json({
billId: bill.id,
paymentStatus: bill.paymentStatus
});
});
支付结果页应通过这个接口读取服务端状态,不能只根据 URL 参数显示支付成功。
安全与合规注意事项
不保存敏感卡信息
应用数据库不应保存:
- 完整信用卡号。
- CVV 或 CVC。
- 磁道数据。
- PIN。
- 未经保护的身份验证数据。
如果页面需要展示卡片信息,通常只保存支付服务商返回的支付方式标识、卡品牌、后四位和有效期摘要。CVV 即使经过加密,通常也不应保存。
降低 PCI DSS 范围
托管支付页通常最容易接入。嵌入式安全组件可以让用户留在站内完成支付,但仍需根据实际集成方式完成相应的 PCI DSS 自评和安全管理。
使用第三方支付不代表可以免除全部合规责任。具体适用范围需要由支付服务商或合规专业人员结合业务架构确认。
防止重复支付
需要同时采取这些措施:
- 提交按钮进入处理中状态后立即禁用。
- 创建支付请求时使用幂等键。
- 为服务商支付编号建立数据库唯一约束。
- 禁止已支付账单再次发起正常付款。
- 保证 Webhook 更新操作幂等。
- 遇到网络超时时先查询支付状态,不要直接重新扣款。
不信任前端数据
以下信息必须由后端核实:
- 用户是否有权支付该账单。
- 账单是否仍然有效。
- 应付金额和币种。
- 优惠券是否可用。
- 是否已经完成支付。
- 商品库存或服务资格。
- 支付成功后应该授予哪些权益。
处理退款和拒付
生产系统还需要处理:
- 全额退款和部分退款。
- 退款状态同步。
- 用户取消和商户取消。
- 拒付通知及证据提交。
- 对账差异。
- 手续费和净结算金额记录。
退款也应由后端调用支付服务商 API,并记录操作人、退款原因、退款金额和服务商退款编号。
上线前检查
正式开放付款前,至少要测试以下情况:
- 正常支付成功。
- 卡片被拒绝。
- 余额不足。
- 3D Secure 验证成功、失败和取消。
- 用户重复点击付款。
- 请求超时后重新进入页面。
- Webhook 重复到达。
- Webhook 延迟或乱序。
- 金额或币种不一致。
- 支付成功但浏览器没有返回。
- 全额退款和部分退款。
- 已支付账单再次付款。
- 用户尝试支付他人的账单。
先在服务商的测试环境中使用测试卡完成全流程验证,再切换到生产密钥。生产环境还需要配置日志告警、Webhook 重试监控、每日对账和异常订单处理机制。
备注:内容仅供参考。
