PAYATHON 2026

PHP Payment Gateway Implementation中的订单ID与支付响应处理

支付老李

明确结论

应在发起支付前创建订单,而不是等支付成功后再创建,也不要临时生成一个没有业务记录的随机数交给支付网关。

比较稳妥的流程如下:

  1. 用户确认购买时,先在数据库中创建状态为 PENDING_PAYMENT 的订单。
  2. 同时生成全局唯一且难以预测的业务订单号,例如 ORD_20260831_01K...。
  3. 将业务订单号传给支付网关。
  4. 服务端收到并确认支付回调后,将订单更新为 PAID、FAILED、CANCELLED 等状态。
  5. 内部自增主键 aID 仅用于数据库关联,不直接暴露给用户或支付网关。
  6. 保存网关交易号、金额、币种、状态、响应码和原始回调,同时过滤或加密敏感信息。

创建订单和支付成功是两件事。即使付款失败、用户取消支付或直接关闭页面,也应该保留订单和支付尝试记录。否则,后续排查问题、重新支付和财务对账都会很困难。

订单号应该如何生成

建议区分三类标识:

标识用途是否展示给用户
orders.id数据库内部主键、表关联通常不展示
orders.order_no商户业务订单号,提交给网关展示
payments.gateway_transaction_id支付网关生成的交易号可在支付详情中展示

aID 可以继续采用自增整数,但它只是内部实现。对外使用自增 ID 可能暴露订单数量,用户修改 URL 后也可能枚举出其他订单。

你提出的 RandomNo 方向基本正确,但不应等支付成功后才写入订单表。它需要在发起支付前生成,并改为含义更明确的 order_no。数据库必须为该字段设置唯一约束,不能只在应用层检查。

订单表可以这样设计:

CREATE TABLE orders (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    order_no VARCHAR(64) NOT NULL UNIQUE,
    user_id BIGINT UNSIGNED NOT NULL,
    amount_minor BIGINT UNSIGNED NOT NULL,
    currency CHAR(3) NOT NULL,
    status VARCHAR(32) NOT NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
);

金额建议使用最小货币单位保存。例如,人民币 199.00 元保存为 19900 分,以免产生浮点数计算误差。币种单独保存为 CNY、USD 等代码。

使用随机订单号还是自增编号

业务订单号至少要满足以下要求:

  • 唯一;
  • 难以猜测;
  • 长度符合支付网关要求;
  • 字符集符合网关限制;
  • 创建后不可修改;
  • 重试支付时仍可关联原订单。

可以采用 UUID、ULID 或安全随机数生成的标识。具体长度和允许使用的字符,应以接入网关的接口文档为准。

下面是一个不依赖第三方库的 PHP 示例:

<?php

function generateOrderNo(): string
{
    return 'ORD_' . gmdate('YmdHis') . '_' . bin2hex(random_bytes(8));
}

$orderNo = generateOrderNo();

random_bytes() 适合生成安全随机值。不要使用 rand()、mt_rand(),也不要只用时间戳作为唯一订单号。

即使发生碰撞的概率很低,数据库中的 UNIQUE 约束也不能省略。如果插入时发生唯一键冲突,重新生成订单号并再次尝试即可。

正确的下单和支付流程

整个流程可以分成”创建订单”和”支付订单”两个阶段。

1. 创建待支付订单

用户提交购买请求后,服务端应重新读取商品价格和优惠规则,再计算最终金额。不能直接采用浏览器提交的金额。

<?php

$pdo->beginTransaction();

