PAYATHON 2026

使用 php form 集成 Credit Card processing 的安全流程

支付小周

结论

整体思路基本正确,但安全流程需要调整。需要遵守以下原则:

  • 不要将信用卡号、有效期或 CVV 保存到自己的数据库、日志、Session、CSV 或邮件中。
  • 优先使用支付服务商提供的托管收银台、Hosted Fields 或前端 Tokenization SDK,让信用卡数据直接提交给支付服务商。
  • 不要相信浏览器提交的金额。服务器必须根据商品、套餐和选项重新计算应付金额。
  • 支付结果不能只用 true 或 false 表示。系统需要验证支付状态、订单号、金额、币种和签名,并通过服务端 API 或 Webhook 确认最终结果。
  • 只有确认付款成功后,才能将订单标记为已支付。客户资料和订单可以提前保存,但订单应处于 pending 等待处理状态。

HTTPS 是支付页面的必要条件,但使用 HTTPS 不代表整个支付流程已经安全,也不代表系统符合 PCI DSS 要求。

推荐的处理流程

常见且相对安全的处理流程如下:

  1. 客户通过 HTTPS 访问网站并填写报价表单。
  2. JavaScript 实时显示估算价格,该价格只用于界面展示。
  3. 客户提交表单后,PHP 根据服务器保存的产品价格和计价规则重新计算金额。
  4. PHP 创建一条状态为 pending 的订单,并生成不可预测的订单编号。
  5. PHP 请求支付服务商创建 Checkout Session、Payment Intent 或同类支付对象。
  6. 客户在支付服务商的托管页面或安全组件中输入信用卡信息。
  7. 支付服务商负责验证、3-D Secure、授权或扣款。
  8. 支付服务商将浏览器跳转到成功页或失败页。
  9. PHP 通过支付服务商的服务端 API 或带有有效签名的 Webhook,确认真实的支付状态。
  10. PHP 核对订单号、金额、币种,并检查该支付对象是否已经处理。
  11. 确认付款成功后,在数据库事务中将订单更新为 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 自评问卷,以及需要承担哪些合规责任,取决于支付集成方式、商户所在地区、收单机构和支付服务商的要求,应向相关支付服务商或合规顾问确认。

备注:内容仅供参考。