PAYATHON 2026

self hosted payment form 仅用 paid 字段是否足够

支付阿杰

结论

不够。users.paid = 0/1 最多只能表示用户当前是否有付费权限,既不能充当可靠的支付记录,也无法独立满足银行支付流程中的核对、重试、退款和审计要求。

支付状态应保存在独立的订单表和支付表中。服务端必须先验证银行回调的真实性,再核对订单号、金额、币种和交易状态。全部一致后,才能将订单标记为已支付。支付结果应以银行回调为主要依据,浏览器跳转到“支付成功页”不能证明支付已经成功。

为什么一个 paid 字段不够

一次支付至少要记录以下信息:

  • 支付对应哪个订单
  • 应付金额和实际支付金额
  • 支付币种
  • 银行返回的交易编号
  • 当前状态是待支付、成功、失败、退款还是拒付
  • 回调是否通过签名验证
  • 支付成功时间
  • 同一个回调是否已经处理

把这些信息压缩成 0 和 1 会带来很多问题。一个用户可能购买多个订单,成功支付后也可能退款。银行可能重复发送通知,用户则可能重新打开旧的成功页面,甚至伪造请求直接访问成功链接。

支付失败通常也不应把用户或订单已有的成功状态改回 0。新的支付尝试失败,不代表之前完成的支付已经失效。

建议的数据结构

至少应分别记录用户、订单和支付尝试:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    amount DECIMAL(12, 2) NOT NULL,
    currency CHAR(3) NOT NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'pending',
    paid_at TIMESTAMP NULL,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL
);

CREATE TABLE payments (
    id BIGINT PRIMARY KEY,
    order_id BIGINT NOT NULL,
    provider VARCHAR(64) NOT NULL,
    provider_transaction_id VARCHAR(128) NULL,
    status VARCHAR(32) NOT NULL DEFAULT 'pending',
    amount DECIMAL(12, 2) NOT NULL,
    currency CHAR(3) NOT NULL,
    raw_response TEXT NULL,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL,

    UNIQUE (provider, provider_transaction_id)
);

具体字段需要根据银行接口文档调整。raw_response 不应保存卡号、CVV、磁道数据或其他禁止持久化的敏感信息,日志也要遵守相同的限制。

支付状态通常不止成功和失败两种,可以根据业务需要选择:

pending
authorized
paid
failed
cancelled
refunded
partially_refunded
chargeback

如果银行采用“先授权、后扣款”的模式,还要区分 authorized 和 captured。最终使用哪些状态名称,应以银行接口和实际业务流程为准。

正确的处理流程

1. 在发起支付前创建订单

订单号应由服务端生成且不可预测,应付金额和币种也要预先固定在数据库中。浏览器提交的金额不能作为最终依据。

order_id = 服务端生成
amount   = 从数据库中的商品和价格计算
currency = CNY
status   = pending

创建订单后,再把银行要求的订单信息发送给支付接口。

2. 区分浏览器跳转和服务端回调

支付完成后,一般会产生两类请求:

  • 浏览器跳转,用来向用户展示结果
  • 服务端回调或 webhook,用来确认支付结果

浏览器可能被关闭或断网,跳转请求也可能被人为构造。因此,成功页面只能显示“正在确认”,或者读取数据库中的订单状态,不能直接执行:

UPDATE users SET paid = 1;

支付状态应由验证通过的服务端回调更新。如果银行提供交易状态查询 API,可以在回调缺失或状态不确定时主动补查。

3. 严格验证回调

收到回调后,至少要检查:

  • 请求是否使用 HTTPS,并按银行规定验证来源
  • 签名、MAC 或证书是否有效
  • 商户号是否属于当前系统
  • 订单号是否存在
  • 交易编号是否合法
  • 回调金额是否与订单金额完全一致
  • 币种是否一致
  • 银行状态是否明确表示已经支付或扣款
  • 该交易是否已经处理

不能只检查请求中有没有 success=true,也不能信任客户端传入的 order_id、金额或支付状态。