try {
    $orderNo = generateOrderNo();

    // 应由服务端根据商品、数量和优惠重新计算。
    $amountMinor = 19900;
    $currency = 'CNY';

    $stmt = $pdo->prepare(
        'INSERT INTO orders
            (order_no, user_id, amount_minor, currency, status, created_at, updated_at)
         VALUES
            (:order_no, :user_id, :amount_minor, :currency, :status, NOW(), NOW())'
    );

    $stmt->execute([
        ':order_no'    => $orderNo,
        ':user_id'     => $userId,
        ':amount_minor'=> $amountMinor,
        ':currency'    => $currency,
        ':status'      => 'PENDING_PAYMENT',
    ]);

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

订单创建成功后,再把 order_no、金额、币种和回调地址等参数提交给支付网关。

2. 创建支付尝试记录

一张订单可能有多次支付尝试。例如,第一次银行卡支付失败后,用户可能改用其他支付方式。因此,支付记录适合单独建表,不要把所有支付字段都放进订单表。

CREATE TABLE payments (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    order_id BIGINT UNSIGNED NOT NULL,
    attempt_no INT UNSIGNED NOT NULL,
    idempotency_key VARCHAR(64) NOT NULL UNIQUE,
    gateway VARCHAR(32) NOT NULL,
    gateway_transaction_id VARCHAR(128) NULL,
    amount_minor BIGINT UNSIGNED NOT NULL,
    currency CHAR(3) NOT NULL,
    status VARCHAR(32) NOT NULL,
    response_code VARCHAR(64) NULL,
    response_message VARCHAR(255) NULL,
    raw_response JSON NULL,
    paid_at DATETIME NULL,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uq_gateway_transaction (gateway, gateway_transaction_id),
    UNIQUE KEY uq_order_attempt (order_id, attempt_no),
    CONSTRAINT fk_payments_order
        FOREIGN KEY (order_id) REFERENCES orders(id)
);

如果数据库不支持 JSON 类型,可以改用 TEXT,但读取时必须严格解析,并限制内容大小。

idempotency_key 用来防止重复创建支付请求。支付网关是否原生支持幂等键,以及对应的参数名称,需要查看该网关的文档。

用户应该看到哪个订单 ID

用户应该看到稳定的业务订单号 order_no,而不是内部自增主键 aID。

订单详情页可以显示:

订单号:ORD_20260831153045_7f12a9406c11e7bd
支付状态:已支付
支付交易号:由支付机构返回的交易号

网关交易号主要用于退款、对账和客服排查,不能取代商户订单号。一张订单可能产生多次支付尝试,因此也可能对应多个网关交易号。

如果订单号出现在 URL 中,服务端还必须检查当前登录用户是否有权访问该订单。难以预测的订单号只能降低枚举风险,不能替代权限校验。

哪些支付响应需要保存

保存的数据应足以支持审计、退款、对账和故障排查,通常包括:

  • 商户订单号;
  • 内部订单 ID;
  • 支付尝试编号;
  • 网关名称;
  • 网关交易号;
  • 请求金额与币种;
  • 网关返回金额与币种;
  • 支付状态;
  • 响应码和简要描述;
  • 回调事件 ID;
  • 回调接收时间;
  • 支付完成时间;
  • 退款金额及退款状态;
  • 过滤后的原始响应;
  • 签名验证结果或处理结果。

这些数据可用于:

  • 识别重复回调;
  • 核对实际支付金额;
  • 发起退款;
  • 处理用户投诉;
  • 排查网关或网络异常;
  • 生成财务对账数据;
  • 查找订单状态与网关状态不一致的问题。

不要原样保存银行卡号、CVV、支付密码、完整身份信息、访问令牌或密钥。支付请求和回调日志还需要设置访问权限、脱敏规则和合理的保留期限。具体合规要求取决于所在地区、支付方式和网关协议。

不要把浏览器跳转结果当作支付成功

支付完成后,网关通常会分别执行两项操作:

  1. 将用户浏览器跳转到商户的成功页或失败页;
  2. 通过服务端回调通知支付结果。

浏览器跳转并不可靠。用户可能关闭页面或断网,也可能伪造查询参数。因此,成功页只能展示当前订单状态,不能仅根据 ?status=success 把订单改为已支付。

最终结果应以验签通过的服务端回调为准。如果长时间没有收到回调,可以主动调用网关的订单查询接口进行确认。

回调处理的关键原则

支付回调必须完成验签、金额校验和幂等处理。

下面给出一个通用结构。verifyGatewaySignature() 只是占位函数,必须按照具体网关的文档实现,不能直接复制到生产环境。

<?php

$rawBody = file_get_contents('php://input');
$headers = getallheaders();

if (!verifyGatewaySignature($rawBody, $headers)) {
    http_response_code(400);
    exit('invalid signature');
}

$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);

