使用 php form 集成 Credit Card processing 的安全流程
结论
整体思路基本正确,但安全流程需要调整。需要遵守以下原则:
- 不要将信用卡号、有效期或 CVV 保存到自己的数据库、日志、Session、CSV 或邮件中。
- 优先使用支付服务商提供的托管收银台、Hosted Fields 或前端 Tokenization SDK,让信用卡数据直接提交给支付服务商。
- 不要相信浏览器提交的金额。服务器必须根据商品、套餐和选项重新计算应付金额。
- 支付结果不能只用
true或false表示。系统需要验证支付状态、订单号、金额、币种和签名,并通过服务端 API 或 Webhook 确认最终结果。 - 只有确认付款成功后,才能将订单标记为已支付。客户资料和订单可以提前保存,但订单应处于
pending等待处理状态。
HTTPS 是支付页面的必要条件,但使用 HTTPS 不代表整个支付流程已经安全,也不代表系统符合 PCI DSS 要求。
推荐的处理流程
常见且相对安全的处理流程如下:
- 客户通过 HTTPS 访问网站并填写报价表单。
- JavaScript 实时显示估算价格,该价格只用于界面展示。
- 客户提交表单后,PHP 根据服务器保存的产品价格和计价规则重新计算金额。
- PHP 创建一条状态为
pending的订单,并生成不可预测的订单编号。 - PHP 请求支付服务商创建 Checkout Session、Payment Intent 或同类支付对象。
- 客户在支付服务商的托管页面或安全组件中输入信用卡信息。
- 支付服务商负责验证、3-D Secure、授权或扣款。
- 支付服务商将浏览器跳转到成功页或失败页。
- PHP 通过支付服务商的服务端 API 或带有有效签名的 Webhook,确认真实的支付状态。
- PHP 核对订单号、金额、币种,并检查该支付对象是否已经处理。
- 确认付款成功后,在数据库事务中将订单更新为
paid,然后发货、开通服务或发送确认邮件。
浏览器跳转到“支付成功页”不能单独证明付款成功。用户可以直接访问这个 URL,也可能在付款完成后关闭页面。最终结果通常应以经过验证的 Webhook 或服务端查询为准。
信用卡数据应该如何提交
最简单的方案是使用支付服务商提供的托管收银台。用户可以跳转到服务商控制的支付页面,也可以通过嵌入方式完成支付,网站本身不接触完整卡号。
如果业务必须使用自定义支付页面,应接入服务商提供的 Hosted Fields 或 JavaScript Tokenization SDK。卡号输入框由服务商提供,信用卡数据从浏览器直接发送给服务商。浏览器只需将生成的 token 或 payment method ID 提交给 PHP。
概念流程如下:
Browser
├─ Order options ───────────────> PHP server
├─ Card details ────────────────> Payment processor
└─ payment_method_token ────────> PHP server
PHP server
├─ Recalculate amount
├─ Create pending order
├─ Charge/confirm through processor API
└─ Verify Webhook and mark order paid
不要自行创建普通的信用卡 <input>,再将卡号 POST 到 PHP 并转发给支付服务商。即使服务器不保存这些数据,只要接触了完整卡号,就会明显扩大 PCI DSS 合规范围,同时增加日志泄漏、异常跟踪系统采集和程序漏洞带来的风险。
PHP 服务端示例
不同支付服务商使用的 SDK 和 API 名称并不相同。下面只展示与供应商无关的程序结构,不能作为具体 API 直接运行。实际项目应换成所选支付服务商的官方 SDK,并以其当前文档为准。
<?php
declare(strict_types=1);
session_start();
require_once __DIR__ . '/vendor/autoload.php';
$userId = requireAuthenticatedUserId();
$selectedPlan = (string) ($_POST['plan'] ?? '');
$selectedOptions = $_POST['options'] ?? [];
$paymentMethodToken = (string) ($_POST['payment_method_token'] ?? '');
// 不能使用 $_POST['amount'] 作为实际扣款金额。
$quote = calculateQuoteOnServer($selectedPlan, $selectedOptions);
$order = createPendingOrder([
'user_id' => $userId,
'amount_minor' => $quote->amountMinor, // 例如 1099 表示 10.99
'currency' => 'CNY',
'plan' => $selectedPlan,
'options' => $selectedOptions,
]);
try {
$payment = $paymentClient->createOrConfirmPayment([
'amount' => $order->amountMinor,
'currency' => $order->currency,
'payment_method' => $paymentMethodToken,
'merchant_order_id' => $order->publicId,
], [
// 具体名称取决于支付服务商。
'idempotency_key' => $order->paymentAttemptId,
]);
saveProcessorPaymentId($order->id, $payment->id);
if ($payment->status === 'requires_action') {
// 例如需要 3-D Secure。返回服务商要求的下一步信息。
respondJson([
'status' => 'requires_action',
'client_secret' => $payment->clientSecret,
]);
exit;
}
// 即使这里显示成功,也应由 Webhook 或服务端查询完成最终确认。
respondJson([
'status' => 'processing',
'order_id' => $order->publicId,
]);
} catch (PaymentDeclinedException $e) {
markOrderPaymentFailed($order->id, safeDeclineCode($e));
respondJson([
'status' => 'failed',
'message' => '支付未获批准,请检查付款信息或更换支付方式。',
], 402);
} catch (Throwable $e) {
// 日志中不能包含卡号、CVV、完整请求体或敏感 token。
logPaymentError($order->publicId, $e);
respondJson([
'status' => 'error',
'message' => '暂时无法确认支付结果,请稍后查询订单状态。',
], 503);
}
金额最好以最小货币单位的整数保存,例如使用“分”,不要使用浮点数:
$amountMinor = 1099; // 10.99
对于没有小数单位的币种,应按支付服务商的币种规则处理,不能统一乘以 100。
Webhook 验证示例
Webhook 端点必须验证支付服务商的签名。不能仅凭请求中的 status=paid 就更新订单。
<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_PAYMENT_SIGNATURE'] ?? '';
try {
// 验证方法、Header 名称和容差参数以服务商官方 SDK 为准。
$event = $webhookVerifier->verify(
$payload,
$signature,
getenv('PAYMENT_WEBHOOK_SECRET')
);
} catch (InvalidWebhookSignatureException $e) {
http_response_code(400);
exit;
}
if ($event->type !== 'payment.succeeded') {
http_response_code(200);
exit;
}
$payment = $event->payment;
$order = findOrderByPublicId($payment->merchantOrderId);
if ($order === null) {
http_response_code(404);
exit;
}
if (
$payment->amount !== $order->amountMinor ||
$payment->currency !== $order->currency
) {
logPaymentMismatch($order->publicId, $payment->id);
http_response_code(400);
exit;
}
beginDatabaseTransaction();
try {
// Webhook 可能重复发送,因此更新操作必须具有幂等性。
if ($order->status !== 'paid') {
markOrderPaid($order->id, $payment->id);
enqueueOrderFulfilment($order->id);
}
commitDatabaseTransaction();
http_response_code(200);
} catch (Throwable $e) {
rollbackDatabaseTransaction();
http_response_code(500);
}
Webhook 密钥和 API 私钥应保存在环境变量或专用的密钥管理系统中。不要将它们提交到代码仓库,也不要发送给浏览器。
数据库中可以保存什么
通常可以保存以下数据:
- 支付服务商生成的 customer ID、payment ID 或 token
- Visa、Mastercard 等卡品牌
- 卡号后四位
- 卡片有效期等非敏感展示信息,但只在确有业务需要时保存
- 订单金额、币种和支付状态
- 授权、退款及拒付记录
- 支付服务商返回的非敏感错误码
以下数据不应保存:
- 完整信用卡号
- CVV、CVC 或 CID
- 磁条数据、PIN 或 PIN block
- 包含上述信息的原始请求、调试日志、截图、CSV 和邮件
CVV 尤其不能保存。即使经过加密,也不应在授权完成后继续保留。如果业务确实需要存储完整卡号,将承担严格的 PCI DSS 合规义务。普通业务系统通常没有必要自行承担相应的成本和风险。
如果需要支持“保存银行卡供下次付款”,应保存支付服务商返回的 token 或 payment method ID,而不是卡号本身。
还需要注意的安全问题
防止金额篡改
用户可以修改 jQuery 计算出的金额。提交给服务器的套餐编号和选项必须经过白名单验证,价格则应根据数据库或可信配置重新计算。
防止重复扣款
用户可能重复点击按钮,网络请求也可能自动重试。创建支付时,应使用支付服务商支持的 idempotency key,并在数据库中为支付对象 ID 或成功交易 ID 设置唯一约束。
正确处理支付状态
支付可能处于多种状态:
- 等待用户完成验证
- 处理中
- 已授权但尚未扣款
- 已扣款
- 被拒绝
- 已取消
- 已退款
- 发生争议或拒付
支付接口不应只返回一个布尔值。数据库中的订单状态和支付状态也应分别记录,避免将“接口请求成功”误认为“资金到账”。
防止 CSRF 和注入
普通表单应使用 CSRF token。数据库操作应使用 PDO 参数化查询,不要拼接 SQL:
$stmt = $pdo->prepare(
'UPDATE orders
SET payment_status = :status, processor_payment_id = :payment_id
WHERE id = :id'
);
$stmt->execute([
':status' => 'paid',
':payment_id' => $paymentId,
':id' => $orderId,
]);
输出客户填写的内容时,还要进行 HTML 转义:
echo htmlspecialchars($customerName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
控制日志和错误信息
支付失败时,面向客户的提示应便于采取下一步操作,但不要透露过多细节。内部日志可以记录订单号、支付对象 ID 和非敏感错误码,但不能记录完整请求体、卡号、CVV、API 密钥或可重复使用的敏感 token。
还要检查 Web 服务器、PHP 错误日志、APM、Session 存储和异常报告平台,确保这些系统不会自动采集敏感字段。
不要通过邮件传输支付数据
邮件不适合传输信用卡资料。给 CSV 附件设置密码,也不会使其自动满足安全要求。业务人员如需对账,应从支付服务商后台导出报表,报表中只保留交易编号、金额、状态、卡品牌和末四位等非敏感数据。
实施建议
优先使用支付服务商的托管收银台。这种方式实现较简单,信用卡数据不会经过 PHP 服务器,安全边界也更清楚。
如果必须采用站内支付界面,应使用服务商官方提供的 Hosted Fields 或 Tokenization SDK,并请熟悉 PCI DSS 的人员检查数据流、日志、第三方脚本和服务器配置。具体适用哪类 PCI DSS 自评问卷,以及需要承担哪些合规责任,取决于支付集成方式、商户所在地区、收单机构和支付服务商的要求,应向相关支付服务商或合规顾问确认。
备注:内容仅供参考。
