幂等性
幂等性是指多次调用同一操作与只调用一次具有相同效果的保证。没有它,重试会变成重复扣款、重复入账和丢失退款。
需要幂等性的场景
| 场景 | 无幂等性 | 有幂等性 |
|---|---|---|
应用在 POST /pix 后崩溃,但不知道是否送达 | 生成 2 笔扣款 | Magen 返回已存在的那笔 |
POST /pix 超时,但 QR 已生成 | 客户看到 2 个不同的 QR | Magen 返回同一笔交易 |
| 重试 job 触发同一笔扣款 2 次 | 2 笔扣款,客服困扰 | 1 笔扣款,客户正常支付 |
| 同一 callback 到达 2 次(超时后重试) | 订单标记已付款 2 次 | 忽略重复的那次 |
交易经历 PENDING → COMPLETED → REFUNDED | 可能忽略退款 | 每次状态转换只处理一次 |
创建时的 clientReference
clientReference 是您在创建扣款、提现或转账时定义的外部幂等标识符。Magen 按 userId + clientReference 索引,如果交易已创建,则返回已存在的那笔。
如何生成
| 模式 | 何时使用 |
|---|---|
order-{orderId} | 每个订单 1 笔扣款。推荐。 |
payout-{payoutId} | 每次申请 1 笔提现。 |
subscription-{subId}-{period} | 周期性扣款(每个周期 1 笔)。 |
retry-{orderId}-{attempt} | 当您需要在彻底失败后强制发起新扣款时。 |
transfer-{from}-{to}-{date} | 按天幂等的内部转账。 |
绝对不要使用 Date.now()、uuid() 或其他随机值作为 clientReference。重试会生成不同的值,Magen 会创建重复扣款,这正好破坏了您想要的那个保证。
curl -X POST https://api.magen.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/magen"
}'async function createCharge(orderId: string, amount: number) {
const res = await fetch('https://api.magen.processamento.com/v1/pix', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MAGEN_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount,
clientReference: `order-${orderId}`,
callbackUrl: 'https://seusite.com.br/webhooks/magen',
}),
});
return res.json();
}def create_charge(order_id: str, amount: float):
return requests.post(
'https://api.magen.processamento.com/v1/pix',
headers={
'Authorization': f'Bearer {os.environ["MAGEN_TOKEN"]}',
'Content-Type': 'application/json',
},
json={
'amount': amount,
'clientReference': f'order-{order_id}',
'callbackUrl': 'https://seusite.com.br/webhooks/magen',
},
).json()func createCharge(orderID string, amount float64) (map[string]any, error) {
payload, _ := json.Marshal(map[string]any{
"amount": amount,
"clientReference": "order-" + orderID,
"callbackUrl": "https://seusite.com.br/webhooks/magen",
})
req, _ := http.NewRequest("POST", "https://api.magen.processamento.com/v1/pix", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("MAGEN_TOKEN"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var out map[string]any
return out, json.NewDecoder(res.Body).Decode(&out)
}function createCharge(string $orderId, float $amount): array {
$ch = curl_init('https://api.magen.processamento.com/v1/pix');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('MAGEN_TOKEN'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'amount' => $amount,
'clientReference' => 'order-' . $orderId,
'callbackUrl' => 'https://seusite.com.br/webhooks/magen',
]),
]);
return json_decode(curl_exec($ch), true);
}通过 id + status 对 callback 去重
同一 callback 可能多次到达:
- 超时后重试:您在 5.1 秒响应,Magen 重新发送。
- 连续状态变化:
PENDING → COMPLETED → REFUNDED,每次都会产生 callback。 - 手动重新处理:通过
POST /user/callbacks/resend。
去重键必须是 id + status,而不仅仅是 id。如果只使用 id,您会忽略 REFUNDED 的 callback,因为之前已经看过 COMPLETED,这样退款就不会入账。
实现
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const TTL_30_DIAS = 30 * 86400;
type magenCallback = {
id: string;
type: 'DEPOSIT' | 'WITHDRAW' | 'INTERNAL_TRANSFER';
status: 'PENDING' | 'COMPLETED' | 'CANCELED' | 'WAITING_FOR_REFUND' | 'REFUNDED' | 'EXPIRED' | 'ERROR';
clientReference?: string;
};
async function handleCallback(tx: magenCallback) {
const dedupeKey = `magen:${tx.id}:${tx.status}`;
const isFirstTime = await redis.set(dedupeKey, '1', 'EX', TTL_30_DIAS, 'NX');
if (!isFirstTime) return;
await processTransaction(tx);
}import redis
r = redis.from_url(os.environ['REDIS_URL'])
TTL_30_DIAS = 30 * 86400
def handle_callback(tx: dict):
key = f"magen:{tx['id']}:{tx['status']}"
is_first = r.set(key, '1', ex=TTL_30_DIAS, nx=True)
if not is_first:
return
process_transaction(tx)func HandleCallback(ctx context.Context, tx magenCallback) error {
key := fmt.Sprintf("magen:%s:%s", tx.ID, tx.Status)
ok, err := rdb.SetNX(ctx, key, "1", 30*24*time.Hour).Result()
if err != nil { return err }
if !ok { return nil }
return processTransaction(ctx, tx)
}常见陷阱
| 陷阱 | 症状 |
|---|---|
每次重试使用随机的 clientReference | 扣款重复,客户困惑 |
仅用 id 去重(不带 status) | 退款不入账,出现"幽灵"退款 |
| 去重 TTL 过短 | 延迟重试导致重新处理 |
| 内存中去重(本地 Map) | 重启后全部重新处理 |
因认为"会变化"而用 Date.now() 重新生成 clientReference | 不触发幂等性,产生新扣款 |
Boas práticas
Padrões testados em produção que separam uma integração que dura uma semana de uma que aguenta produção. Idempotência, multi-tenant, callbacks, paginação, dinheiro, segurança, tratamento de erros e checklist final.
Multi-tenant com virtualAccount
Uma só conta Magen pode atender N tenants (lojas, filiais, marketplaces) usando virtualAccount. Volta em todo callback, permite filtrar listagens e dispensa criar contas filhas.