$orderNo = (string) $event['merchant_order_no'];
$gatewayTransactionId = (string) $event['transaction_id'];
$eventId = (string) $event['event_id'];
$paidAmountMinor = (int) $event['amount_minor'];
$currency = (string) $event['currency'];
$gatewayStatus = (string) $event['status'];

$pdo->beginTransaction();

try {
    // FOR UPDATE 防止同一订单的并发回调同时修改状态。
    $stmt = $pdo->prepare(
        'SELECT id, amount_minor, currency, status
           FROM orders
          WHERE order_no = :order_no
          FOR UPDATE'
    );
    $stmt->execute([':order_no' => $orderNo]);

    $order = $stmt->fetch(PDO::FETCH_ASSOC);

    if (!$order) {
        throw new RuntimeException('Unknown order');
    }

    if (
        (int) $order['amount_minor'] !== $paidAmountMinor ||
        $order['currency'] !== $currency
    ) {
        throw new RuntimeException('Amount or currency mismatch');
    }

    // event_id 应在数据库中设置唯一约束。
    $eventStmt = $pdo->prepare(
        'INSERT INTO payment_events
            (gateway_event_id, order_id, payload, created_at)
         VALUES
            (:event_id, :order_id, :payload, NOW())'
    );

    $eventStmt->execute([
        ':event_id' => $eventId,
        ':order_id' => $order['id'],
        ':payload'  => $rawBody,
    ]);

    if ($gatewayStatus === 'PAID' && $order['status'] !== 'PAID') {
        $update = $pdo->prepare(
            'UPDATE orders
                SET status = :status, updated_at = NOW()
              WHERE id = :id'
        );

        $update->execute([
            ':status' => 'PAID',
            ':id'     => $order['id'],
        ]);

        $paymentUpdate = $pdo->prepare(
            'UPDATE payments
                SET gateway_transaction_id = :transaction_id,
                    status = :status,
                    paid_at = NOW(),
                    updated_at = NOW()
              WHERE order_id = :order_id
                AND gateway = :gateway'
        );

        $paymentUpdate->execute([
            ':transaction_id' => $gatewayTransactionId,
            ':status'         => 'SUCCEEDED',
            ':order_id'       => $order['id'],
            ':gateway'        => 'example_gateway',
        ]);
    }

    $pdo->commit();

    // 具体响应正文和状态码按网关协议返回。
    http_response_code(200);
    echo 'success';
} catch (PDOException $e) {
    $pdo->rollBack();

    // 若是 gateway_event_id 唯一键冲突,通常表示重复回调。
    // 应按实际数据库错误码识别,确认此前已成功处理后再返回成功。
    http_response_code(500);
    echo 'retry';
} catch (Throwable $e) {
    $pdo->rollBack();
    http_response_code(400);
    echo 'failed';
}

处理回调时还要注意:

  • 验证签名或消息认证码;
  • 按网关要求使用原始请求体参与验签;
  • 检查回调时间戳,防止重放攻击;
  • 核对商户号、订单号、金额和币种;
  • 为网关事件 ID 设置唯一约束;
  • 使用数据库事务和行锁处理并发;
  • 已支付订单再次收到成功通知时,直接返回成功;
  • 重复回调不能触发重复发货;
  • 回调接口应尽快响应,耗时业务可以交给可靠队列处理;
  • 不能只看 response_code 判断是否成功,应按照网关定义的最终交易状态处理。

常见支付场景及处理方式

支付成功

验签并核对金额后,将支付记录更新为 SUCCEEDED,订单更新为 PAID。发货、增加会员权益等业务只能执行一次,最好也设置独立的幂等标识。

用户取消支付

订单可以继续保持 PENDING_PAYMENT,也可以改为 CANCELLED,相应的支付尝试记录为 CANCELLED。如果业务规则允许,用户可以重新发起支付。

支付失败

