From bc0e9f94e2a5dc6df7c7624e6905e3f004470a86 Mon Sep 17 00:00:00 2001 From: RWDai <27391645+RWDai@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:51:14 +0800 Subject: [PATCH] fix(admin): serialize wallet batches across tabs --- .../components/UserBatchActionDialog.vue | 10 ++ .../userBatchWalletIdempotency.spec.ts | 110 +++++++++++++++--- .../users/utils/userBatchWalletIdempotency.ts | 66 +++++++++-- frontend/src/i18n/legacy-admin-messages.ts | 2 + 4 files changed, 163 insertions(+), 25 deletions(-) diff --git a/frontend/src/features/users/components/UserBatchActionDialog.vue b/frontend/src/features/users/components/UserBatchActionDialog.vue index fff9293be..ac9175b37 100644 --- a/frontend/src/features/users/components/UserBatchActionDialog.vue +++ b/frontend/src/features/users/components/UserBatchActionDialog.vue @@ -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 { 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 { 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 diff --git a/frontend/src/features/users/utils/__tests__/userBatchWalletIdempotency.spec.ts b/frontend/src/features/users/utils/__tests__/userBatchWalletIdempotency.spec.ts index 2452fe98e..ad4ae4810 100644 --- a/frontend/src/features/users/utils/__tests__/userBatchWalletIdempotency.spec.ts +++ b/frontend/src/features/users/utils/__tests__/userBatchWalletIdempotency.spec.ts @@ -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 +let testLockManager: Pick + +type CoordinatorOptions = NonNullable[0]> + +function createTestLockManager(): Pick { + const heldNames = new Set() + const request = async ( + name: string, + _options: LockOptions, + callback: (lock: Lock | null) => Promise, + ): Promise => { + 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 +} + +function createCoordinator(options: Omit = {}) { + 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((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() }, diff --git a/frontend/src/features/users/utils/userBatchWalletIdempotency.ts b/frontend/src/features/users/utils/userBatchWalletIdempotency.ts index d81fc412c..c0117525c 100644 --- a/frontend/src/features/users/utils/userBatchWalletIdempotency.ts +++ b/frontend/src/features/users/utils/userBatchWalletIdempotency.ts @@ -23,6 +23,7 @@ interface CoordinatorOptions { storage?: StringStorage | null createKey?: () => string fallback?: Map + lockManager?: Pick | 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 | 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(storageKey: string, task: () => Promise): Promise { + 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, ) { 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, ): Promise { 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) + }) }, } } diff --git a/frontend/src/i18n/legacy-admin-messages.ts b/frontend/src/i18n/legacy-admin-messages.ts index bad7721f1..c81f44423 100644 --- a/frontend/src/i18n/legacy-admin-messages.ts +++ b/frontend/src/i18n/legacy-admin-messages.ts @@ -613,6 +613,8 @@ export const legacyAdminEnglishMessages: Record = { '系统调账': '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',