mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-03 16:07:46 +08:00
fix(admin): serialize wallet batches across tabs
This commit is contained in:
@@ -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()
|
||||
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()
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user