是否需要单独存储 Payment Gateway 处理的交易记录?
结论
需要。系统应维护自己的支付交易记录,通常还应在调用 Payment Gateway 之前,先创建一条状态为 pending 的记录。
这张表不是用来复制网关保存的所有信息,更不能保存完整信用卡号、CVV/CVC、磁道数据等敏感内容。它主要用于关联业务订单和网关交易,处理异步通知与重复请求,以及支持对账、退款和争议排查。
各系统的职责可以这样划分:
- 业务系统记录客户为什么付款、应付多少,以及付款后应获得什么服务。
- Payment Gateway 负责处理银行卡支付。
- 会计或财务台账记录最终确认的资金变化。
- 网关后台不能取代业务系统自己的交易记录。
为什么不能只依赖 Payment Gateway
业务数据无法完全由网关表达
网关通常知道付款金额、币种、时间和网关交易号,但不一定知道这笔钱对应哪个客户、哪个套餐、哪段服务期限,也不知道付款成功后应开通哪些权限。
没有本地记录,后续就很难回答这些问题:
- 某位客户的服务为什么被开通?
- 某个订单对应网关中的哪笔交易?
- 客户重复提交了几次,最终哪一次成功?
- 已经成功扣款但权限没有开通,应如何补偿?
- 退款后,相关服务是否应被撤销?
- 网关账单和内部订单的金额为什么不一致?
支付结果与业务更新不是一个原子事务
支付可能已经成功,但系统在更新客户权限时数据库连接中断。反过来,浏览器也可能已经显示成功页面,服务端却还没有确认到账。
第三方支付和本地数据库通常不能放进同一个数据库事务,因此系统需要保存中间状态,再通过重试和对账实现最终一致性。仅靠一段“支付成功后立即开通服务”的代码,无法处理网络超时、进程崩溃和重复通知等情况。
浏览器返回结果不能作为最终依据
用户可能关闭或刷新页面,也可能修改请求参数。某些支付方式会先返回处理中状态,过一段时间才能得到最终结果。
因此,不能只因为浏览器跳转到了 success URL 就开通服务。最终结果应通过以下任一可信渠道确认:
- 网关发送的服务端 Webhook;
- 服务端使用网关交易号主动查询;
- 网关提供并经过签名验证的服务端响应。
具体使用哪种方式,取决于网关的接口设计。
推荐的数据模型
至少应区分业务订单和支付尝试。一张订单可能经历多次支付尝试,例如第一次失败、第二次成功,因此不适合把两者简单合并。
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 和后台权限实行最小权限控制。
- 不得把敏感字段写入普通应用日志或错误追踪系统。
- 保存状态变化时间。必要时增加独立的状态历史表,以便审计。
- 为失败、超时、退款、部分退款和拒付制定明确的处理流程。
- 根据业务和法规设置保留期限,到期删除不再需要保存的个人信息。
正确的做法不是在支付前完整复制网关数据,而是先创建一条自己的支付意图记录,再根据网关返回的标识和可信的服务端通知更新状态。这样才能可靠地关联付款、服务开通、退款和财务对账。
备注:内容仅供参考。
