PAYATHON 2026

是否需要单独存储 Payment Gateway 处理的交易记录?

支付小周

结论

需要。系统应维护自己的支付交易记录,通常还应在调用 Payment Gateway 之前,先创建一条状态为 pending 的记录。

这张表不是用来复制网关保存的所有信息,更不能保存完整信用卡号、CVV/CVC、磁道数据等敏感内容。它主要用于关联业务订单和网关交易,处理异步通知与重复请求,以及支持对账、退款和争议排查。

各系统的职责可以这样划分:

  • 业务系统记录客户为什么付款、应付多少,以及付款后应获得什么服务。
  • Payment Gateway 负责处理银行卡支付。
  • 会计或财务台账记录最终确认的资金变化。
  • 网关后台不能取代业务系统自己的交易记录。

为什么不能只依赖 Payment Gateway

业务数据无法完全由网关表达

网关通常知道付款金额、币种、时间和网关交易号,但不一定知道这笔钱对应哪个客户、哪个套餐、哪段服务期限,也不知道付款成功后应开通哪些权限。

没有本地记录,后续就很难回答这些问题:

  • 某位客户的服务为什么被开通?
  • 某个订单对应网关中的哪笔交易?
  • 客户重复提交了几次,最终哪一次成功?
  • 已经成功扣款但权限没有开通,应如何补偿?
  • 退款后,相关服务是否应被撤销?
  • 网关账单和内部订单的金额为什么不一致?

支付结果与业务更新不是一个原子事务

支付可能已经成功,但系统在更新客户权限时数据库连接中断。反过来,浏览器也可能已经显示成功页面,服务端却还没有确认到账。

第三方支付和本地数据库通常不能放进同一个数据库事务,因此系统需要保存中间状态,再通过重试和对账实现最终一致性。仅靠一段“支付成功后立即开通服务”的代码,无法处理网络超时、进程崩溃和重复通知等情况。

浏览器返回结果不能作为最终依据

用户可能关闭或刷新页面,也可能修改请求参数。某些支付方式会先返回处理中状态,过一段时间才能得到最终结果。

因此,不能只因为浏览器跳转到了 success URL 就开通服务。最终结果应通过以下任一可信渠道确认:

  1. 网关发送的服务端 Webhook;
  2. 服务端使用网关交易号主动查询;
  3. 网关提供并经过签名验证的服务端响应。

具体使用哪种方式,取决于网关的接口设计。

推荐的数据模型

至少应区分业务订单和支付尝试。一张订单可能经历多次支付尝试,例如第一次失败、第二次成功,因此不适合把两者简单合并。

CREATE TABLE orders (
    id                  BIGINT PRIMARY KEY,
    customer_id         BIGINT NOT NULL,
    service_code        VARCHAR(100) NOT NULL,
    amount_minor        BIGINT NOT NULL,
    currency            CHAR(3) NOT NULL,
    status              VARCHAR(30) NOT NULL,
    created_at          TIMESTAMP NOT NULL,
    paid_at             TIMESTAMP NULL
);

CREATE TABLE payment_transactions (
    id                      BIGINT PRIMARY KEY,
    order_id                BIGINT NOT NULL,
    gateway                 VARCHAR(50) NOT NULL,
    merchant_reference      VARCHAR(100) NOT NULL,
    gateway_transaction_id  VARCHAR(255) NULL,
    amount_minor            BIGINT NOT NULL,
    currency                CHAR(3) NOT NULL,
    status                  VARCHAR(30) NOT NULL,
    card_brand              VARCHAR(30) NULL,
    card_last4              CHAR(4) NULL,
    failure_code            VARCHAR(100) NULL,
    failure_message         VARCHAR(500) NULL,
    created_at              TIMESTAMP NOT NULL,
    updated_at              TIMESTAMP NOT NULL,
    completed_at            TIMESTAMP NULL,

    CONSTRAINT fk_payment_order
        FOREIGN KEY (order_id) REFERENCES orders(id),

    CONSTRAINT uq_merchant_reference
        UNIQUE (merchant_reference),

    CONSTRAINT uq_gateway_transaction
        UNIQUE (gateway, gateway_transaction_id)
);