下面是简化后的伪代码:

function handleBankWebhook(Request $request): Response
{
    $payload = $request->getRawBody();
    $signature = $request->getHeader('X-Bank-Signature');

    if (!verifyBankSignature($payload, $signature)) {
        return new Response('invalid signature', 400);
    }

    $event = parseBankPayload($payload);

    beginTransaction();

    try {
        $order = findOrderForUpdate($event->orderId);

        if ($order === null) {
            rollback();
            return new Response('unknown order', 404);
        }

        if (!hash_equals($order->merchantId, $event->merchantId)) {
            rollback();
            return new Response('merchant mismatch', 400);
        }

        if ($order->amount !== $event->amount ||
            $order->currency !== $event->currency) {
            rollback();
            return new Response('payment mismatch', 400);
        }

        if (paymentAlreadyProcessed(
            $event->provider,
            $event->transactionId
        )) {
            commit();
            return new Response('OK', 200);
        }

        savePaymentEvent($order->id, $event);

        if ($event->status === 'PAID') {
            markOrderAsPaid(
                $order->id,
                $event->transactionId,
                $event->paidAt
            );
        } elseif ($event->status === 'FAILED') {
            markPaymentAttemptAsFailed($event->transactionId);
        }

        commit();
        return new Response('OK', 200);
    } catch (Throwable $e) {
        rollback();
        logPaymentProcessingError($e);
        return new Response('temporary error', 500);
    }
}

verifyBankSignature()、请求头名称和状态值都只是示例,必须按照银行提供的协议实现,不能自行假设。

回调必须支持幂等

支付平台在请求超时、发生网络错误或没有收到正确响应时,通常会重复发送同一条通知。因此,即使同一个交易编号被处理多次,系统也必须保持结果不变:

UNIQUE (provider, provider_transaction_id)

除了设置唯一约束,还应在事务中锁定订单,并限制状态只能按合法路径转换。例如:

pending -> paid
pending -> failed
paid -> refunded
paid -> partially_refunded

迟到的失败通知不能把已经处于 paid 状态的订单改成 failed。

users.paid 可以保留,但不能作为支付凭证

如果业务是会员订阅,或者用户一次付费后可以永久使用服务,可以保留类似字段作为权限缓存,例如:

users.has_access
users.membership_expires_at

但这类字段必须根据已经确认的订单、订阅或授权记录更新。发生退款、订阅到期或拒付后,还要重新计算用户权限。

换句话说:

支付记录是事实来源
users.paid 只是业务权限的派生结果

其他容易遗漏的事项

  • 支付成功、订单状态更新和交易记录写入应放在同一个数据库事务中。
  • 不要把邮件发送放进支付回调事务。应先提交数据库事务,再通过消息队列或后台任务发送邮件,以免邮件服务故障导致银行不断重试。
  • 保存必要的审计信息,但要过滤卡号、CVV、密钥、签名私钥和身份认证信息。
  • 对长期停留在 pending 状态的订单安排定时查询或人工核对。
  • 为退款、部分退款、撤销、拒付和对账差异预留处理流程。
  • 金额应使用定点数或 DECIMAL,不能用浮点数比较。
  • 为回调接口设置监控和告警。除非银行明确要求并同时提供可靠的签名机制,否则不能只根据来源 IP 判断回调是否可信。
  • 先返回银行要求的确认响应,再异步执行非关键任务,避免回调超时。

如果银行卡信息经过你的页面、JavaScript 或服务器,即使没有写入数据库,也可能进入 PCI DSS 的合规范围。使用银行托管页面、重定向支付、tokenization 或 hosted fields,通常可以减少系统接触敏感卡数据的范围。具体责任边界应以银行、收单机构和适用的 PCI DSS 要求为准。

因此,合理的最小实现不是一个布尔字段,而是一套包含订单记录、支付记录、签名验证、金额核对、幂等处理、状态转换和对账机制的完整流程。

备注:内容仅供参考。