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 { import {
createUserBatchWalletRetryCoordinator, createUserBatchWalletRetryCoordinator,
matchesPendingWalletRequest, matchesPendingWalletRequest,
WalletIdempotencyCoordinationUnavailableError,
WalletIdempotencyPersistenceUnavailableError, WalletIdempotencyPersistenceUnavailableError,
WalletIdempotencyRequestInProgressError,
WalletIdempotencyScopeChangedError, WalletIdempotencyScopeChangedError,
WalletIdempotencyScopeUnavailableError, WalletIdempotencyScopeUnavailableError,
WalletIdempotencyUnavailableError, WalletIdempotencyUnavailableError,
@@ -438,6 +440,10 @@ async function executeBatchAction(): Promise<void> {
refreshPendingWalletBatch() refreshPendingWalletBatch()
if (err instanceof WalletIdempotencyPersistenceUnavailableError) { if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。')) warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
} else if (err instanceof WalletIdempotencyRequestInProgressError) {
warning(legacyT('另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。'))
} else if (err instanceof WalletIdempotencyCoordinationUnavailableError) {
warning(legacyT('当前浏览器无法保护跨标签页的钱包批量请求,请使用支持此功能的浏览器。请求未发送。'))
} else if ( } else if (
err instanceof WalletIdempotencyUnavailableError err instanceof WalletIdempotencyUnavailableError
|| err instanceof WalletIdempotencyScopeUnavailableError || err instanceof WalletIdempotencyScopeUnavailableError
@@ -468,6 +474,10 @@ async function retryPendingWalletBatch(): Promise<void> {
refreshPendingWalletBatch() refreshPendingWalletBatch()
if (err instanceof WalletIdempotencyPersistenceUnavailableError) { if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。')) warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
} else if (err instanceof WalletIdempotencyRequestInProgressError) {
warning(legacyT('另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。'))
} else if (err instanceof WalletIdempotencyCoordinationUnavailableError) {
warning(legacyT('当前浏览器无法保护跨标签页的钱包批量请求,请使用支持此功能的浏览器。请求未发送。'))
} else if ( } else if (
err instanceof WalletIdempotencyScopeUnavailableError err instanceof WalletIdempotencyScopeUnavailableError
|| err instanceof WalletIdempotencyScopeChangedError || err instanceof WalletIdempotencyScopeChangedError
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { UserBatchActionResponse, UserBatchBalanceActionRequest } from '@/api/users' import type { UserBatchActionResponse, UserBatchBalanceActionRequest } from '@/api/users'
import { import {
createUserBatchWalletRetryCoordinator, createUserBatchWalletRetryCoordinator,
WalletIdempotencyCoordinationUnavailableError,
WalletIdempotencyRequestInProgressError,
UnresolvedWalletRequestMismatchError, UnresolvedWalletRequestMismatchError,
WalletIdempotencyPersistenceUnavailableError, WalletIdempotencyPersistenceUnavailableError,
WalletIdempotencyUnavailableError, WalletIdempotencyUnavailableError,
@@ -23,6 +25,31 @@ const walletRequest = {
} }
const defaultStorageKey = 'admin.users.batch.wallet-adjustment.pending.v1:default' const defaultStorageKey = 'admin.users.batch.wallet-adjustment.pending.v1:default'
let testFallback: Map<string, string> 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 { function response(interrupted = false): UserBatchActionResponse {
return { total: 2, success: 1, failed: 0, failures: [], interrupted } return { total: 2, success: 1, failed: 0, failures: [], interrupted }
@@ -33,11 +60,12 @@ describe('user batch wallet idempotency', () => {
sessionStorage.clear() sessionStorage.clear()
localStorage.clear() localStorage.clear()
testFallback = new Map() testFallback = new Map()
testLockManager = createTestLockManager()
}) })
it('serializes the key with the exact top-level wallet request before sending', async () => { it('serializes the key with the exact top-level wallet request before sending', async () => {
const storage = createStorage() const storage = createStorage()
const coordinator = createUserBatchWalletRetryCoordinator({ const coordinator = createCoordinator({
storage, storage,
fallback: testFallback, fallback: testFallback,
createKey: () => 'wallet-key-1', 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 () => { it('retains a transport failure and reopens with the exact request for retry', async () => {
const storage = createStorage() const storage = createStorage()
const first = createUserBatchWalletRetryCoordinator({ const first = createCoordinator({
storage, storage,
fallback: testFallback, fallback: testFallback,
createKey: () => 'wallet-key-2', createKey: () => 'wallet-key-2',
@@ -68,7 +144,7 @@ describe('user batch wallet idempotency', () => {
const sendFailure = new Error('connection lost') const sendFailure = new Error('connection lost')
await expect(first.execute(walletRequest, async () => { throw sendFailure })).rejects.toBe(sendFailure) await expect(first.execute(walletRequest, async () => { throw sendFailure })).rejects.toBe(sendFailure)
const reopened = createUserBatchWalletRetryCoordinator({ const reopened = createCoordinator({
storage, storage,
fallback: testFallback, fallback: testFallback,
createKey: () => 'must-not-be-used', 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 () => { it('keeps unresolved requests in persistent browser storage across coordinators', async () => {
const first = createUserBatchWalletRetryCoordinator({ const first = createCoordinator({
createKey: () => 'wallet-key-persistent', createKey: () => 'wallet-key-persistent',
scope: () => 'admin-1', scope: () => 'admin-1',
}) })
@@ -94,7 +170,7 @@ describe('user batch wallet idempotency', () => {
throw new Error('connection lost') throw new Error('connection lost')
})).rejects.toThrow('connection lost') })).rejects.toThrow('connection lost')
const reopened = createUserBatchWalletRetryCoordinator({ scope: () => 'admin-1' }) const reopened = createCoordinator({ scope: () => 'admin-1' })
const pending = reopened.getPending() const pending = reopened.getPending()
expect(pending?.request).toEqual({ expect(pending?.request).toEqual({
...walletRequest, ...walletRequest,
@@ -111,7 +187,7 @@ describe('user batch wallet idempotency', () => {
it('keeps unresolved requests isolated by authenticated administrator', async () => { it('keeps unresolved requests isolated by authenticated administrator', async () => {
const storage = createStorage() const storage = createStorage()
const adminA = createUserBatchWalletRetryCoordinator({ const adminA = createCoordinator({
storage, storage,
fallback: testFallback, fallback: testFallback,
scope: () => 'admin-a', scope: () => 'admin-a',
@@ -120,7 +196,7 @@ describe('user batch wallet idempotency', () => {
await expect(adminA.execute(walletRequest, async () => { throw new Error('connection lost') })) await expect(adminA.execute(walletRequest, async () => { throw new Error('connection lost') }))
.rejects.toThrow('connection lost') .rejects.toThrow('connection lost')
const adminB = createUserBatchWalletRetryCoordinator({ const adminB = createCoordinator({
storage, storage,
fallback: testFallback, fallback: testFallback,
scope: () => 'admin-b', scope: () => 'admin-b',
@@ -137,7 +213,7 @@ describe('user batch wallet idempotency', () => {
const storage = createStorage() const storage = createStorage()
let scopeReads = 0 let scopeReads = 0
const send = vi.fn(async () => response()) const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({ const coordinator = createCoordinator({
storage, storage,
fallback: testFallback, fallback: testFallback,
scope: () => (++scopeReads === 1 ? 'admin-a' : 'admin-b'), 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 () => { it('retains interrupted requests and reuses their key until a terminal response', async () => {
const storage = createStorage() const storage = createStorage()
const first = createUserBatchWalletRetryCoordinator({ const first = createCoordinator({
storage, storage,
fallback: testFallback, fallback: testFallback,
createKey: () => 'wallet-key-3', createKey: () => 'wallet-key-3',
}) })
await first.execute(walletRequest, async () => response(true)) await first.execute(walletRequest, async () => response(true))
const reopened = createUserBatchWalletRetryCoordinator({ storage, fallback: testFallback }) const reopened = createCoordinator({ storage, fallback: testFallback })
const pending = reopened.getPending() const pending = reopened.getPending()
const send = vi.fn(async () => response(true)) const send = vi.fn(async () => response(true))
@@ -180,14 +256,14 @@ describe('user batch wallet idempotency', () => {
...walletRequest, ...walletRequest,
selection: { filters: { search: 'active', is_active: undefined } }, selection: { filters: { search: 'active', is_active: undefined } },
} }
const first = createUserBatchWalletRetryCoordinator({ const first = createCoordinator({
storage, storage,
fallback: testFallback, fallback: testFallback,
createKey: () => 'wallet-key-filter', createKey: () => 'wallet-key-filter',
}) })
await first.execute(requestWithUndefinedField, async () => response(true)) 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()) const send = vi.fn(async () => response())
await reopened.execute({ await reopened.execute({
...walletRequest, ...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 () => { it('blocks changed payloads while unresolved and gives a later adjustment a new key', async () => {
const storage = createStorage() const storage = createStorage()
let nextKey = 0 let nextKey = 0
const coordinator = createUserBatchWalletRetryCoordinator({ const coordinator = createCoordinator({
storage, storage,
fallback: testFallback, fallback: testFallback,
createKey: () => `wallet-key-${++nextKey}`, createKey: () => `wallet-key-${++nextKey}`,
@@ -240,7 +316,7 @@ describe('user batch wallet idempotency', () => {
removeItem: () => undefined, removeItem: () => undefined,
} }
const send = vi.fn(async () => response(true)) const send = vi.fn(async () => response(true))
const coordinator = createUserBatchWalletRetryCoordinator({ const coordinator = createCoordinator({
storage: unavailableStorage, storage: unavailableStorage,
fallback: testFallback, fallback: testFallback,
createKey: () => 'wallet-key-fallback', createKey: () => 'wallet-key-fallback',
@@ -261,7 +337,7 @@ describe('user batch wallet idempotency', () => {
removeItem: () => undefined, removeItem: () => undefined,
} }
const send = vi.fn(async () => response()) const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({ const coordinator = createCoordinator({
storage: mismatchedStorage, storage: mismatchedStorage,
fallback: testFallback, fallback: testFallback,
createKey: () => 'wallet-key-readback', createKey: () => 'wallet-key-readback',
@@ -281,7 +357,7 @@ describe('user batch wallet idempotency', () => {
removeItem: () => undefined, removeItem: () => undefined,
} }
const send = vi.fn(async () => response()) const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({ const coordinator = createCoordinator({
storage: unavailableStorage, storage: unavailableStorage,
fallback: testFallback, fallback: testFallback,
createKey: () => 'must-not-be-used', createKey: () => 'must-not-be-used',
@@ -297,7 +373,7 @@ describe('user batch wallet idempotency', () => {
it('fails closed when secure UUID generation is unavailable', async () => { it('fails closed when secure UUID generation is unavailable', async () => {
const storage = createStorage() const storage = createStorage()
const send = vi.fn(async () => response()) const send = vi.fn(async () => response())
const coordinator = createUserBatchWalletRetryCoordinator({ const coordinator = createCoordinator({
storage, storage,
fallback: testFallback, fallback: testFallback,
createKey: () => { throw new WalletIdempotencyUnavailableError() }, createKey: () => { throw new WalletIdempotencyUnavailableError() },
@@ -23,6 +23,7 @@ interface CoordinatorOptions {
storage?: StringStorage | null storage?: StringStorage | null
createKey?: () => string createKey?: () => string
fallback?: Map<string, string> fallback?: Map<string, string>
lockManager?: Pick<LockManager, 'request'> | null
scope?: () => string | 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 { export class WalletIdempotencyScopeUnavailableError extends Error {
constructor() { constructor() {
super('The authenticated administrator identity is unavailable') 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 { function secureRandomUUID(): string {
try { try {
const cryptoApi = globalThis.crypto const cryptoApi = globalThis.crypto
@@ -145,6 +168,9 @@ export function createUserBatchWalletRetryCoordinator(options: CoordinatorOption
const storage = 'storage' in options ? options.storage ?? null : browserPersistentStorage() const storage = 'storage' in options ? options.storage ?? null : browserPersistentStorage()
const fallback = options.fallback ?? inMemoryFallback const fallback = options.fallback ?? inMemoryFallback
const createKey = options.createKey ?? secureRandomUUID const createKey = options.createKey ?? secureRandomUUID
const lockManager = 'lockManager' in options
? options.lockManager ?? null
: browserLockManager()
const getScope = options.scope ?? (() => 'default') const getScope = options.scope ?? (() => 'default')
function getStorageKey(): string { 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( function getOrCreate(
storageKey: string, storageKey: string,
request: UserBatchWalletAdjustmentRequest, request: UserBatchWalletAdjustmentRequest,
@@ -234,19 +280,23 @@ export function createUserBatchWalletRetryCoordinator(options: CoordinatorOption
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>, send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
) { ) {
const storageKey = getStorageKey() const storageKey = getStorageKey()
return withExclusiveLock(storageKey, async () => {
const keyedRequest = getOrCreate(storageKey, request) const keyedRequest = getOrCreate(storageKey, request)
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError() if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
return sendAndResolve(keyedRequest, send, storageKey) return sendAndResolve(keyedRequest, send, storageKey)
})
}, },
async retry( async retry(
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>, send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
): Promise<UserBatchActionResponse | null> { ): Promise<UserBatchActionResponse | null> {
const storageKey = getStorageKey() const storageKey = getStorageKey()
return withExclusiveLock(storageKey, async () => {
const pending = readPending(storageKey) const pending = readPending(storageKey)
if (!pending) return null if (!pending) return null
persist(storageKey, pending) persist(storageKey, pending)
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError() if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
return sendAndResolve(pending.request, send, storageKey) return sendAndResolve(pending.request, send, storageKey)
})
}, },
} }
} }
@@ -613,6 +613,8 @@ export const legacyAdminEnglishMessages: Record<string, string> = {
'系统调账': 'System adjustment', '系统调账': 'System adjustment',
'退款扣减': 'Refund debit', '退款扣减': 'Refund debit',
'退款回补': 'Refund recredit', '退款回补': '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', '兑换码批次已创建': 'Redemption code batch created',
'CSV 已导出': 'CSV exported', 'CSV 已导出': 'CSV exported',
'批次已停用': 'Batch disabled', '批次已停用': 'Batch disabled',