最佳实践
说明如何用 clientReference 避免重复调用造成重复扣款或重复入账。
幂等性是指多次调用同一操作与只调用一次具有相同效果的保证。没有它,重试会变成重复扣款、重复入账和丢失退款。
需要幂等性的场景
| 场景 | 无幂等性 | 有幂等性 |
|---|---|---|
应用在 POST /pix 后崩溃,但不知道是否送达 | 生成 2 笔扣款 | PayZu 返回已存在的那笔 |
POST /pix 超时,但 QR 已生成 | 客户看到 2 个不同的 QR | PayZu 返回同一笔交易 |
| 重试 job 触发同一笔扣款 2 次 | 2 笔扣款,客服困扰 | 1 笔扣款,客户正常支付 |
| 同一 callback 到达 2 次(超时后重试) | 订单标记已付款 2 次 | 忽略重复的那次 |
交易经历 PENDING → COMPLETED → REFUNDED | 可能忽略退款 | 每次状态转换只处理一次 |
创建时的 clientReference
clientReference 是您在创建扣款、Pix 付款或转账时定义的外部幂等标识符。PayZu 按账户 + clientReference 去重:同一个 key 只会在您自己的账户内冲突,如果交易已创建,则返回已存在的那笔。
如何生成
| 模式 | 何时使用 |
|---|---|
order-{orderId} | 每个订单 1 笔扣款。推荐。 |
payout-{payoutId} | 每次申请 1 笔 Pix 付款。 |
subscription-{subId}-{period} | 周期性扣款(每个周期 1 笔)。 |
retry-{orderId}-{attempt} | 当您需要在彻底失败后强制发起新扣款时。 |
transfer-{from}-{to}-{date} | 按天幂等的内部转账。 |
绝对不要使用 Date.now()、uuid() 或其他随机值作为 clientReference。重试会生成不同的值,PayZu 会创建重复扣款,这正好破坏了您想要的那个保证。
curl -X POST https://api.payzu.processamento.com/v1/pix \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"amount": 99.90,
"clientReference": "order-1234",
"callbackUrl": "https://seusite.com.br/webhooks/payzu"
}'其他语言的相同请求请见教程接收 Pix 付款。
callback 去重
同一 callback 可能多次到达:
- 送达重试:PayZu 按照 webhook 重试策略重新发送。
- 连续状态变化:
PENDING → COMPLETED → REFUNDED,每次都会产生 callback。 - 手动重新处理:通过
POST /user/callbacks/resend。
去重键不能只用 id:那样会因为已经看过 COMPLETED 而忽略 REFUNDED 的 callback,退款就不会入账。请按投递来源构造去重键:
- 注册的 webhook:使用
id加上X-Callback-Eventheader。有三个事件(TRANSACTION_SUSPECTED_FRAUD、TRANSACTION_SUSPECTED_FRAUD_REVERSAL和INFRACTION_CHANGED)不改变交易status,若用id + status会把这些投递当成重复而丢弃。 - 交易的
callbackUrl:没有事件 header,使用id + status。当请求体带有infraction对象时,请把infraction.status也加入键中,否则争议更新会丢失。
实现
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const TTL_30_DIAS = 30 * 86400;
type PayzuCallback = {
id: string;
type: 'DEPOSIT' | 'WITHDRAW';
method: 'PIX' | 'BANK_SLIP' | 'INTERNAL_TRANSFER';
status: 'PENDING' | 'COMPLETED' | 'CANCELED' | 'WAITING_FOR_REFUND' | 'REFUNDED' | 'EXPIRED' | 'ERROR';
clientReference?: string;
};
async function handleCallback(tx: PayzuCallback) {
const dedupeKey = `payzu:${tx.id}:${tx.status}`;
const isFirstTime = await redis.set(dedupeKey, '1', 'EX', TTL_30_DIAS, 'NX');
if (!isFirstTime) return;
await processTransaction(tx);
}常见陷阱
| 陷阱 | 症状 |
|---|---|
每次重试使用随机的 clientReference | 扣款重复,客户困惑 |
仅用 id 去重(不带 status) | 退款不入账,出现"幽灵"退款 |
| 去重 TTL 过短 | 延迟重试导致重新处理 |
| 内存中去重(本地 Map) | 重启后全部重新处理 |
因认为"会变化"而用 Date.now() 重新生成 clientReference | 不触发幂等性,产生新扣款 |