Files
sub2api/frontend/src/views/admin/affiliates/affiliateWithdrawOperation.ts
T
feeeei c19204289d fix(affiliate): 线下提现登记按 Idempotency-Key 幂等
登记请求只有金额,提交成功但响应丢失时,管理员重试会把同一笔站外打款再扣
一次返利额度。现在一次登记由 Idempotency-Key 标识,数据库在同一事务内以唯一
约束去重:

- user_affiliate_ledger 新增 operation_id(幂等键派生的 SHA-256)与部分唯一
  索引(迁移 240);接口要求 Idempotency-Key,缺失或非法返回 400
- 事务内先以 operation_id 写入占位流水,再解冻、扣减并补写额度快照;同键
  重试返回首笔流水的结果并带 X-Idempotency-Replayed,不重复扣减;同键换了
  用户或金额返回 409 IDEMPOTENCY_KEY_CONFLICT;额度不足时占位随事务回滚
- 并发的同键请求在唯一索引上等待先到者:先到者提交则返回同一条流水,回滚
  则自行登记
- 弹窗在网络错误、超时、5xx、408/409 时锁定用户与金额,重试沿用同一请求与
  幂等键;幂等键按管理员、用户、金额暂存于 sessionStorage,登记有确定结果后
  释放
2026-09-23 01:24:59 +08:00

69 lines
2.4 KiB
TypeScript

/** 一笔线下提现登记及标识它的幂等键。 */
export interface AffiliateWithdrawOperation {
userId: number
amount: number
key: string
storageKey: string | null
outcomeUncertain: boolean
}
// 返利额度按 8 位小数记账。
const LEDGER_SCALE = 1e8
const pendingKeys = new Map<string, string>()
function currentAdminId(): number | null {
try {
const user = JSON.parse(globalThis.localStorage?.getItem('auth_user') ?? 'null') as { id?: unknown } | null
const id = user?.id
return typeof id === 'number' && Number.isSafeInteger(id) && id > 0 ? id : null
} catch {
return null
}
}
function readStoredKey(storageKey: string): string | null {
try {
return globalThis.sessionStorage?.getItem(storageKey) ?? null
} catch {
return null
}
}
function storeKey(storageKey: string, key: string | null) {
try {
if (key) globalThis.sessionStorage?.setItem(storageKey, key)
else globalThis.sessionStorage?.removeItem(storageKey)
} catch {
// 浏览器存储不可用时,同一页面内的重试仍由内存中的键保护。
}
}
/**
* 取得该用户与金额对应的登记。结果未知的登记在完成前一直保留它的键,
* 再次提交同一用户与金额时沿用该键,服务端据此返回首次结果而不重复扣减。
*/
export function prepareAffiliateWithdrawOperation(userId: number, amount: number): AffiliateWithdrawOperation {
const normalizedAmount = Math.round(amount * LEDGER_SCALE) / LEDGER_SCALE
const adminId = currentAdminId()
const storageKey = adminId ? `sub2api:admin:affiliate-withdraw:${adminId}:${userId}:${normalizedAmount}` : null
let key = storageKey ? pendingKeys.get(storageKey) ?? readStoredKey(storageKey) : null
const outcomeUncertain = !!key
if (!key) {
const requestId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
key = `affiliate-withdraw-${adminId ?? 'unknown'}-${requestId}`
}
if (storageKey) {
pendingKeys.set(storageKey, key)
storeKey(storageKey, key)
}
return { userId, amount: normalizedAmount, key, storageKey, outcomeUncertain }
}
/** 登记有了确定结果后释放它的键。 */
export function completeAffiliateWithdrawOperation(operation: AffiliateWithdrawOperation) {
if (!operation.storageKey) return
pendingKeys.delete(operation.storageKey)
storeKey(operation.storageKey, null)
}