保存失败码和网关返回的可公开描述,并在页面提供重新支付或更换支付方式的入口。不要把包含内部细节的完整网关响应直接展示给用户。

支付处理中

有些支付方式不会立即返回最终结果,此时应使用 PROCESSING 状态,不能把”尚未确认”视为失败。后续可以等待回调或定时查询。

用户已付款,但没有回到网站

这种情况很常见。只要服务端回调正常,订单仍然会更新为已支付。用户再次打开订单页面时,读取数据库中的最新状态即可。

回调延迟或丢失

可以设置定时任务,查找长时间停留在 PENDING_PAYMENT 或 PROCESSING 状态的支付记录,再调用网关查询接口校正状态。查询频率和时间范围要符合网关的接口限制。

重复回调

网关在没有收到预期响应时通常会重试,所以同一通知可能多次到达。可以结合唯一事件 ID、网关交易号、数据库事务和订单状态判断,保证处理过程幂等。

用户重复点击支付按钮

前端可以暂时禁用按钮,但服务端仍然必须承担真正的防重复处理。应使用幂等键,并检查是否已有可复用且尚未完成的支付尝试。

金额或币种不一致

不要把订单更新为已支付,也不要发货。应记录异常并转入人工核查。参与比较的金额必须来自数据库和经过验证的网关回调,不能使用浏览器提交的值。

同一订单出现多笔成功支付

并发请求、超时重试或用户同时打开多个支付页面,都可能造成这种情况。第一笔成功支付可以完成订单,后续成功款项应进入异常队列,再按业务规则原路退款或人工处理,不能重复发货。

回调先于页面请求到达

页面跳转与服务端回调没有固定顺序。所有处理都应以数据库状态为准,不能假定成功页一定先打开,也不能假定回调一定稍后到达。

退款、部分退款和撤销

退款应使用独立的数据表,保存退款单号、关联交易号、金额、状态和网关退款号。退款申请成功并不代表退款已经到账,还需要接收退款回调或查询最终状态。

订单状态也不适合直接退回 UNPAID,可以使用 REFUND_PENDING、PARTIALLY_REFUNDED、REFUNDED 等状态。

拒付和争议

银行卡等支付方式可能在支付成功后发生拒付。因此,订单支付状态不能只有成功和失败,还要支持 DISPUTED、CHARGEBACK 等后续状态,并保留发货和交易证据。

网络超时

向网关发起请求时发生超时,并不等于支付失败。此时不要立即创建另一笔支付,也不要把订单标记为失败。应使用原幂等键查询网关结果,确认交易不存在后才能安全重试。

建议的状态设计

订单状态和支付状态适合分开管理。

订单状态示例:

PENDING_PAYMENT
PAID
FULFILLING
COMPLETED
CANCELLED
REFUND_PENDING
PARTIALLY_REFUNDED
REFUNDED

支付状态示例:

CREATED
PROCESSING
SUCCEEDED
FAILED
CANCELLED
REFUNDED
DISPUTED

状态不能随意跳转。例如,PAID 订单不能因为后来收到一条延迟的失败通知,就变回 PENDING_PAYMENT。系统应明确规定状态转换规则,并优先保护已经确认的终态。

上线前需要检查的事项

  • 在网关沙箱环境测试成功、失败、取消、超时和重复回调;
  • 使用 HTTPS,妥善保存 API 密钥和签名密钥;
  • 由服务端计算并核对金额;
  • 验证回调签名、商户号、订单号、金额和币种;
  • 为订单号、事件 ID、幂等键和网关交易号设置合适的唯一约束;
  • 确保重复通知和重复请求不会造成重复扣减库存或重复发货;
  • 建立未完成订单查询和每日对账机制;
  • 在日志中隐藏银行卡、令牌、密钥和个人敏感信息;
  • 监控支付失败率、回调验签失败和金额不一致等异常;
  • 严格按照所接入支付网关的签名、重试、状态和响应规范实现。

基本原则是:先创建订单,再发起支付;内部 ID 与对外订单号分开使用;支付结果以后端验证为准;所有可能被重试的操作都必须保证幂等。

备注:内容仅供参考。