金额建议用最小货币单位的整数保存。例如,人民币 ¥19.90 可以保存为 1990 分,以免产生浮点数计算误差。currency 也必须一并保存,因为单独一个 1990 无法说明它代表 CNY、USD 还是其他币种。

status 可以根据网关能力设置为:

pending
processing
authorized
succeeded
failed
cancelled
refunded
partially_refunded

不必盲目支持所有状态,但最好不要只用一个 success 布尔值。支付、退款和撤销都可能有中间状态。

推荐的处理流程

1. 先创建订单和支付记录

收到支付请求后,服务端应根据商品或服务配置重新计算金额,不能直接采用浏览器提交的价格。

创建 order
    ↓
创建 payment_transaction(status = pending)
    ↓
生成唯一 merchant_reference
    ↓
调用 Payment Gateway

此时记录的是付款意图,并不表示支付已经完成。

2. 调用网关时传递本地引用

如果网关支持 merchant_reference、order_id、metadata 或类似字段,应传入本地生成且不会重复的标识。这样在收到 Webhook 后,系统可以准确找到对应的本地记录。

示意代码如下:

const payment = await db.transaction(async (tx) => {
  const order = await tx.orders.insert({
    customerId,
    serviceCode,
    amountMinor: 1990,
    currency: "CNY",
    status: "awaiting_payment"
  });

  return tx.paymentTransactions.insert({
    orderId: order.id,
    gateway: "example_gateway",
    merchantReference: crypto.randomUUID(),
    amountMinor: order.amountMinor,
    currency: order.currency,
    status: "pending"
  });
});

const gatewayResult = await gateway.createPayment({
  amount: payment.amountMinor,
  currency: payment.currency,
  merchantReference: payment.merchantReference,
  idempotencyKey: payment.merchantReference
});

idempotencyKey 的具体名称和行为取决于网关。如果网关支持,应优先使用它,以减少超时重试导致重复扣款的风险。

3. 保存网关返回的交易标识

网关接受请求后,应保存它返回的 transaction_id、payment_intent_id 或同类标识。即使交易仍在处理中,这个标识也会用于后续查询、退款和对账。

如果调用超时,不要马上把支付判定为失败。超时只说明调用方没有收到明确结果,网关可能已经创建甚至完成了交易。此时应使用幂等键重试,或者通过本地引用主动查询。

4. 使用 Webhook 或服务端查询确认结果

下面只是通用示意,签名算法和请求字段必须按照所用网关的文档实现:

app.post("/webhooks/payment", async (req, res) => {
  const event = gateway.verifyWebhookSignature(
    req.rawBody,
    req.headers
  );

  if (!event) {
    return res.status(400).send("invalid signature");
  }

  await db.transaction(async (tx) => {
    const payment = await tx.paymentTransactions.findByGatewayIdForUpdate(
      event.transactionId
    );

    if (!payment) {
      throw new Error("unknown payment transaction");
    }

    // 重复通知已经处理过,直接返回成功。
    if (payment.status === "succeeded") {
      return;
    }

    // 不要只核对交易号,还要核对金额、币种和商户引用。
    if (
      event.merchantReference !== payment.merchantReference ||
      event.amountMinor !== payment.amountMinor ||
      event.currency !== payment.currency
    ) {
      throw new Error("payment data mismatch");
    }

    if (event.status === "succeeded") {
      await tx.paymentTransactions.markSucceeded(payment.id, {
        completedAt: new Date()
      });

      await tx.orders.markPaid(payment.orderId);

      // 此操作也应具备幂等性,避免重复开通。
      await tx.serviceEntitlements.grantOnce(payment.orderId);
    } else if (event.status === "failed") {
      await tx.paymentTransactions.markFailed(payment.id, {
        failureCode: event.failureCode
      });
    }
  });

  res.status(200).send("ok");
});

Webhook 经常会重复投递,顺序也不一定稳定,因此处理逻辑必须具备幂等性。同一笔成功通知即使处理两次,也不能重复开通服务、发放权益或记账。

应该保存哪些信息

