mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 08:27:46 +08:00
Merge pull request #846 from RWDai/review/pr-02-user-bulk-balance
feat(admin): batch adjust user wallet balances
This commit is contained in:
@@ -17,7 +17,7 @@ vi.mock('@/utils/cache', () => ({
|
||||
cachedRequest: cachedRequestMock,
|
||||
}))
|
||||
|
||||
import { usersApi } from '@/api/users'
|
||||
import { buildUserBatchBalanceAdjustmentPayload, usersApi } from '@/api/users'
|
||||
|
||||
describe('usersApi admin list query', () => {
|
||||
beforeEach(() => {
|
||||
@@ -104,3 +104,25 @@ describe('usersApi admin list query', () => {
|
||||
expect(getMock).toHaveBeenCalledWith('/api/admin/users/target-user/api-keys')
|
||||
})
|
||||
})
|
||||
|
||||
describe('user batch wallet balance payload', () => {
|
||||
it('builds an addition payload from a valid positive amount', () => {
|
||||
expect(buildUserBatchBalanceAdjustmentPayload('add', '12.5')).toEqual({
|
||||
operation: 'add',
|
||||
amount: 12.5,
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a deduction payload from a valid positive amount', () => {
|
||||
expect(buildUserBatchBalanceAdjustmentPayload('deduct', 4)).toEqual({
|
||||
operation: 'deduct',
|
||||
amount: 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects zero, blank, and non-finite amounts', () => {
|
||||
expect(buildUserBatchBalanceAdjustmentPayload('add', '0')).toBeNull()
|
||||
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '')).toBeNull()
|
||||
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '1e999')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -120,9 +120,28 @@ export interface UserBatchRolePayload {
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role'
|
||||
export type UserBatchBalanceOperation = 'add' | 'deduct'
|
||||
|
||||
export type UserBatchActionPayload = UserBatchAccessControlPayload | UserBatchRolePayload
|
||||
export interface UserBatchBalanceAdjustmentPayload {
|
||||
operation: UserBatchBalanceOperation
|
||||
amount: number
|
||||
}
|
||||
|
||||
export function buildUserBatchBalanceAdjustmentPayload(
|
||||
operation: UserBatchBalanceOperation,
|
||||
amountInput: string | number,
|
||||
): UserBatchBalanceAdjustmentPayload | null {
|
||||
const amount = typeof amountInput === 'number' ? amountInput : Number(amountInput.trim())
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null
|
||||
return { operation, amount }
|
||||
}
|
||||
|
||||
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role' | 'adjust_wallet_balance'
|
||||
|
||||
export type UserBatchActionPayload =
|
||||
| UserBatchAccessControlPayload
|
||||
| UserBatchRolePayload
|
||||
| UserBatchBalanceAdjustmentPayload
|
||||
|
||||
export interface UserBatchToggleActionRequest {
|
||||
selection: UserBatchSelection
|
||||
@@ -142,10 +161,18 @@ export interface UserBatchRoleActionRequest {
|
||||
payload: UserBatchRolePayload
|
||||
}
|
||||
|
||||
export interface UserBatchBalanceActionRequest {
|
||||
selection: UserBatchSelection
|
||||
action: 'adjust_wallet_balance'
|
||||
payload: UserBatchBalanceAdjustmentPayload
|
||||
idempotency_key: string
|
||||
}
|
||||
|
||||
export type UserBatchActionRequest =
|
||||
| UserBatchToggleActionRequest
|
||||
| UserBatchAccessControlActionRequest
|
||||
| UserBatchRoleActionRequest
|
||||
| UserBatchBalanceActionRequest
|
||||
|
||||
export interface UserBatchActionFailure {
|
||||
user_id: string
|
||||
@@ -157,6 +184,10 @@ export interface UserBatchActionResponse {
|
||||
success: number
|
||||
failed: number
|
||||
failures: UserBatchActionFailure[]
|
||||
interrupted?: boolean
|
||||
completed_user_ids?: string[]
|
||||
uncertain_user_ids?: string[]
|
||||
unprocessed_user_ids?: string[]
|
||||
warnings?: UserBatchSelectionWarning[]
|
||||
action?: string
|
||||
modified_fields?: string[]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Dialog
|
||||
:model-value="open"
|
||||
:title="legacyT('用户批量操作')"
|
||||
:description="legacyT('按当前选择批量调整用户状态、角色和额度')"
|
||||
:description="legacyT('按当前选择批量调整用户状态、角色、额度和钱包余额')"
|
||||
size="2xl"
|
||||
persistent
|
||||
@update:model-value="handleDialogUpdate"
|
||||
@@ -39,6 +39,88 @@
|
||||
v-model="quotaMode"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="selectedAction === 'adjust_wallet_balance'"
|
||||
class="space-y-3 rounded-xl border border-border bg-background p-4"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<Label for="user-batch-wallet-amount" class="text-sm font-medium">
|
||||
{{ legacyT('调整金额 (USD)') }}
|
||||
</Label>
|
||||
<div class="inline-flex rounded-md border border-border p-0.5" role="group" :aria-label="legacyT('余额调整方式')">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
:variant="balanceOperation === 'add' ? 'default' : 'ghost'"
|
||||
:aria-pressed="balanceOperation === 'add'"
|
||||
@click="balanceOperation = 'add'"
|
||||
>
|
||||
<Plus class="mr-1.5 h-4 w-4" />
|
||||
{{ legacyT('增加') }}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
:variant="balanceOperation === 'deduct' ? 'default' : 'ghost'"
|
||||
:aria-pressed="balanceOperation === 'deduct'"
|
||||
@click="balanceOperation = 'deduct'"
|
||||
>
|
||||
<Minus class="mr-1.5 h-4 w-4" />
|
||||
{{ legacyT('扣减') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
id="user-batch-wallet-amount"
|
||||
:model-value="balanceAmount"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputmode="decimal"
|
||||
:aria-invalid="balanceAmount !== '' && balancePayload === null"
|
||||
@update:model-value="balanceAmount = String($event)"
|
||||
/>
|
||||
<p v-if="balanceAmount !== '' && balancePayload === null" class="text-xs text-destructive">
|
||||
{{ legacyT('请输入大于 0 的有限金额') }}
|
||||
</p>
|
||||
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||
{{ legacyT('扣减超过单个用户可用余额时,该用户余额将归零。') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="pendingWalletBatch"
|
||||
role="alert"
|
||||
class="space-y-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100"
|
||||
>
|
||||
<p>
|
||||
{{ legacyT('存在未决的钱包批量调整') }}:{{ pendingWalletOperationLabel }} {{ pendingWalletBatch.request.payload.amount }} USD。{{ legacyT('结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="walletRequestMismatch"
|
||||
class="text-xs"
|
||||
>
|
||||
{{ legacyT('当前表单与原请求不同;新钱包调整已禁用,请先重试原请求。') }}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="executing"
|
||||
@click="retryPendingWalletBatch"
|
||||
>
|
||||
<RotateCcw class="mr-1.5 h-4 w-4" />
|
||||
{{ legacyT('重试原钱包批次') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p
|
||||
v-else-if="pendingWalletReadError"
|
||||
role="alert"
|
||||
class="rounded-md border border-amber-300 bg-amber-50 px-3 py-2.5 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100"
|
||||
>
|
||||
{{ legacyT('无法读取未决的钱包批量请求。为避免重复扣款,钱包余额调整已禁用;请先核对余额操作结果。') }}
|
||||
</p>
|
||||
|
||||
<UserBatchResultSummary
|
||||
:result="lastResult"
|
||||
:label="lastResultLabel"
|
||||
@@ -69,8 +151,12 @@ import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
} from '@/components/ui'
|
||||
import { Minus, Plus, RotateCcw } from 'lucide-vue-next'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { useI18n } from '@/i18n'
|
||||
@@ -82,11 +168,27 @@ import UserBatchRolePanel from './UserBatchRolePanel.vue'
|
||||
import UserBatchTargetSummary from './UserBatchTargetSummary.vue'
|
||||
import { USER_BATCH_ACTION_OPTIONS } from './user-management-config'
|
||||
import type { UserBatchQuotaMode } from './user-management-types'
|
||||
import { buildUserBatchBalanceAdjustmentPayload } from '@/api/users'
|
||||
import {
|
||||
createUserBatchWalletRetryCoordinator,
|
||||
matchesPendingWalletRequest,
|
||||
WalletIdempotencyCoordinationUnavailableError,
|
||||
WalletIdempotencyPersistenceUnavailableError,
|
||||
WalletIdempotencyRequestInProgressError,
|
||||
WalletIdempotencyScopeChangedError,
|
||||
WalletIdempotencyScopeUnavailableError,
|
||||
WalletIdempotencyUnavailableError,
|
||||
} from '../utils/userBatchWalletIdempotency'
|
||||
import type {
|
||||
PendingUserBatchWalletRequest,
|
||||
UserBatchWalletAdjustmentRequest,
|
||||
} from '../utils/userBatchWalletIdempotency'
|
||||
import type {
|
||||
UserBatchAccessControlPayload,
|
||||
UserBatchAction,
|
||||
UserBatchActionRequest,
|
||||
UserBatchActionResponse,
|
||||
UserBatchBalanceOperation,
|
||||
UserBatchRolePayload,
|
||||
UserBatchSelection,
|
||||
UserBatchSelectionFilters,
|
||||
@@ -110,22 +212,51 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const usersStore = useUsersStore()
|
||||
const authStore = useAuthStore()
|
||||
const walletRetryCoordinator = createUserBatchWalletRetryCoordinator({
|
||||
scope: () => authStore.user?.id ?? null,
|
||||
})
|
||||
const { success, warning, error } = useToast()
|
||||
const { legacyT, locale } = useI18n()
|
||||
|
||||
const selectedAction = ref<UserBatchAction>('enable')
|
||||
const targetRole = ref<UserRole>('user')
|
||||
const quotaMode = ref<UserBatchQuotaMode>('skip')
|
||||
const balanceOperation = ref<UserBatchBalanceOperation>('add')
|
||||
const balanceAmount = ref('')
|
||||
const selectedGroupIds = ref<string[]>([])
|
||||
const previewLoading = ref(false)
|
||||
const previewItems = ref<UserBatchSelectionItem[]>([])
|
||||
const resolvedTotal = ref<number | null>(null)
|
||||
const executing = ref(false)
|
||||
const lastResult = ref<UserBatchActionResponse | null>(null)
|
||||
const pendingWalletBatch = ref<PendingUserBatchWalletRequest | null>(null)
|
||||
const pendingWalletReadError = ref(false)
|
||||
|
||||
const hasAnyTarget = computed(() => props.selectedCount > 0 || selectedGroupIds.value.length > 0)
|
||||
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
|
||||
const canExecute = computed(() => hasAnyTarget.value && !previewLoading.value && !executing.value)
|
||||
const balancePayload = computed(() => buildUserBatchBalanceAdjustmentPayload(
|
||||
balanceOperation.value,
|
||||
balanceAmount.value,
|
||||
))
|
||||
const walletAdjustmentRequest = computed<UserBatchWalletAdjustmentRequest | null>(() => (
|
||||
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 && !pendingWalletReadError.value && !walletRequestMismatch.value))
|
||||
))
|
||||
const selectedActionLabel = computed(() => (
|
||||
USER_BATCH_ACTION_OPTIONS.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
|
||||
))
|
||||
@@ -143,13 +274,25 @@ 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) {
|
||||
return legacyT(
|
||||
`批量操作中断:成功 ${lastResult.value.success} 个,结果待确认 ${lastResult.value.uncertain_user_ids?.length ?? 0} 个,尚未执行 ${lastResult.value.unprocessed_user_ids?.length ?? 0} 个`,
|
||||
)
|
||||
}
|
||||
return legacyT(`成功 ${lastResult.value.success} 个,失败 ${lastResult.value.failed} 个`)
|
||||
})
|
||||
const lastResultFailuresLabel = computed(() => {
|
||||
if (!lastResult.value || lastResult.value.failures.length === 0) return ''
|
||||
const failures = lastResult.value.failures.slice(0, 3).map((item) => `${item.user_id} ${item.reason}`).join(locale.value === 'en-US' ? '; ' : ';')
|
||||
const failures = lastResult.value.failures.slice(0, 3)
|
||||
.map((item) => `${item.user_id} ${legacyT(item.reason)}`)
|
||||
.join(locale.value === 'en-US' ? '; ' : ';')
|
||||
return locale.value === 'en-US' ? `: ${failures}` : `:${failures}`
|
||||
})
|
||||
|
||||
@@ -158,8 +301,15 @@ watch(
|
||||
(open) => {
|
||||
if (!open) return
|
||||
resetLocalState()
|
||||
refreshPendingWalletBatch()
|
||||
void resolvePreview()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => authStore.user?.id,
|
||||
() => refreshPendingWalletBatch(),
|
||||
)
|
||||
|
||||
watch(
|
||||
@@ -177,10 +327,22 @@ function resetLocalState(): void {
|
||||
selectedAction.value = 'enable'
|
||||
targetRole.value = 'user'
|
||||
quotaMode.value = 'skip'
|
||||
balanceOperation.value = 'add'
|
||||
balanceAmount.value = ''
|
||||
selectedGroupIds.value = []
|
||||
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) {
|
||||
@@ -227,7 +389,8 @@ function buildRolePayload(): UserBatchRolePayload {
|
||||
async function executeBatchAction(): Promise<void> {
|
||||
if (!canExecute.value) return
|
||||
const selection = buildSelection()
|
||||
let request: UserBatchActionRequest
|
||||
let request: Exclude<UserBatchActionRequest, { action: 'adjust_wallet_balance' }> | null = null
|
||||
let walletRequest: UserBatchWalletAdjustmentRequest | null = null
|
||||
if (selectedAction.value === 'update_access_control') {
|
||||
const payload = buildAccessControlPayload()
|
||||
if (payload === null) {
|
||||
@@ -235,6 +398,12 @@ async function executeBatchAction(): Promise<void> {
|
||||
return
|
||||
}
|
||||
request = { selection, action: 'update_access_control', payload }
|
||||
} else if (selectedAction.value === 'adjust_wallet_balance') {
|
||||
if (walletAdjustmentRequest.value === null) {
|
||||
warning(legacyT('请输入大于 0 的有限金额'))
|
||||
return
|
||||
}
|
||||
walletRequest = walletAdjustmentRequest.value
|
||||
} else if (selectedAction.value === 'update_role') {
|
||||
request = { selection, action: 'update_role', payload: buildRolePayload() }
|
||||
} else {
|
||||
@@ -243,8 +412,22 @@ async function executeBatchAction(): Promise<void> {
|
||||
|
||||
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) {
|
||||
warning(`${lastResultLabel.value};${legacyT('请核对余额后再重试,勿直接重试整批')}`)
|
||||
emit('completed', result)
|
||||
return
|
||||
}
|
||||
const message = legacyT(`批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
|
||||
if (result.failed > 0) {
|
||||
warning(message)
|
||||
@@ -253,9 +436,71 @@ async function executeBatchAction(): Promise<void> {
|
||||
}
|
||||
emit('completed', result)
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '批量操作失败'), legacyT('批量操作失败'))
|
||||
if (walletRequest) {
|
||||
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
|
||||
|| err instanceof WalletIdempotencyScopeChangedError
|
||||
) {
|
||||
warning(legacyT('无法确认管理员身份或安全生成钱包批量请求标识,请求未发送。'))
|
||||
} else {
|
||||
warning(legacyT('钱包批量调整结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。'))
|
||||
}
|
||||
} else {
|
||||
error(legacyT(parseApiError(err, '批量操作失败')), legacyT('批量操作失败'))
|
||||
}
|
||||
} finally {
|
||||
executing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function retryPendingWalletBatch(): Promise<void> {
|
||||
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 WalletIdempotencyRequestInProgressError) {
|
||||
warning(legacyT('另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。'))
|
||||
} else if (err instanceof WalletIdempotencyCoordinationUnavailableError) {
|
||||
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)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -7,11 +7,26 @@
|
||||
<span v-if="failuresLabel">
|
||||
{{ failuresLabel }}
|
||||
</span>
|
||||
<details v-if="result.interrupted" class="mt-2 border-t border-border/60 pt-2">
|
||||
<summary class="cursor-pointer select-none">{{ legacyT('查看批次中断详情') }}</summary>
|
||||
<div class="mt-2 max-h-32 space-y-1 overflow-auto break-all">
|
||||
<p>{{ legacyT('已完成用户 ID') }}:{{ userIds(result.completed_user_ids) }}</p>
|
||||
<p>{{ legacyT('结果待核对用户 ID') }}:{{ userIds(result.uncertain_user_ids) }}</p>
|
||||
<p>{{ legacyT('尚未执行用户 ID') }}:{{ userIds(result.unprocessed_user_ids) }}</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserBatchActionResponse } from '@/api/users'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const { legacyT } = useI18n()
|
||||
|
||||
function userIds(ids?: string[]): string {
|
||||
return ids && ids.length > 0 ? ids.join(', ') : '-'
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
result: UserBatchActionResponse | null
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CheckCircle2,
|
||||
ShieldCheck,
|
||||
UserCog,
|
||||
Wallet,
|
||||
} from 'lucide-vue-next'
|
||||
import type { Component } from 'vue'
|
||||
import type { UserBatchAction, UserRole } from '@/api/users'
|
||||
@@ -64,6 +65,12 @@ export const USER_BATCH_ACTION_OPTIONS: UserBatchActionOption[] = [
|
||||
description: '批量设为普通用户或管理员',
|
||||
icon: UserCog,
|
||||
},
|
||||
{
|
||||
value: 'adjust_wallet_balance',
|
||||
label: '调整余额',
|
||||
description: '批量增加或扣减钱包余额',
|
||||
icon: Wallet,
|
||||
},
|
||||
]
|
||||
|
||||
export function formatUserRoleLabel(role: UserRole | string): string {
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { UserBatchActionResponse, UserBatchBalanceActionRequest } from '@/api/users'
|
||||
import {
|
||||
createUserBatchWalletRetryCoordinator,
|
||||
WalletIdempotencyCoordinationUnavailableError,
|
||||
WalletIdempotencyRequestInProgressError,
|
||||
UnresolvedWalletRequestMismatchError,
|
||||
WalletIdempotencyPersistenceUnavailableError,
|
||||
WalletIdempotencyUnavailableError,
|
||||
} from '../userBatchWalletIdempotency'
|
||||
|
||||
function createStorage() {
|
||||
const values = new Map<string, string>()
|
||||
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<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 }
|
||||
}
|
||||
|
||||
describe('user batch wallet idempotency', () => {
|
||||
beforeEach(() => {
|
||||
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 = createCoordinator({
|
||||
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('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 = createCoordinator({
|
||||
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 = createCoordinator({
|
||||
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 = createCoordinator({
|
||||
createKey: () => 'wallet-key-persistent',
|
||||
scope: () => 'admin-1',
|
||||
})
|
||||
await expect(first.execute(walletRequest, async () => {
|
||||
throw new Error('connection lost')
|
||||
})).rejects.toThrow('connection lost')
|
||||
|
||||
const reopened = createCoordinator({ 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 = createCoordinator({
|
||||
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 = createCoordinator({
|
||||
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 = createCoordinator({
|
||||
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 = createCoordinator({
|
||||
storage,
|
||||
fallback: testFallback,
|
||||
createKey: () => 'wallet-key-3',
|
||||
})
|
||||
await first.execute(walletRequest, async () => response(true))
|
||||
const reopened = createCoordinator({ 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 = createCoordinator({
|
||||
storage,
|
||||
fallback: testFallback,
|
||||
createKey: () => 'wallet-key-filter',
|
||||
})
|
||||
await first.execute(requestWithUndefinedField, async () => response(true))
|
||||
|
||||
const reopened = createCoordinator({ 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 = createCoordinator({
|
||||
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 = createCoordinator({
|
||||
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 = createCoordinator({
|
||||
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 = createCoordinator({
|
||||
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 = createCoordinator({
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
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<string, string>
|
||||
lockManager?: Pick<LockManager, 'request'> | null
|
||||
scope?: () => string | null
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'admin.users.batch.wallet-adjustment.pending.v1'
|
||||
const inMemoryFallback = new Map<string, string>()
|
||||
|
||||
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 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')
|
||||
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 browserLockManager(): Pick<LockManager, 'request'> | null {
|
||||
try {
|
||||
return globalThis.navigator?.locks ?? 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<PendingUserBatchWalletRequest>
|
||||
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<string, unknown>)
|
||||
.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 lockManager = 'lockManager' in options
|
||||
? options.lockManager ?? null
|
||||
: browserLockManager()
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
): 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<UserBatchActionResponse>,
|
||||
storageKey: string,
|
||||
): Promise<UserBatchActionResponse> {
|
||||
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<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',
|
||||
|
||||
@@ -1453,6 +1453,27 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
||||
'暂无选项': 'No options',
|
||||
'用户批量操作': 'User batch actions',
|
||||
'按当前选择批量调整用户状态、角色和额度': 'Batch update user status, role, and quota for the current selection',
|
||||
'按当前选择批量调整用户状态、角色、额度和钱包余额': 'Batch update user status, role, quota, and wallet balances for the current selection',
|
||||
'调整余额': 'Adjust balance',
|
||||
'批量增加或扣减钱包余额': 'Add or deduct wallet balances in bulk',
|
||||
'调整金额 (USD)': 'Adjustment amount (USD)',
|
||||
'余额调整方式': 'Balance adjustment mode',
|
||||
'增加': 'Add',
|
||||
'扣减': 'Deduct',
|
||||
'请输入大于 0 的有限金额': 'Enter a finite amount greater than 0',
|
||||
'扣减超过单个用户可用余额时,该用户余额将归零。': 'Deductions above an individual user’s available balance will be clamped to zero.',
|
||||
'用户钱包不可用': 'User wallet is unavailable',
|
||||
'当前为只读模式,无法批量调整用户钱包余额': 'Cannot adjust user wallet balances in read-only mode',
|
||||
'余额调整结果未确认,批次已中止,请核对钱包后再重试': 'Balance adjustment result is uncertain; the batch stopped. Verify the wallet before retrying.',
|
||||
'用户钱包更新结果未确认,批次已中止,请核对钱包后再重试': 'User wallet update result is uncertain; the batch stopped. Verify the wallet before retrying.',
|
||||
'用户更新结果未确认,批次已中止,请核对后再重试': 'User update result is uncertain; the batch stopped. Verify the result before retrying.',
|
||||
'读取用户状态失败,批次已中止,该用户未执行': 'Could not read user state; the batch stopped before processing this user.',
|
||||
'因前序错误未执行': 'Not processed because an earlier operation failed',
|
||||
'请核对余额后再重试,勿直接重试整批': 'Verify balances before retrying; do not retry the entire batch blindly.',
|
||||
'查看批次中断详情': 'View interrupted batch details',
|
||||
'已完成用户 ID': 'Completed user IDs',
|
||||
'结果待核对用户 ID': 'User IDs with uncertain results',
|
||||
'尚未执行用户 ID': 'Unprocessed user IDs',
|
||||
'影响用户:': 'Affected users:',
|
||||
'目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。': 'Targets all users matching the current filters; the backend will resolve the selection again before execution.',
|
||||
'目标为当前已勾选的用户,重复 ID 会自动去重。': 'Targets the currently selected users; duplicate IDs are deduplicated automatically.',
|
||||
@@ -3052,6 +3073,7 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]
|
||||
[/^确认(启用|禁用|删除|重置)((.+))$/u, match => `Confirm ${translateLegacyText(match[1], 'en-US').toLowerCase()} (${match[2]})`],
|
||||
[/^成功 (.+) 个,失败 (.+) 个$/u, match => `Succeeded ${match[1]}, failed ${match[2]}`],
|
||||
[/^批量操作完成:成功 (.+) 个,失败 (.+) 个$/u, match => `Batch action complete: succeeded ${match[1]}, failed ${match[2]}`],
|
||||
[/^批量操作中断:成功 (.+) 个,结果待确认 (.+) 个,尚未执行 (.+) 个$/u, match => `Batch interrupted: succeeded ${match[1]}, uncertain ${match[2]}, not processed ${match[3]}`],
|
||||
[/^匹配 (.+) 个,当前页 (.+) 个,已选 (.+) 个$/u, match => `Matched ${match[1]} items, current page ${match[2]} items, selected ${match[3]} items`],
|
||||
[/^已选 (.+) 个$/u, match => `Selected ${match[1]} items`],
|
||||
[/^已强制下线 (.+) 个设备$/u, match => `Signed out ${match[1]} devices`],
|
||||
|
||||
Reference in New Issue
Block a user