fix(admin): serialize wallet batches across tabs

This commit is contained in:
RWDai
2026-09-28 18:51:14 +08:00
parent 74072e5007
commit bc0e9f94e2
4 changed files with 163 additions and 25 deletions
@@ -172,7 +172,9 @@ import { buildUserBatchBalanceAdjustmentPayload } from '@/api/users'
import {
createUserBatchWalletRetryCoordinator,
matchesPendingWalletRequest,
WalletIdempotencyCoordinationUnavailableError,
WalletIdempotencyPersistenceUnavailableError,
WalletIdempotencyRequestInProgressError,
WalletIdempotencyScopeChangedError,
WalletIdempotencyScopeUnavailableError,
WalletIdempotencyUnavailableError,
@@ -438,6 +440,10 @@ async function executeBatchAction(): Promise<void> {
refreshPendingWalletBatch()
if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
} else if (err instanceof WalletIdempotencyRequestInProgressError) {
warning(legacyT('另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。'))
} else if (err instanceof WalletIdempotencyCoordinationUnavailableError) {
warning(legacyT('当前浏览器无法保护跨标签页的钱包批量请求,请使用支持此功能的浏览器。请求未发送。'))
} else if (
err instanceof WalletIdempotencyUnavailableError
|| err instanceof WalletIdempotencyScopeUnavailableError
@@ -468,6 +474,10 @@ async function retryPendingWalletBatch(): Promise<void> {
refreshPendingWalletBatch()
if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
} else if (err instanceof WalletIdempotencyRequestInProgressError) {
warning(legacyT('另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。'))
} else if (err instanceof WalletIdempotencyCoordinationUnavailableError) {
warning(legacyT('当前浏览器无法保护跨标签页的钱包批量请求,请使用支持此功能的浏览器。请求未发送。'))
} else if (
err instanceof WalletIdempotencyScopeUnavailableError
|| err instanceof WalletIdempotencyScopeChangedError
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { UserBatchActionResponse, UserBatchBalanceActionRequest } from '@/api/users'
import {
createUserBatchWalletRetryCoordinator,
WalletIdempotencyCoordinationUnavailableError,
WalletIdempotencyRequestInProgressError,
UnresolvedWalletRequestMismatchError,
WalletIdempotencyPersistenceUnavailableError,
WalletIdempotencyUnavailableError,
@@ -23,6 +25,31 @@ const walletRequest = {
}
const defaultStorageKey = 'admin.users.batch.wallet-adjustment.pending.v1:default'
let testFallback: Map<string, string>
let testLockManager: Pick<LockManager, 'request'>
type CoordinatorOptions = NonNullable<Parameters<typeof createUserBatchWalletRetryCoordinator>[0]>
function createTestLockManager(): Pick<LockManager, 'request'> {
const heldNames = new Set<string>()
const request = async <T>(
name: string,
_options: LockOptions,
callback: (lock: Lock | null) => Promise<T>,
): Promise<T> => {
if (heldNames.has(name)) return callback(null)
heldNames.add(name)
try {
return await callback({ name, mode: 'exclusive' } as Lock)
} finally {
heldNames.delete(name)
}
}
return { request } as unknown as Pick<LockManager, 'request'>
}
function createCoordinator(options: Omit<CoordinatorOptions, 'lockManager'> = {}) {
return createUserBatchWalletRetryCoordinator({ ...options, lockManager: testLockManager })
}
function response(interrupted = false): UserBatchActionResponse {
return { total: 2, success: 1, failed: 0, failures: [], interrupted }
@@ -33,11 +60,12 @@ describe('user batch wallet idempotency', () => {
sessionStorage.clear()
localStorage.clear()
testFallback = new Map()
testLockManager = createTestLockManager()
})
it('serializes the key with the exact top-level wallet request before sending', async () => {
const storage = createStorage()
const coordinator = createUserBatchWalletRetryCoordinator({
const coordinator = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-1',
@@ -58,9 +86,57 @@ describe('user batch wallet idempotency', () => {
})
})
it('rejects a second tab while the same wallet batch request is in progress', async () => {
const storage = createStorage()
const first = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-first-tab',
})
const second = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-second-tab',
})
let resolveFirst!: (value: UserBatchActionResponse) => void
const firstSend = vi.fn(() => new Promise<UserBatchActionResponse>((resolve) => {
resolveFirst = resolve
}))
const secondSend = vi.fn(async () => response())
const firstExecution = first.execute(walletRequest, firstSend)
await vi.waitFor(() => expect(firstSend).toHaveBeenCalledOnce())
await expect(second.execute(walletRequest, secondSend)).rejects.toBeInstanceOf(
WalletIdempotencyRequestInProgressError,
)
expect(secondSend).not.toHaveBeenCalled()
resolveFirst(response())
await expect(firstExecution).resolves.toEqual(response())
expect(firstSend).toHaveBeenCalledOnce()
expect(storage.getItem(defaultStorageKey)).toBeNull()
})
it('fails closed when cross-tab request coordination is unavailable', async () => {
const storage = createStorage()
const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({
storage,
fallback: testFallback,
lockManager: null,
createKey: () => 'wallet-key-without-locks',
})
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
WalletIdempotencyCoordinationUnavailableError,
)
expect(send).not.toHaveBeenCalled()
expect(storage.getItem(defaultStorageKey)).toBeNull()
})
it('retains a transport failure and reopens with the exact request for retry', async () => {
const storage = createStorage()
const first = createUserBatchWalletRetryCoordinator({
const first = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-2',
@@ -68,7 +144,7 @@ describe('user batch wallet idempotency', () => {
const sendFailure = new Error('connection lost')
await expect(first.execute(walletRequest, async () => { throw sendFailure })).rejects.toBe(sendFailure)
const reopened = createUserBatchWalletRetryCoordinator({
const reopened = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'must-not-be-used',
@@ -86,7 +162,7 @@ describe('user batch wallet idempotency', () => {
})
it('keeps unresolved requests in persistent browser storage across coordinators', async () => {
const first = createUserBatchWalletRetryCoordinator({
const first = createCoordinator({
createKey: () => 'wallet-key-persistent',
scope: () => 'admin-1',
})
@@ -94,7 +170,7 @@ describe('user batch wallet idempotency', () => {
throw new Error('connection lost')
})).rejects.toThrow('connection lost')
const reopened = createUserBatchWalletRetryCoordinator({ scope: () => 'admin-1' })
const reopened = createCoordinator({ scope: () => 'admin-1' })
const pending = reopened.getPending()
expect(pending?.request).toEqual({
...walletRequest,
@@ -111,7 +187,7 @@ describe('user batch wallet idempotency', () => {
it('keeps unresolved requests isolated by authenticated administrator', async () => {
const storage = createStorage()
const adminA = createUserBatchWalletRetryCoordinator({
const adminA = createCoordinator({
storage,
fallback: testFallback,
scope: () => 'admin-a',
@@ -120,7 +196,7 @@ describe('user batch wallet idempotency', () => {
await expect(adminA.execute(walletRequest, async () => { throw new Error('connection lost') }))
.rejects.toThrow('connection lost')
const adminB = createUserBatchWalletRetryCoordinator({
const adminB = createCoordinator({
storage,
fallback: testFallback,
scope: () => 'admin-b',
@@ -137,7 +213,7 @@ describe('user batch wallet idempotency', () => {
const storage = createStorage()
let scopeReads = 0
const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({
const coordinator = createCoordinator({
storage,
fallback: testFallback,
scope: () => (++scopeReads === 1 ? 'admin-a' : 'admin-b'),
@@ -153,13 +229,13 @@ describe('user batch wallet idempotency', () => {
it('retains interrupted requests and reuses their key until a terminal response', async () => {
const storage = createStorage()
const first = createUserBatchWalletRetryCoordinator({
const first = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-3',
})
await first.execute(walletRequest, async () => response(true))
const reopened = createUserBatchWalletRetryCoordinator({ storage, fallback: testFallback })
const reopened = createCoordinator({ storage, fallback: testFallback })
const pending = reopened.getPending()
const send = vi.fn(async () => response(true))
@@ -180,14 +256,14 @@ describe('user batch wallet idempotency', () => {
...walletRequest,
selection: { filters: { search: 'active', is_active: undefined } },
}
const first = createUserBatchWalletRetryCoordinator({
const first = createCoordinator({
storage,
fallback: testFallback,
createKey: () => 'wallet-key-filter',
})
await first.execute(requestWithUndefinedField, async () => response(true))
const reopened = createUserBatchWalletRetryCoordinator({ storage, fallback: testFallback })
const reopened = createCoordinator({ storage, fallback: testFallback })
const send = vi.fn(async () => response())
await reopened.execute({
...walletRequest,
@@ -204,7 +280,7 @@ describe('user batch wallet idempotency', () => {
it('blocks changed payloads while unresolved and gives a later adjustment a new key', async () => {
const storage = createStorage()
let nextKey = 0
const coordinator = createUserBatchWalletRetryCoordinator({
const coordinator = createCoordinator({
storage,
fallback: testFallback,
createKey: () => `wallet-key-${++nextKey}`,
@@ -240,7 +316,7 @@ describe('user batch wallet idempotency', () => {
removeItem: () => undefined,
}
const send = vi.fn(async () => response(true))
const coordinator = createUserBatchWalletRetryCoordinator({
const coordinator = createCoordinator({
storage: unavailableStorage,
fallback: testFallback,
createKey: () => 'wallet-key-fallback',
@@ -261,7 +337,7 @@ describe('user batch wallet idempotency', () => {
removeItem: () => undefined,
}
const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({
const coordinator = createCoordinator({
storage: mismatchedStorage,
fallback: testFallback,
createKey: () => 'wallet-key-readback',
@@ -281,7 +357,7 @@ describe('user batch wallet idempotency', () => {
removeItem: () => undefined,
}
const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({
const coordinator = createCoordinator({
storage: unavailableStorage,
fallback: testFallback,
createKey: () => 'must-not-be-used',
@@ -297,7 +373,7 @@ describe('user batch wallet idempotency', () => {
it('fails closed when secure UUID generation is unavailable', async () => {
const storage = createStorage()
const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({
const coordinator = createCoordinator({
storage,
fallback: testFallback,
createKey: () => { throw new WalletIdempotencyUnavailableError() },
@@ -23,6 +23,7 @@ interface CoordinatorOptions {
storage?: StringStorage | null
createKey?: () => string
fallback?: Map<string, string>
lockManager?: Pick<LockManager, 'request'> | null
scope?: () => string | null
}
@@ -50,6 +51,20 @@ export class WalletIdempotencyPersistenceUnavailableError extends Error {
}
}
export class WalletIdempotencyCoordinationUnavailableError extends Error {
constructor() {
super('Cross-tab wallet request coordination is unavailable')
this.name = 'WalletIdempotencyCoordinationUnavailableError'
}
}
export class WalletIdempotencyRequestInProgressError extends Error {
constructor() {
super('A wallet batch request is already in progress in another tab')
this.name = 'WalletIdempotencyRequestInProgressError'
}
}
export class WalletIdempotencyScopeUnavailableError extends Error {
constructor() {
super('The authenticated administrator identity is unavailable')
@@ -79,6 +94,14 @@ function browserPersistentStorage(): StringStorage | null {
}
}
function browserLockManager(): Pick<LockManager, 'request'> | null {
try {
return globalThis.navigator?.locks ?? null
} catch {
return null
}
}
function secureRandomUUID(): string {
try {
const cryptoApi = globalThis.crypto
@@ -145,6 +168,9 @@ export function createUserBatchWalletRetryCoordinator(options: CoordinatorOption
const storage = 'storage' in options ? options.storage ?? null : browserPersistentStorage()
const fallback = options.fallback ?? inMemoryFallback
const createKey = options.createKey ?? secureRandomUUID
const lockManager = 'lockManager' in options
? options.lockManager ?? null
: browserLockManager()
const getScope = options.scope ?? (() => 'default')
function getStorageKey(): string {
@@ -192,6 +218,26 @@ export function createUserBatchWalletRetryCoordinator(options: CoordinatorOption
}
}
async function withExclusiveLock<T>(storageKey: string, task: () => Promise<T>): Promise<T> {
if (!lockManager) throw new WalletIdempotencyCoordinationUnavailableError()
let taskStarted = false
try {
return await lockManager.request(
storageKey,
{ mode: 'exclusive', ifAvailable: true },
async (lock) => {
if (lock === null) throw new WalletIdempotencyRequestInProgressError()
taskStarted = true
return task()
},
)
} catch (error) {
if (taskStarted || error instanceof WalletIdempotencyRequestInProgressError) throw error
throw new WalletIdempotencyCoordinationUnavailableError()
}
}
function getOrCreate(
storageKey: string,
request: UserBatchWalletAdjustmentRequest,
@@ -234,19 +280,23 @@ export function createUserBatchWalletRetryCoordinator(options: CoordinatorOption
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
) {
const storageKey = getStorageKey()
const keyedRequest = getOrCreate(storageKey, request)
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
return sendAndResolve(keyedRequest, send, storageKey)
return withExclusiveLock(storageKey, async () => {
const keyedRequest = getOrCreate(storageKey, request)
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
return sendAndResolve(keyedRequest, send, storageKey)
})
},
async retry(
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
): Promise<UserBatchActionResponse | null> {
const storageKey = getStorageKey()
const pending = readPending(storageKey)
if (!pending) return null
persist(storageKey, pending)
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
return sendAndResolve(pending.request, send, storageKey)
return withExclusiveLock(storageKey, async () => {
const pending = readPending(storageKey)
if (!pending) return null
persist(storageKey, pending)
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
return sendAndResolve(pending.request, send, storageKey)
})
},
}
}
@@ -613,6 +613,8 @@ export const legacyAdminEnglishMessages: Record<string, string> = {
'系统调账': 'System adjustment',
'退款扣减': 'Refund debit',
'退款回补': 'Refund recredit',
'另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。': 'Another tab is processing a wallet batch adjustment. Refresh the status and try again later. No new request was sent from this tab.',
'当前浏览器无法保护跨标签页的钱包批量请求,请使用支持此功能的浏览器。请求未发送。': 'This browser cannot protect wallet batch requests across tabs. Use a browser that supports this feature. The request was not sent.',
'兑换码批次已创建': 'Redemption code batch created',
'CSV 已导出': 'CSV exported',
'批次已停用': 'Batch disabled',