# 幂等性 (/zh/docs/pix-processamento/best-practices/idempotency)

<QuickLinks>
  <QuickLink href="/docs/pix-processamento/endpoints/pix-operations/post_pix" title="POST /pix" />

  <QuickLink href="/docs/pix-processamento/endpoints/pix-operations/get_pix" title="GET /pix" />

  <QuickLink href="/docs/pix-processamento/webhooks" title="Webhooks" />
</QuickLinks>

幂等性是指**多次调用同一操作与只调用一次具有相同效果**的保证。没有它,重试会变成重复扣款、重复入账和丢失退款。

## 需要幂等性的场景 [#需要幂等性的场景]

| 场景                                    | 无幂等性           | 有幂等性           |
| ------------------------------------- | -------------- | -------------- |
| 应用在 `POST /pix` 后崩溃,但不知道是否送达          | 生成 2 笔扣款       | PayZu 返回已存在的那笔 |
| `POST /pix` 超时,但 QR 已生成               | 客户看到 2 个不同的 QR | PayZu 返回同一笔交易  |
| 重试 job 触发同一笔扣款 2 次                    | 2 笔扣款,客服困扰     | 1 笔扣款,客户正常支付   |
| 同一 callback 到达 2 次(超时后重试)             | 订单标记已付款 2 次    | 忽略重复的那次        |
| 交易经历 `PENDING → COMPLETED → REFUNDED` | 可能忽略退款         | 每次状态转换只处理一次    |

## 创建时的 `clientReference` [#创建时的-clientreference]

`clientReference` 是**您**在创建扣款、Pix 付款或转账时定义的**外部幂等标识符**。PayZu 按账户 + `clientReference` 去重:同一个 key 只会在您自己的账户内冲突,如果交易已创建,则返回已存在的那笔。

<Mermaid
  chart="`
flowchart LR
  A[&#x22;POST /pix&#x22;]
  A -->|&#x22;第 1 次调用&#x22;| B[&#x22;创建新的&#x22;]
  A -->|&#x22;重试&#x22;| C[&#x22;返回已存在的&#x22;]

  click A &#x22;/zh/docs/pix-processamento/endpoints/pix-operations/post_pix&#x22; &#x22;POST /pix&#x22;

  style B fill:#14ce71,stroke:#0eb464,color:#ffffff
  style C fill:#14ce71,stroke:#0eb464,color:#ffffff
`"
/>

### 如何生成 [#如何生成]

| 模式                              | 何时使用                    |
| ------------------------------- | ----------------------- |
| `order-{orderId}`               | 每个订单 1 笔扣款。推荐。          |
| `payout-{payoutId}`             | 每次申请 1 笔 Pix 付款。        |
| `subscription-{subId}-{period}` | 周期性扣款(每个周期 1 笔)。        |
| `retry-{orderId}-{attempt}`     | 当您需要在彻底失败后**强制**发起新扣款时。 |
| `transfer-{from}-{to}-{date}`   | 按天幂等的内部转账。              |

<Callout type="warn">
  **绝对不要**使用 `Date.now()`、`uuid()` 或其他随机值作为 `clientReference`。重试会生成不同的值,PayZu 会创建重复扣款,这正好破坏了您想要的那个保证。
</Callout>

```bash
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 付款](/docs/pix-processamento/tutoriais/receive-pix)。

## callback 去重 [#callback-去重]

同一 callback 可能多次到达:

* **送达重试**:PayZu 按照 [webhook 重试策略](/docs/pix-processamento/webhooks)重新发送。
* **连续状态变化**:`PENDING → COMPLETED → REFUNDED`,每次都会产生 callback。
* **手动重新处理**:通过 [`POST /user/callbacks/resend`](/docs/pix-processamento/endpoints/callbacks/resend_user_callbacks)。

去重键&#x2A;*不能只用 `id`**:那样会因为已经看过 `COMPLETED` 而忽略 `REFUNDED` 的 callback,退款就不会入账。请按投递来源构造去重键:

* **注册的 webhook**:使用 `id` 加上 `X-Callback-Event` header。有三个事件(`TRANSACTION_SUSPECTED_FRAUD`、`TRANSACTION_SUSPECTED_FRAUD_REVERSAL` 和 `INFRACTION_CHANGED`)不改变交易 `status`,若用 `id + status` 会把这些投递当成重复而丢弃。
* **交易的 `callbackUrl`**:没有事件 header,使用 `id + status`。当请求体带有 `infraction` 对象时,请把 `infraction.status` 也加入键中,否则争议更新会丢失。

<Mermaid
  chart="`
flowchart LR
  A[&#x22;第 1 次到达 COMPLETED&#x22;] -->|&#x22;唯一键&#x22;| K1[&#x22;处理&#x22;]
  B[&#x22;重试 COMPLETED&#x22;] -->|&#x22;相同键&#x22;| K2[&#x22;忽略&#x22;]
  C[&#x22;变更为 REFUNDED&#x22;] -->|&#x22;新键&#x22;| K3[&#x22;处理退款&#x22;]

  click A &#x22;/zh/docs/pix-processamento/webhooks&#x22; &#x22;Webhooks&#x22;
  click B &#x22;/zh/docs/pix-processamento/webhooks#重试机制&#x22; &#x22;重试&#x22;
  click C &#x22;/zh/docs/pix-processamento/med&#x22; &#x22;MED 退款&#x22;

  style K1 fill:#14ce71,stroke:#0eb464,color:#ffffff
  style K2 fill:#737373,stroke:#525252,color:#ffffff
  style K3 fill:#ef4444,stroke:#dc2626,color:#ffffff
`"
/>

### 实现 [#实现]

```ts
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` | 不触发幂等性,产生新扣款   |