mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-03 16:41:20 +00:00
登记请求只有金额,提交成功但响应丢失时,管理员重试会把同一笔站外打款再扣 一次返利额度。现在一次登记由 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,登记有确定结果后 释放
69 lines
2.4 KiB
TypeScript
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)
|
|
}
|