一般可以保存:

  • 本地订单号;
  • 本地支付尝试号;
  • 网关名称;
  • 商户引用号;
  • 网关交易号;
  • 应付金额、实付金额和币种;
  • 当前状态及状态更新时间;
  • 创建、成功、失败和退款等时间;
  • 失败代码;
  • 退款金额及退款交易号;
  • 必要时保存卡组织和卡号后四位;
  • 对账所需的批次号、结算号或手续费信息。

是否保存持卡人姓名,应根据实际业务需求、隐私政策和当地法规决定。如果姓名只用于付款,没有后续业务用途,通常不应因为“以后可能有用”而复制保存。

Webhook 原始内容可以根据审计需要保留一段有限的时间,但要先评估其中是否含有个人信息或敏感字段,并设置访问控制、脱敏措施和清理周期。也不能让日志变成绕过数据库安全规则的敏感信息仓库。

不应该保存哪些信息

除非已经具备相应的合规能力,而且确有明确需求,否则不要保存:

  • 完整信用卡号;
  • CVV、CVC 或安全码;
  • 磁条数据;
  • PIN 或 PIN block;
  • 未经保护的网关访问令牌;
  • 含有完整敏感支付信息的请求、响应和调试日志。

应优先使用网关提供的托管支付页面、Hosted Fields 或客户端 Tokenization,让银行卡数据直接进入网关,不要先经过自己的服务器。

即使只保存卡号后四位,也要限制用途和访问范围。判断支付合规范围时,不能只看系统是否保存了完整卡号,还要考虑银行卡数据是否经过服务器、前端如何集成,以及日志和第三方脚本的处理方式。具体要求应以所用网关的集成文档、适用的 PCI DSS 要求和当地法律为准。

交易记录不等于会计账簿

payment_transactions 更接近支付操作记录。它可以用于对账,但不一定能直接满足正式的会计核算要求。

实际业务中,可能还要分别记录:

  • 客户应付金额;
  • 网关实际收款金额;
  • 手续费;
  • 税费;
  • 退款;
  • 拒付或争议款;
  • 网关结算到银行账户的净额;
  • 收款时间与结算时间的差异。

例如,客户支付 100.00,网关扣除 2.00 手续费后结算 98.00。不能因为银行只到账 98.00,就把销售额记为 98.00。具体如何入账取决于所在地规则和企业的核算方式,应由会计人员确认。

如果系统需要更严格的财务能力,可以另外设计一张不能随意修改的账务流水表,不要反复覆盖支付记录中的金额和状态。

还应加入定期对账

Webhook 可能因网络、配置错误或服务故障而漏收。因此,系统应定期从网关下载或查询交易、退款和结算记录,并与本地数据核对。

需要重点检查:

  • 网关已经成功、本地仍为 pending 的交易;
  • 本地标记成功、网关实际失败或已撤销的交易;
  • 金额或币种不一致;
  • 无法关联到本地订单的网关交易;
  • 本地存在但网关不存在的交易;
  • 遗漏的退款、拒付和手续费;
  • 已付款但尚未开通服务的订单。

发现异常后,应将记录放入人工处理队列,不能直接自动删除或覆盖。

实践中的注意事项

  • 调用网关前先创建本地记录,但状态只能是 pending。
  • 不要按照客户端提交的金额收费,服务端应根据可信配置重新计算。
  • 不要只凭 success redirect 开通服务。
  • 同时核对交易号、商户引用、金额、币种和商户账户。
  • 为支付请求、Webhook 和服务开通操作设计幂等机制。
  • 为数据库中的重要标识设置唯一约束,不要只靠应用代码判断是否重复。
  • 对网关密钥、Webhook Secret 和后台权限实行最小权限控制。
  • 不得把敏感字段写入普通应用日志或错误追踪系统。
  • 保存状态变化时间。必要时增加独立的状态历史表,以便审计。
  • 为失败、超时、退款、部分退款和拒付制定明确的处理流程。
  • 根据业务和法规设置保留期限,到期删除不再需要保存的个人信息。

正确的做法不是在支付前完整复制网关数据,而是先创建一条自己的支付意图记录,再根据网关返回的标识和可信的服务端通知更新状态。这样才能可靠地关联付款、服务开通、退款和财务对账。

备注:内容仅供参考。