From bcd121d4471cd97c92a7b245ebd51177ff9c2df3 Mon Sep 17 00:00:00 2001
From: RWDai <27391645+RWDai@users.noreply.github.com>
Date: Wed, 23 Sep 2026 19:43:18 +0800
Subject: [PATCH] fix(admin): make bulk wallet batches idempotent
---
apps/aether-gateway/src/data/state/mod.rs | 12 +-
apps/aether-gateway/src/data/state/runtime.rs | 113 +++-
.../src/handlers/admin/request/state.rs | 58 ++
.../src/handlers/admin/users/batch.rs | 455 ++++++++++++++-
apps/aether-gateway/src/state/app.rs | 15 +
apps/aether-gateway/src/state/core.rs | 6 +
.../state/runtime/wallet/balance_mutations.rs | 191 +++++++
apps/aether-gateway/src/state/testing.rs | 8 +
.../src/tests/control/admin/users_batch.rs | 183 ++++++-
...000_add_admin_wallet_batch_idempotency.sql | 12 +
.../adapters/postgres/src/wallet.rs | 518 +++++++++++++++++-
.../contracts/src/repository/wallet/types.rs | 92 ++++
.../postgres/baseline/005_wallet_billing.sql | 14 +
.../schema/logical/005_wallet_billing.toml | 44 ++
.../runtime/src/repository/wallet/mod.rs | 43 +-
frontend/src/api/users.ts | 1 +
.../components/UserBatchActionDialog.vue | 162 +++++-
.../userBatchWalletIdempotency.spec.ts | 312 +++++++++++
.../users/utils/userBatchWalletIdempotency.ts | 252 +++++++++
19 files changed, 2396 insertions(+), 95 deletions(-)
create mode 100644 crates/aether-data/adapters/postgres/migrations/20260923000000_add_admin_wallet_batch_idempotency.sql
create mode 100644 frontend/src/features/users/utils/__tests__/userBatchWalletIdempotency.spec.ts
create mode 100644 frontend/src/features/users/utils/userBatchWalletIdempotency.ts
diff --git a/apps/aether-gateway/src/data/state/mod.rs b/apps/aether-gateway/src/data/state/mod.rs
index 0a455684b..6837220c7 100644
--- a/apps/aether-gateway/src/data/state/mod.rs
+++ b/apps/aether-gateway/src/data/state/mod.rs
@@ -62,8 +62,9 @@ pub(crate) use aether_data::repository::users::{
StoredUserPreferenceRecord, StoredUserSessionRecord,
};
use aether_data::repository::wallet::{
- AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
- AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
+ AdjustWalletBalanceInBatchInput, AdjustWalletBalanceInput, AdminPaymentOrderListQuery,
+ AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery,
+ AdminUserWalletBalanceBatchUserOutcome, AdminWalletLedgerQuery, AdminWalletListQuery,
AdminWalletRefundRequestListQuery, CompareAndSwapPaymentOrderStripeClientSecretInput,
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
@@ -72,13 +73,14 @@ use aether_data::repository::wallet::{
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
DeleteAdminRedeemCodeBatchInput, DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput,
FailAdminWalletRefundInput, FailWalletRechargeCheckoutInput, InitializeAuthWalletOutcome,
+ PrepareAdminUserWalletBalanceBatchInput, PrepareAdminUserWalletBalanceBatchOutcome,
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
ReclaimWalletRechargeCheckoutInput, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder,
StoredAdminPaymentOrderPage, StoredAdminRedeemCode, StoredAdminRedeemCodeBatch,
- StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminWalletLedgerPage,
- StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
- StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
+ StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminUserWalletBalanceBatch,
+ StoredAdminWalletLedgerPage, StoredAdminWalletListPage, StoredAdminWalletRefund,
+ StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput,
UpdateWalletRechargeCheckoutInput, WalletLookupKey, WalletMutationOutcome,
diff --git a/apps/aether-gateway/src/data/state/runtime.rs b/apps/aether-gateway/src/data/state/runtime.rs
index b06449a80..5cac1999b 100644
--- a/apps/aether-gateway/src/data/state/runtime.rs
+++ b/apps/aether-gateway/src/data/state/runtime.rs
@@ -1,9 +1,10 @@
use super::{
read_decision_trace, read_provider_transport_snapshot, read_request_candidate_trace,
- AdjustWalletBalanceInput, AdminBillingCollectorRecord, AdminBillingCollectorWriteInput,
- AdminBillingMutationOutcome, AdminBillingPresetApplyResult, AdminBillingRuleRecord,
- AdminBillingRuleWriteInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
- AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
+ AdjustWalletBalanceInBatchInput, AdjustWalletBalanceInput, AdminBillingCollectorRecord,
+ AdminBillingCollectorWriteInput, AdminBillingMutationOutcome, AdminBillingPresetApplyResult,
+ AdminBillingRuleRecord, AdminBillingRuleWriteInput, AdminPaymentOrderListQuery,
+ AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery,
+ AdminUserWalletBalanceBatchUserOutcome, AdminWalletLedgerQuery, AdminWalletListQuery,
AdminWalletRefundRequestListQuery, AnnouncementListQuery, AuditLogListQuery,
BackgroundTaskListQuery, BackgroundTaskSummary, BillingModelContextCacheKey,
BillingModelContextCacheState, BillingModelContextInflightState, BillingPlanRecord,
@@ -17,24 +18,25 @@ use super::{
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
FailWalletRechargeCheckoutInput, GatewayDataState, GatewayProviderTransportSnapshot,
LocalVideoTaskReadResponse, PaymentGatewayConfigCasWriteInput, PaymentGatewayConfigRecord,
- PaymentGatewayConfigWriteInput, PaymentGatewaySecretCasUpdate, ProcessAdminWalletRefundInput,
- ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome, ReclaimWalletRechargeCheckoutInput,
- ReconcileUsagePolicyCostInput, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
- ReleaseUsagePolicyRequestAdmissionInput, RequestAuditBundle, RequestCandidateTrace,
- ReserveUsagePolicyCostInput, ReserveUsagePolicyCostOutcome, ReserveUsagePolicyRequestInput,
- ReserveUsagePolicyRequestOutcome, StoredAdminAuditLogPage, StoredAdminPaymentCallbackPage,
- StoredAdminPaymentOrder, StoredAdminPaymentOrderPage, StoredAdminRedeemCodeBatch,
- StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminWalletLedgerPage,
- StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
- StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
- StoredAdminWalletTransactionPage, StoredAnnouncement, StoredAnnouncementPage,
- StoredBackgroundTaskEvent, StoredBackgroundTaskRun, StoredBackgroundTaskRunPage,
- StoredBillingModelContext, StoredProviderQuotaSnapshot, StoredProviderUsageSummary,
- StoredRequestUsageAudit, StoredSuspiciousActivity, StoredUsagePolicyCostReservation,
- StoredUsagePolicyRequestAdmission, StoredUsageSettlement, StoredUserAuditLogPage,
- StoredUserAuthRecord, StoredUserExportRow, StoredUserSummary, StoredVideoTask,
- StoredWalletDailyUsageLedger, StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot,
- UpdateAdminWalletRefundGatewayInput, UpdateAnnouncementRecord,
+ PaymentGatewayConfigWriteInput, PaymentGatewaySecretCasUpdate,
+ PrepareAdminUserWalletBalanceBatchInput, PrepareAdminUserWalletBalanceBatchOutcome,
+ ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
+ ReclaimWalletRechargeCheckoutInput, ReconcileUsagePolicyCostInput, RedeemWalletCodeInput,
+ RedeemWalletCodeOutcome, ReleaseUsagePolicyRequestAdmissionInput, RequestAuditBundle,
+ RequestCandidateTrace, ReserveUsagePolicyCostInput, ReserveUsagePolicyCostOutcome,
+ ReserveUsagePolicyRequestInput, ReserveUsagePolicyRequestOutcome, StoredAdminAuditLogPage,
+ StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder, StoredAdminPaymentOrderPage,
+ StoredAdminRedeemCodeBatch, StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage,
+ StoredAdminUserWalletBalanceBatch, StoredAdminWalletLedgerPage, StoredAdminWalletListPage,
+ StoredAdminWalletRefund, StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestPage,
+ StoredAdminWalletTransaction, StoredAdminWalletTransactionPage, StoredAnnouncement,
+ StoredAnnouncementPage, StoredBackgroundTaskEvent, StoredBackgroundTaskRun,
+ StoredBackgroundTaskRunPage, StoredBillingModelContext, StoredProviderQuotaSnapshot,
+ StoredProviderUsageSummary, StoredRequestUsageAudit, StoredSuspiciousActivity,
+ StoredUsagePolicyCostReservation, StoredUsagePolicyRequestAdmission, StoredUsageSettlement,
+ StoredUserAuditLogPage, StoredUserAuthRecord, StoredUserExportRow, StoredUserSummary,
+ StoredVideoTask, StoredWalletDailyUsageLedger, StoredWalletDailyUsageLedgerPage,
+ StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput, UpdateAnnouncementRecord,
UpdateWalletRechargeCheckoutInput, UpsertBackgroundTaskEvent, UpsertBackgroundTaskRun,
UpsertUsageRecord, UpsertVideoTask, UsageSettlementInput, UserDailyQuotaAvailabilityRecord,
UserPlanEntitlementRecord, VideoTaskLookupKey, VideoTaskModelCount, VideoTaskQueryFilter,
@@ -1074,6 +1076,73 @@ impl GatewayDataState {
}
}
+ pub(crate) async fn prepare_admin_user_wallet_balance_batch(
+ &self,
+ input: PrepareAdminUserWalletBalanceBatchInput,
+ ) -> Result
+
+
+ {{ legacyT('存在未决的钱包批量调整') }}:{{ pendingWalletOperationLabel }} {{ pendingWalletBatch.request.payload.amount }} USD。{{ legacyT('结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。') }}
+
+
+ {{ legacyT('当前表单与原请求不同;新钱包调整已禁用,请先重试原请求。') }}
+
+
+
+
+ {{ legacyT('无法读取未决的钱包批量请求。为避免重复扣款,钱包余额调整已禁用;请先核对余额操作结果。') }}
+
+
()
const usersStore = useUsersStore()
+const authStore = useAuthStore()
+const walletRetryCoordinator = createUserBatchWalletRetryCoordinator({
+ scope: () => authStore.user?.id ?? null,
+})
const { success, warning, error } = useToast()
const { legacyT, locale } = useI18n()
@@ -178,6 +228,8 @@ const previewItems = ref([])
const resolvedTotal = ref(null)
const executing = ref(false)
const lastResult = ref(null)
+const pendingWalletBatch = ref(null)
+const pendingWalletReadError = ref(false)
const hasAnyTarget = computed(() => props.selectedCount > 0 || selectedGroupIds.value.length > 0)
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
@@ -185,11 +237,23 @@ const balancePayload = computed(() => buildUserBatchBalanceAdjustmentPayload(
balanceOperation.value,
balanceAmount.value,
))
+const walletAdjustmentRequest = computed(() => (
+ balancePayload.value === null
+ ? null
+ : { selection: buildSelection(), action: 'adjust_wallet_balance', payload: balancePayload.value }
+))
+const walletRequestMismatch = computed(() => (
+ pendingWalletBatch.value !== null
+ && selectedAction.value === 'adjust_wallet_balance'
+ && (walletAdjustmentRequest.value === null
+ || !matchesPendingWalletRequest(pendingWalletBatch.value, walletAdjustmentRequest.value))
+))
const canExecute = computed(() => (
hasAnyTarget.value
&& !previewLoading.value
&& !executing.value
- && (selectedAction.value !== 'adjust_wallet_balance' || balancePayload.value !== null)
+ && (selectedAction.value !== 'adjust_wallet_balance'
+ || (balancePayload.value !== null && !pendingWalletReadError.value && !walletRequestMismatch.value))
))
const selectedActionLabel = computed(() => (
USER_BATCH_ACTION_OPTIONS.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
@@ -208,6 +272,11 @@ const targetRoleWarning = computed(() => {
return legacyT('提示:设置为普通用户会移除目标用户的管理员权限。')
})
const executeButtonLabel = computed(() => legacyT(`确认${selectedActionLabel.value}(${impactCount.value})`))
+const pendingWalletOperationLabel = computed(() => (
+ pendingWalletBatch.value?.request.payload.operation === 'deduct'
+ ? legacyT('扣减')
+ : legacyT('增加')
+))
const lastResultLabel = computed(() => {
if (!lastResult.value) return ''
if (lastResult.value.interrupted) {
@@ -230,8 +299,15 @@ watch(
(open) => {
if (!open) return
resetLocalState()
+ refreshPendingWalletBatch()
void resolvePreview()
},
+ { immediate: true },
+)
+
+watch(
+ () => authStore.user?.id,
+ () => refreshPendingWalletBatch(),
)
watch(
@@ -255,6 +331,16 @@ function resetLocalState(): void {
lastResult.value = null
}
+function refreshPendingWalletBatch(): void {
+ try {
+ pendingWalletBatch.value = walletRetryCoordinator.getPending()
+ pendingWalletReadError.value = false
+ } catch {
+ pendingWalletBatch.value = null
+ pendingWalletReadError.value = true
+ }
+}
+
function buildSelection(): UserBatchSelection {
const group_ids = selectedGroupIds.value.length > 0 ? [...selectedGroupIds.value] : undefined
if (props.selectAllFiltered) {
@@ -301,7 +387,8 @@ function buildRolePayload(): UserBatchRolePayload {
async function executeBatchAction(): Promise {
if (!canExecute.value) return
const selection = buildSelection()
- let request: UserBatchActionRequest
+ let request: Exclude | null = null
+ let walletRequest: UserBatchWalletAdjustmentRequest | null = null
if (selectedAction.value === 'update_access_control') {
const payload = buildAccessControlPayload()
if (payload === null) {
@@ -310,11 +397,11 @@ async function executeBatchAction(): Promise {
}
request = { selection, action: 'update_access_control', payload }
} else if (selectedAction.value === 'adjust_wallet_balance') {
- if (balancePayload.value === null) {
+ if (walletAdjustmentRequest.value === null) {
warning(legacyT('请输入大于 0 的有限金额'))
return
}
- request = { selection, action: 'adjust_wallet_balance', payload: balancePayload.value }
+ walletRequest = walletAdjustmentRequest.value
} else if (selectedAction.value === 'update_role') {
request = { selection, action: 'update_role', payload: buildRolePayload() }
} else {
@@ -323,6 +410,15 @@ async function executeBatchAction(): Promise {
executing.value = true
try {
+ if (walletRequest) {
+ const result = await walletRetryCoordinator.execute(
+ walletRequest,
+ (keyedRequest) => usersStore.batchAction(keyedRequest),
+ )
+ handleWalletBatchResult(result)
+ return
+ }
+ if (request === null) return
const result = await usersStore.batchAction(request)
lastResult.value = result
if (result.interrupted) {
@@ -338,9 +434,63 @@ async function executeBatchAction(): Promise {
}
emit('completed', result)
} catch (err) {
- error(legacyT(parseApiError(err, '批量操作失败')), legacyT('批量操作失败'))
+ if (walletRequest) {
+ refreshPendingWalletBatch()
+ if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
+ warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
+ } else if (
+ err instanceof WalletIdempotencyUnavailableError
+ || err instanceof WalletIdempotencyScopeUnavailableError
+ || err instanceof WalletIdempotencyScopeChangedError
+ ) {
+ warning(legacyT('无法确认管理员身份或安全生成钱包批量请求标识,请求未发送。'))
+ } else {
+ warning(legacyT('钱包批量调整结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。'))
+ }
+ } else {
+ error(legacyT(parseApiError(err, '批量操作失败')), legacyT('批量操作失败'))
+ }
} finally {
executing.value = false
}
}
+
+async function retryPendingWalletBatch(): Promise {
+ if (executing.value) return
+ executing.value = true
+ try {
+ const result = await walletRetryCoordinator.retry(
+ (request) => usersStore.batchAction(request),
+ )
+ if (result) handleWalletBatchResult(result)
+ else refreshPendingWalletBatch()
+ } catch (err) {
+ refreshPendingWalletBatch()
+ if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
+ warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
+ } else if (
+ err instanceof WalletIdempotencyScopeUnavailableError
+ || err instanceof WalletIdempotencyScopeChangedError
+ ) {
+ warning(legacyT('无法确认管理员身份,请求未发送。'))
+ } else {
+ warning(legacyT('钱包批量调整结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。'))
+ }
+ } finally {
+ executing.value = false
+ }
+}
+
+function handleWalletBatchResult(result: UserBatchActionResponse): void {
+ lastResult.value = result
+ refreshPendingWalletBatch()
+ if (result.interrupted) {
+ warning(`${lastResultLabel.value};${legacyT('结果可能部分完成,请仅重试原请求,不要开始新的余额调整。')}`)
+ } else {
+ const message = legacyT(`批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
+ if (result.failed > 0) warning(message)
+ else success(message)
+ }
+ emit('completed', result)
+}
diff --git a/frontend/src/features/users/utils/__tests__/userBatchWalletIdempotency.spec.ts b/frontend/src/features/users/utils/__tests__/userBatchWalletIdempotency.spec.ts
new file mode 100644
index 000000000..2452fe98e
--- /dev/null
+++ b/frontend/src/features/users/utils/__tests__/userBatchWalletIdempotency.spec.ts
@@ -0,0 +1,312 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { UserBatchActionResponse, UserBatchBalanceActionRequest } from '@/api/users'
+import {
+ createUserBatchWalletRetryCoordinator,
+ UnresolvedWalletRequestMismatchError,
+ WalletIdempotencyPersistenceUnavailableError,
+ WalletIdempotencyUnavailableError,
+} from '../userBatchWalletIdempotency'
+
+function createStorage() {
+ const values = new Map()
+ return {
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => values.set(key, value),
+ removeItem: (key: string) => values.delete(key),
+ }
+}
+
+const walletRequest = {
+ selection: { user_ids: ['user-1', 'user-2'], group_ids: ['group-1'] },
+ action: 'adjust_wallet_balance' as const,
+ payload: { operation: 'deduct' as const, amount: 17.25 },
+}
+const defaultStorageKey = 'admin.users.batch.wallet-adjustment.pending.v1:default'
+let testFallback: Map
+
+function response(interrupted = false): UserBatchActionResponse {
+ return { total: 2, success: 1, failed: 0, failures: [], interrupted }
+}
+
+describe('user batch wallet idempotency', () => {
+ beforeEach(() => {
+ sessionStorage.clear()
+ localStorage.clear()
+ testFallback = new Map()
+ })
+
+ it('serializes the key with the exact top-level wallet request before sending', async () => {
+ const storage = createStorage()
+ const coordinator = createUserBatchWalletRetryCoordinator({
+ storage,
+ fallback: testFallback,
+ createKey: () => 'wallet-key-1',
+ })
+ let storedDuringSend: string | null = null
+ let sentRequest: UserBatchBalanceActionRequest | undefined
+
+ await coordinator.execute(walletRequest, async (request) => {
+ sentRequest = request
+ storedDuringSend = storage.getItem(defaultStorageKey)
+ return response(true)
+ })
+
+ expect(sentRequest).toEqual({ ...walletRequest, idempotency_key: 'wallet-key-1' })
+ expect(JSON.parse(storedDuringSend ?? 'null')).toEqual({
+ idempotency_key: 'wallet-key-1',
+ request: sentRequest,
+ })
+ })
+
+ it('retains a transport failure and reopens with the exact request for retry', async () => {
+ const storage = createStorage()
+ const first = createUserBatchWalletRetryCoordinator({
+ storage,
+ fallback: testFallback,
+ createKey: () => 'wallet-key-2',
+ })
+ const sendFailure = new Error('connection lost')
+ await expect(first.execute(walletRequest, async () => { throw sendFailure })).rejects.toBe(sendFailure)
+
+ const reopened = createUserBatchWalletRetryCoordinator({
+ storage,
+ fallback: testFallback,
+ createKey: () => 'must-not-be-used',
+ })
+ const pending = reopened.getPending()
+ expect(pending).toEqual({
+ idempotency_key: 'wallet-key-2',
+ request: { ...walletRequest, idempotency_key: 'wallet-key-2' },
+ })
+
+ const send = vi.fn(async () => response())
+ await expect(reopened.retry(send)).resolves.toEqual(response())
+ expect(send).toHaveBeenCalledWith(pending?.request)
+ expect(storage.getItem(defaultStorageKey)).toBeNull()
+ })
+
+ it('keeps unresolved requests in persistent browser storage across coordinators', async () => {
+ const first = createUserBatchWalletRetryCoordinator({
+ createKey: () => 'wallet-key-persistent',
+ scope: () => 'admin-1',
+ })
+ await expect(first.execute(walletRequest, async () => {
+ throw new Error('connection lost')
+ })).rejects.toThrow('connection lost')
+
+ const reopened = createUserBatchWalletRetryCoordinator({ scope: () => 'admin-1' })
+ const pending = reopened.getPending()
+ expect(pending?.request).toEqual({
+ ...walletRequest,
+ idempotency_key: 'wallet-key-persistent',
+ })
+
+ const send = vi.fn(async () => response())
+ await reopened.retry(send)
+ expect(send).toHaveBeenCalledWith(pending?.request)
+ expect(localStorage.getItem(
+ 'admin.users.batch.wallet-adjustment.pending.v1:admin-1',
+ )).toBeNull()
+ })
+
+ it('keeps unresolved requests isolated by authenticated administrator', async () => {
+ const storage = createStorage()
+ const adminA = createUserBatchWalletRetryCoordinator({
+ storage,
+ fallback: testFallback,
+ scope: () => 'admin-a',
+ createKey: () => 'wallet-key-admin-a',
+ })
+ await expect(adminA.execute(walletRequest, async () => { throw new Error('connection lost') }))
+ .rejects.toThrow('connection lost')
+
+ const adminB = createUserBatchWalletRetryCoordinator({
+ storage,
+ fallback: testFallback,
+ scope: () => 'admin-b',
+ createKey: () => 'wallet-key-admin-b',
+ })
+ expect(adminB.getPending()).toBeNull()
+ await adminB.execute(walletRequest, async () => response(true))
+
+ expect(adminA.getPending()?.idempotency_key).toBe('wallet-key-admin-a')
+ expect(adminB.getPending()?.idempotency_key).toBe('wallet-key-admin-b')
+ })
+
+ it('does not send if the authenticated administrator changes before dispatch', async () => {
+ const storage = createStorage()
+ let scopeReads = 0
+ const send = vi.fn(async () => response())
+ const coordinator = createUserBatchWalletRetryCoordinator({
+ storage,
+ fallback: testFallback,
+ scope: () => (++scopeReads === 1 ? 'admin-a' : 'admin-b'),
+ createKey: () => 'wallet-key-scope-change',
+ })
+
+ await expect(coordinator.execute(walletRequest, send)).rejects.toThrow(
+ 'authenticated administrator changed',
+ )
+ expect(send).not.toHaveBeenCalled()
+ expect(storage.getItem('admin.users.batch.wallet-adjustment.pending.v1:admin-a')).not.toBeNull()
+ })
+
+ it('retains interrupted requests and reuses their key until a terminal response', async () => {
+ const storage = createStorage()
+ const first = createUserBatchWalletRetryCoordinator({
+ storage,
+ fallback: testFallback,
+ createKey: () => 'wallet-key-3',
+ })
+ await first.execute(walletRequest, async () => response(true))
+ const reopened = createUserBatchWalletRetryCoordinator({ storage, fallback: testFallback })
+ const pending = reopened.getPending()
+ const send = vi.fn(async () => response(true))
+
+ await reopened.retry(send)
+
+ expect(send).toHaveBeenCalledWith(pending?.request)
+ expect(reopened.getPending()).toEqual(pending)
+ await reopened.retry(async (request) => {
+ expect(request).toEqual(pending?.request)
+ return response()
+ })
+ expect(reopened.getPending()).toBeNull()
+ })
+
+ it('matches the same serialized request when optional filter fields are omitted', async () => {
+ const storage = createStorage()
+ const requestWithUndefinedField = {
+ ...walletRequest,
+ selection: { filters: { search: 'active', is_active: undefined } },
+ }
+ const first = createUserBatchWalletRetryCoordinator({
+ storage,
+ fallback: testFallback,
+ createKey: () => 'wallet-key-filter',
+ })
+ await first.execute(requestWithUndefinedField, async () => response(true))
+
+ const reopened = createUserBatchWalletRetryCoordinator({ storage, fallback: testFallback })
+ const send = vi.fn(async () => response())
+ await reopened.execute({
+ ...walletRequest,
+ selection: { filters: { search: 'active' } },
+ }, send)
+
+ expect(send).toHaveBeenCalledWith({
+ ...walletRequest,
+ selection: { filters: { search: 'active' } },
+ idempotency_key: 'wallet-key-filter',
+ })
+ })
+
+ it('blocks changed payloads while unresolved and gives a later adjustment a new key', async () => {
+ const storage = createStorage()
+ let nextKey = 0
+ const coordinator = createUserBatchWalletRetryCoordinator({
+ storage,
+ fallback: testFallback,
+ createKey: () => `wallet-key-${++nextKey}`,
+ })
+ await expect(coordinator.execute(walletRequest, async () => { throw new Error('connection lost') }))
+ .rejects.toThrow('connection lost')
+ const changedRequest = {
+ ...walletRequest,
+ payload: { operation: 'add' as const, amount: 20 },
+ }
+ const send = vi.fn(async () => response())
+
+ await expect(coordinator.execute(changedRequest, send)).rejects.toBeInstanceOf(
+ UnresolvedWalletRequestMismatchError,
+ )
+ expect(send).not.toHaveBeenCalled()
+ expect(coordinator.getPending()?.idempotency_key).toBe('wallet-key-1')
+
+ await coordinator.retry(async () => response())
+ let newRequest
+ await coordinator.execute(changedRequest, async (request) => {
+ newRequest = request
+ return response()
+ })
+
+ expect(newRequest).toEqual({ ...changedRequest, idempotency_key: 'wallet-key-2' })
+ })
+
+ it('fails closed when persistent storage cannot save the request', async () => {
+ const unavailableStorage = {
+ getItem: () => null,
+ setItem: () => { throw new Error('storage unavailable') },
+ removeItem: () => undefined,
+ }
+ const send = vi.fn(async () => response(true))
+ const coordinator = createUserBatchWalletRetryCoordinator({
+ storage: unavailableStorage,
+ fallback: testFallback,
+ createKey: () => 'wallet-key-fallback',
+ })
+
+ await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
+ WalletIdempotencyPersistenceUnavailableError,
+ )
+ expect(send).not.toHaveBeenCalled()
+ expect(testFallback.size).toBe(0)
+ })
+
+ it('does not send when persistent storage readback does not match', async () => {
+ let wasWritten = false
+ const mismatchedStorage = {
+ getItem: () => wasWritten ? 'different request' : null,
+ setItem: () => { wasWritten = true },
+ removeItem: () => undefined,
+ }
+ const send = vi.fn(async () => response())
+ const coordinator = createUserBatchWalletRetryCoordinator({
+ storage: mismatchedStorage,
+ fallback: testFallback,
+ createKey: () => 'wallet-key-readback',
+ })
+
+ await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
+ WalletIdempotencyPersistenceUnavailableError,
+ )
+ expect(send).not.toHaveBeenCalled()
+ expect(testFallback.size).toBe(0)
+ })
+
+ it('does not create a new request when persistent storage cannot be read', async () => {
+ const unavailableStorage = {
+ getItem: () => { throw new Error('storage unavailable') },
+ setItem: () => undefined,
+ removeItem: () => undefined,
+ }
+ const send = vi.fn(async () => response())
+ const coordinator = createUserBatchWalletRetryCoordinator({
+ storage: unavailableStorage,
+ fallback: testFallback,
+ createKey: () => 'must-not-be-used',
+ })
+
+ await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
+ WalletIdempotencyPersistenceUnavailableError,
+ )
+ expect(send).not.toHaveBeenCalled()
+ expect(testFallback.size).toBe(0)
+ })
+
+ it('fails closed when secure UUID generation is unavailable', async () => {
+ const storage = createStorage()
+ const send = vi.fn(async () => response())
+ const coordinator = createUserBatchWalletRetryCoordinator({
+ storage,
+ fallback: testFallback,
+ createKey: () => { throw new WalletIdempotencyUnavailableError() },
+ })
+
+ await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
+ WalletIdempotencyUnavailableError,
+ )
+ expect(send).not.toHaveBeenCalled()
+ expect(storage.getItem(defaultStorageKey)).toBeNull()
+ })
+})
diff --git a/frontend/src/features/users/utils/userBatchWalletIdempotency.ts b/frontend/src/features/users/utils/userBatchWalletIdempotency.ts
new file mode 100644
index 000000000..d81fc412c
--- /dev/null
+++ b/frontend/src/features/users/utils/userBatchWalletIdempotency.ts
@@ -0,0 +1,252 @@
+import type {
+ UserBatchActionResponse,
+ UserBatchBalanceActionRequest,
+} from '@/api/users'
+
+export type UserBatchWalletAdjustmentRequest = Omit<
+ UserBatchBalanceActionRequest,
+ 'idempotency_key'
+>
+
+export interface PendingUserBatchWalletRequest {
+ idempotency_key: string
+ request: UserBatchBalanceActionRequest
+}
+
+interface StringStorage {
+ getItem(key: string): string | null
+ setItem(key: string, value: string): void
+ removeItem(key: string): void
+}
+
+interface CoordinatorOptions {
+ storage?: StringStorage | null
+ createKey?: () => string
+ fallback?: Map
+ scope?: () => string | null
+}
+
+const STORAGE_KEY = 'admin.users.batch.wallet-adjustment.pending.v1'
+const inMemoryFallback = new Map()
+
+export class UnresolvedWalletRequestMismatchError extends Error {
+ constructor(readonly pending: PendingUserBatchWalletRequest) {
+ super('A different wallet batch request is still unresolved')
+ this.name = 'UnresolvedWalletRequestMismatchError'
+ }
+}
+
+export class WalletIdempotencyUnavailableError extends Error {
+ constructor() {
+ super('crypto.randomUUID is unavailable')
+ this.name = 'WalletIdempotencyUnavailableError'
+ }
+}
+
+export class WalletIdempotencyPersistenceUnavailableError extends Error {
+ constructor() {
+ super('Persistent browser storage is unavailable')
+ this.name = 'WalletIdempotencyPersistenceUnavailableError'
+ }
+}
+
+export class WalletIdempotencyScopeUnavailableError extends Error {
+ constructor() {
+ super('The authenticated administrator identity is unavailable')
+ this.name = 'WalletIdempotencyScopeUnavailableError'
+ }
+}
+
+export class WalletIdempotencyScopeChangedError extends Error {
+ constructor() {
+ super('The authenticated administrator changed before the request was sent')
+ this.name = 'WalletIdempotencyScopeChangedError'
+ }
+}
+
+export class InvalidPendingWalletRequestError extends Error {
+ constructor() {
+ super('The stored wallet batch request is invalid')
+ this.name = 'InvalidPendingWalletRequestError'
+ }
+}
+
+function browserPersistentStorage(): StringStorage | null {
+ try {
+ return globalThis.localStorage ?? null
+ } catch {
+ return null
+ }
+}
+
+function secureRandomUUID(): string {
+ try {
+ const cryptoApi = globalThis.crypto
+ if (typeof cryptoApi?.randomUUID === 'function') {
+ return cryptoApi.randomUUID()
+ }
+ } catch {
+ // Treat unavailable secure randomness as a hard failure.
+ }
+ throw new WalletIdempotencyUnavailableError()
+}
+
+function isPendingRequest(value: unknown): value is PendingUserBatchWalletRequest {
+ if (typeof value !== 'object' || value === null) return false
+ const record = value as Partial
+ const request = record.request
+ return typeof record.idempotency_key === 'string'
+ && record.idempotency_key.length > 0
+ && typeof request === 'object'
+ && request !== null
+ && request.action === 'adjust_wallet_balance'
+ && request.idempotency_key === record.idempotency_key
+ && typeof request.selection === 'object'
+ && request.selection !== null
+ && typeof request.payload === 'object'
+ && request.payload !== null
+ && (request.payload.operation === 'add' || request.payload.operation === 'deduct')
+ && Number.isFinite(request.payload.amount)
+ && request.payload.amount > 0
+}
+
+function parsePendingRequest(serialized: string): PendingUserBatchWalletRequest {
+ try {
+ const value: unknown = JSON.parse(serialized)
+ if (isPendingRequest(value)) return value
+ } catch {
+ // Invalid persisted state must not allow a fresh adjustment to be sent.
+ }
+ throw new InvalidPendingWalletRequestError()
+}
+
+function stableSerialize(value: unknown): string {
+ if (Array.isArray(value)) {
+ return `[${value.map((item) => item === undefined ? 'null' : stableSerialize(item)).join(',')}]`
+ }
+ if (typeof value === 'object' && value !== null) {
+ const fields = Object.entries(value as Record)
+ .filter(([, item]) => item !== undefined)
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
+ return `{${fields.map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`).join(',')}}`
+ }
+ return JSON.stringify(value) ?? 'null'
+}
+
+export function matchesPendingWalletRequest(
+ pending: PendingUserBatchWalletRequest,
+ request: UserBatchWalletAdjustmentRequest,
+): boolean {
+ const { idempotency_key: _key, ...pendingPayload } = pending.request
+ return stableSerialize(pendingPayload) === stableSerialize(request)
+}
+
+export function createUserBatchWalletRetryCoordinator(options: CoordinatorOptions = {}) {
+ const storage = 'storage' in options ? options.storage ?? null : browserPersistentStorage()
+ const fallback = options.fallback ?? inMemoryFallback
+ const createKey = options.createKey ?? secureRandomUUID
+ const getScope = options.scope ?? (() => 'default')
+
+ function getStorageKey(): string {
+ const scope = getScope()
+ if (!scope) throw new WalletIdempotencyScopeUnavailableError()
+ return `${STORAGE_KEY}:${encodeURIComponent(scope)}`
+ }
+
+ function readPending(storageKey: string): PendingUserBatchWalletRequest | null {
+ let serialized: string | null = null
+ let storageReadFailed = false
+ try {
+ serialized = storage?.getItem(storageKey) ?? null
+ } catch {
+ storageReadFailed = true
+ serialized = null
+ }
+ serialized ??= fallback.get(storageKey) ?? null
+ if (serialized === null && (!storage || storageReadFailed)) {
+ throw new WalletIdempotencyPersistenceUnavailableError()
+ }
+ return serialized === null ? null : parsePendingRequest(serialized)
+ }
+
+ function persist(storageKey: string, pending: PendingUserBatchWalletRequest): void {
+ const serialized = JSON.stringify(pending)
+ if (!storage) throw new WalletIdempotencyPersistenceUnavailableError()
+ try {
+ storage.setItem(storageKey, serialized)
+ if (storage.getItem(storageKey) !== serialized) {
+ throw new Error('Stored wallet batch request could not be verified')
+ }
+ } catch {
+ throw new WalletIdempotencyPersistenceUnavailableError()
+ }
+ fallback.set(storageKey, serialized)
+ }
+
+ function clear(storageKey: string): void {
+ fallback.delete(storageKey)
+ try {
+ storage?.removeItem(storageKey)
+ } catch {
+ // A stale persisted request is safe to replay and will fail closed on mismatch.
+ }
+ }
+
+ function getOrCreate(
+ storageKey: string,
+ request: UserBatchWalletAdjustmentRequest,
+ ): UserBatchBalanceActionRequest {
+ const pending = readPending(storageKey)
+ if (pending) {
+ if (!matchesPendingWalletRequest(pending, request)) {
+ throw new UnresolvedWalletRequestMismatchError(pending)
+ }
+ persist(storageKey, pending)
+ return pending.request
+ }
+
+ const idempotencyKey = createKey()
+ if (!idempotencyKey) throw new WalletIdempotencyUnavailableError()
+ const keyedRequest: UserBatchBalanceActionRequest = {
+ ...request,
+ idempotency_key: idempotencyKey,
+ }
+ persist(storageKey, { idempotency_key: idempotencyKey, request: keyedRequest })
+ return keyedRequest
+ }
+
+ async function sendAndResolve(
+ request: UserBatchBalanceActionRequest,
+ send: (request: UserBatchBalanceActionRequest) => Promise,
+ storageKey: string,
+ ): Promise {
+ const response = await send(request)
+ if (!response.interrupted) clear(storageKey)
+ return response
+ }
+
+ return {
+ getPending() {
+ return readPending(getStorageKey())
+ },
+ async execute(
+ request: UserBatchWalletAdjustmentRequest,
+ send: (request: UserBatchBalanceActionRequest) => Promise,
+ ) {
+ const storageKey = getStorageKey()
+ 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)
+ },
+ }
+}