fix(admin): make bulk wallet batches idempotent

This commit is contained in:
RWDai
2026-09-23 19:43:18 +08:00
parent 75471ae4a4
commit bcd121d447
19 changed files with 2396 additions and 95 deletions
+7 -5
View File
@@ -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,
+91 -22
View File
@@ -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<Option<PrepareAdminUserWalletBalanceBatchOutcome>, DataLayerError> {
match &self.wallet_writer {
Some(repository) => repository
.prepare_admin_user_wallet_balance_batch(input)
.await
.map(Some),
None => Ok(None),
}
}
pub(crate) async fn get_admin_user_wallet_balance_batch(
&self,
admin_user_id: &str,
idempotency_key: &str,
request_fingerprint: &str,
) -> Result<Option<PrepareAdminUserWalletBalanceBatchOutcome>, DataLayerError> {
match &self.wallet_writer {
Some(repository) => {
repository
.get_admin_user_wallet_balance_batch(
admin_user_id,
idempotency_key,
request_fingerprint,
)
.await
}
None => Ok(None),
}
}
pub(crate) async fn adjust_admin_user_wallet_balance_batch_user(
&self,
input: AdjustWalletBalanceInBatchInput,
) -> Result<Option<AdminUserWalletBalanceBatchUserOutcome>, DataLayerError> {
match &self.wallet_writer {
Some(repository) => repository
.adjust_admin_user_wallet_balance_batch_user(input)
.await
.map(Some),
None => Ok(None),
}
}
pub(crate) async fn record_admin_user_wallet_balance_batch_failure(
&self,
admin_user_id: &str,
idempotency_key: &str,
user_id: &str,
reason: &str,
) -> Result<Option<AdminUserWalletBalanceBatchUserOutcome>, DataLayerError> {
match &self.wallet_writer {
Some(repository) => repository
.record_admin_user_wallet_balance_batch_failure(
admin_user_id,
idempotency_key,
user_id,
reason,
)
.await
.map(Some),
None => Ok(None),
}
}
pub(crate) async fn create_manual_wallet_recharge(
&self,
input: CreateManualWalletRechargeInput,
@@ -17,6 +17,64 @@ impl<'a> AdminAppState<'a> {
pub(crate) fn cloned_app(&self) -> AppState {
self.app.clone()
}
pub(crate) async fn get_admin_user_wallet_balance_batch(
&self,
admin_user_id: &str,
idempotency_key: &str,
request_fingerprint: &str,
) -> Result<
Option<aether_data::repository::wallet::PrepareAdminUserWalletBalanceBatchOutcome>,
GatewayError,
> {
self.app
.get_admin_user_wallet_balance_batch(
admin_user_id,
idempotency_key,
request_fingerprint,
)
.await
}
pub(crate) async fn prepare_admin_user_wallet_balance_batch(
&self,
input: aether_data::repository::wallet::PrepareAdminUserWalletBalanceBatchInput,
) -> Result<
aether_data::repository::wallet::PrepareAdminUserWalletBalanceBatchOutcome,
GatewayError,
> {
self.app
.prepare_admin_user_wallet_balance_batch(input)
.await
}
pub(crate) async fn record_admin_user_wallet_balance_batch_failure(
&self,
admin_user_id: &str,
idempotency_key: &str,
user_id: &str,
reason: &str,
) -> Result<aether_data::repository::wallet::AdminUserWalletBalanceBatchUserOutcome, GatewayError>
{
self.app
.record_admin_user_wallet_balance_batch_failure(
admin_user_id,
idempotency_key,
user_id,
reason,
)
.await
}
pub(crate) async fn adjust_admin_user_wallet_balance_batch_user(
&self,
input: aether_data::repository::wallet::AdjustWalletBalanceInBatchInput,
) -> Result<aether_data::repository::wallet::AdminUserWalletBalanceBatchUserOutcome, GatewayError>
{
self.app
.adjust_admin_user_wallet_balance_batch_user(input)
.await
}
}
impl<'a> AsRef<AppState> for AdminAppState<'a> {
@@ -8,6 +8,9 @@ use super::{
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::attach_admin_audit_response;
use crate::GatewayError;
use aether_data::repository::wallet::{
AdminUserWalletBalanceBatchUserOutcome, PrepareAdminUserWalletBalanceBatchOutcome,
};
use axum::{
body::{Body, Bytes},
http,
@@ -15,9 +18,10 @@ use axum::{
Json,
};
use serde_json::{json, Value};
use sha2::{Digest as _, Sha256};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Default, serde::Deserialize)]
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
struct AdminUserSelectionFilters {
#[serde(default)]
search: Option<String>,
@@ -29,7 +33,7 @@ struct AdminUserSelectionFilters {
group_id: Option<String>,
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Clone, Default, serde::Serialize)]
struct AdminUserSelectionRequest {
user_ids: Vec<String>,
group_ids: Vec<String>,
@@ -42,6 +46,7 @@ struct AdminUserBatchActionRequest {
selection: AdminUserSelectionRequest,
action: String,
payload: Option<Value>,
idempotency_key: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
@@ -50,6 +55,8 @@ struct RawAdminUserBatchActionRequest {
action: String,
#[serde(default)]
payload: Option<Value>,
#[serde(default)]
idempotency_key: Option<String>,
}
#[derive(Debug, Clone, Default)]
@@ -70,7 +77,7 @@ struct AdminUserSelectionItem {
matched_by: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
struct AdminUserSelectionWarning {
#[serde(rename = "type")]
warning_type: String,
@@ -117,6 +124,11 @@ enum AdminBatchWalletBalanceAdjustmentError {
BalanceAdjustment,
}
enum AdminBatchWalletLimitModeError {
WalletLookup,
Mutation,
}
pub(in super::super) async fn build_admin_resolve_user_selection_response(
state: &AdminAppState<'_>,
_request_context: &AdminRequestContext<'_>,
@@ -148,10 +160,29 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
};
let mutation = match parse_batch_mutation(&request.action, request.payload) {
let mutation = match parse_batch_mutation(&request.action, request.payload.clone()) {
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
};
if mutation.wallet_balance_adjustment.is_some() {
if !management_token_may_adjust_admin_wallet_balance(request_context) {
return Ok(build_admin_users_wallet_permission_denied_response(
request_context,
));
}
if !state.has_auth_wallet_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法批量调整用户钱包余额",
));
}
return build_admin_user_wallet_balance_batch_response(
state,
request_context,
request,
mutation,
)
.await;
}
let resolved = match resolve_admin_user_selection(state, request.selection).await {
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
@@ -180,18 +211,6 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
"当前为只读模式,无法批量更新用户钱包",
));
}
if mutation.wallet_balance_adjustment.is_some()
&& !management_token_may_adjust_admin_wallet_balance(request_context)
{
return Ok(build_admin_users_wallet_permission_denied_response(
request_context,
));
}
if mutation.wallet_balance_adjustment.is_some() && !state.has_auth_wallet_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法批量调整用户钱包余额",
));
}
let active_admin_demotions = count_active_admin_demotions(&mutation, &resolved.items);
let active_admin_count = if active_admin_demotions > 0 {
state.count_active_admin_users().await?
@@ -263,7 +282,20 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
}));
continue;
}
Err(_) => {
Err(AdminBatchWalletLimitModeError::WalletLookup) => {
record_batch_action_interruption(
&resolved.items,
item_index,
false,
"读取用户钱包失败,批次已中止,该用户未执行",
&mut failures,
&mut uncertain_user_ids,
&mut unprocessed_user_ids,
);
interrupted = true;
break;
}
Err(AdminBatchWalletLimitModeError::Mutation) => {
record_batch_action_interruption(
&resolved.items,
item_index,
@@ -427,6 +459,379 @@ fn record_batch_action_interruption(
}
}
async fn build_admin_user_wallet_balance_batch_response(
state: &AdminAppState<'_>,
request_context: &AdminRequestContext<'_>,
request: AdminUserBatchActionRequest,
mutation: AdminUserBatchMutation,
) -> Result<Response<Body>, GatewayError> {
let Some(idempotency_key) = request
.idempotency_key
.as_deref()
.map(str::trim)
.filter(|value| {
!value.is_empty()
&& value.len() <= 128
&& value.bytes().all(|byte| (0x21..=0x7e).contains(&byte))
})
else {
return Ok(build_admin_user_batch_bad_request_response(
"余额批量操作必须提供有效的 idempotency_key".to_string(),
));
};
let Some(admin_user_id) = request_context
.decision()
.and_then(|decision| decision.admin_principal.as_ref())
.map(|principal| principal.user_id.clone())
else {
return Ok(build_admin_users_permission_denied_response(
request_context,
));
};
let action = request.action.trim().to_ascii_lowercase();
let fingerprint_payload = json!({
"selection": &request.selection,
"action": &action,
"payload": &request.payload,
});
let encoded = serde_json::to_vec(&fingerprint_payload)
.map_err(|error| GatewayError::Internal(error.to_string()))?;
let request_fingerprint = format!("{:x}", Sha256::digest(encoded));
let existing = state
.get_admin_user_wallet_balance_batch(&admin_user_id, idempotency_key, &request_fingerprint)
.await?;
let batch = match existing {
Some(PrepareAdminUserWalletBalanceBatchOutcome::Conflict) => {
return Ok(build_admin_user_batch_idempotency_conflict_response());
}
Some(PrepareAdminUserWalletBalanceBatchOutcome::Ready(batch)) => batch,
None => {
let resolved =
match resolve_admin_user_selection(state, request.selection.clone()).await {
Ok(value) => value,
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
};
let warnings = serde_json::to_value(&resolved.warnings)
.ok()
.and_then(|value| value.as_array().cloned())
.unwrap_or_default();
let prepared = state
.prepare_admin_user_wallet_balance_batch(
aether_data::repository::wallet::PrepareAdminUserWalletBalanceBatchInput {
admin_user_id: admin_user_id.clone(),
idempotency_key: idempotency_key.to_string(),
request_fingerprint: request_fingerprint.clone(),
target_user_ids: resolved
.items
.iter()
.map(|item| item.user_id.clone())
.collect(),
missing_user_ids: resolved.missing_user_ids,
warnings,
},
)
.await?;
match prepared {
PrepareAdminUserWalletBalanceBatchOutcome::Conflict => {
return Ok(build_admin_user_batch_idempotency_conflict_response());
}
PrepareAdminUserWalletBalanceBatchOutcome::Ready(batch) => batch,
}
}
};
let warnings: Vec<AdminUserSelectionWarning> =
serde_json::from_value(Value::Array(batch.warnings.clone()))
.map_err(|error| GatewayError::Internal(error.to_string()))?;
let resolved = ResolvedAdminUserSelection {
items: batch
.target_user_ids
.iter()
.map(|user_id| AdminUserSelectionItem {
user_id: user_id.clone(),
username: String::new(),
email: None,
role: "user".to_string(),
is_active: true,
matched_by: Vec::new(),
})
.collect(),
missing_user_ids: batch.missing_user_ids.clone(),
warnings,
};
let adjustment = mutation
.wallet_balance_adjustment
.expect("wallet balance action should have an adjustment");
let signed_amount = match adjustment.operation {
AdminUserWalletBalanceOperation::Add => adjustment.amount,
AdminUserWalletBalanceOperation::Deduct => -adjustment.amount,
};
let mut outcomes = batch.user_outcomes;
let mut completed_user_ids = outcomes
.iter()
.filter_map(|(user_id, outcome)| {
matches!(outcome, AdminUserWalletBalanceBatchUserOutcome::Succeeded)
.then_some(user_id.clone())
})
.collect::<Vec<_>>();
let mut success = completed_user_ids.len();
let mut failures = resolved
.missing_user_ids
.iter()
.map(|user_id| json!({ "user_id": user_id, "reason": "用户不存在或已删除" }))
.collect::<Vec<_>>();
for (user_id, outcome) in &outcomes {
if let AdminUserWalletBalanceBatchUserOutcome::Failed(reason) = outcome {
failures.push(json!({ "user_id": user_id, "reason": reason }));
}
}
let mut uncertain_user_ids = Vec::new();
let mut unprocessed_user_ids = Vec::new();
let mut interrupted = false;
for (item_index, item) in resolved.items.iter().enumerate() {
if outcomes.contains_key(&item.user_id) {
continue;
}
match state.find_user_auth_by_id(&item.user_id).await {
Err(_) => {
failures.push(json!({
"user_id": item.user_id,
"reason": "读取用户状态失败,批次已中止,该用户未执行",
}));
unprocessed_user_ids.push(item.user_id.clone());
interrupted = true;
}
Ok(None) => {
let outcome = match state
.record_admin_user_wallet_balance_batch_failure(
&admin_user_id,
idempotency_key,
&item.user_id,
"用户不存在或已删除",
)
.await
{
Ok(outcome) => outcome,
Err(_) => {
failures.push(json!({
"user_id": item.user_id,
"reason": "记录用户状态失败,批次已中止,该用户未执行",
}));
unprocessed_user_ids.push(item.user_id.clone());
interrupted = true;
append_wallet_batch_unprocessed_suffix(
&resolved.items,
item_index + 1,
&outcomes,
&mut failures,
&mut unprocessed_user_ids,
);
break;
}
};
outcomes.insert(item.user_id.clone(), outcome.clone());
match outcome {
AdminUserWalletBalanceBatchUserOutcome::Succeeded => {
completed_user_ids.push(item.user_id.clone());
success += 1;
}
AdminUserWalletBalanceBatchUserOutcome::Failed(reason) => {
failures.push(json!({ "user_id": item.user_id, "reason": reason }));
}
}
}
Ok(Some(_)) => {
let wallet = match state
.find_wallet(aether_data::repository::wallet::WalletLookupKey::UserId(
&item.user_id,
))
.await
{
Ok(Some(wallet)) => wallet,
Ok(None) => {
let outcome = match state
.record_admin_user_wallet_balance_batch_failure(
&admin_user_id,
idempotency_key,
&item.user_id,
"用户钱包不可用",
)
.await
{
Ok(outcome) => outcome,
Err(_) => {
failures.push(json!({
"user_id": item.user_id,
"reason": "记录用户钱包状态失败,批次已中止,该用户未执行",
}));
unprocessed_user_ids.push(item.user_id.clone());
interrupted = true;
append_wallet_batch_unprocessed_suffix(
&resolved.items,
item_index + 1,
&outcomes,
&mut failures,
&mut unprocessed_user_ids,
);
break;
}
};
outcomes.insert(item.user_id.clone(), outcome.clone());
match outcome {
AdminUserWalletBalanceBatchUserOutcome::Succeeded => {
completed_user_ids.push(item.user_id.clone());
success += 1;
}
AdminUserWalletBalanceBatchUserOutcome::Failed(reason) => {
failures.push(json!({ "user_id": item.user_id, "reason": reason }));
}
}
continue;
}
Err(_) => {
failures.push(json!({
"user_id": item.user_id,
"reason": "读取用户钱包失败,批次已中止,该用户未执行",
}));
unprocessed_user_ids.push(item.user_id.clone());
interrupted = true;
append_wallet_batch_unprocessed_suffix(
&resolved.items,
item_index + 1,
&outcomes,
&mut failures,
&mut unprocessed_user_ids,
);
break;
}
};
let result = state
.adjust_admin_user_wallet_balance_batch_user(
aether_data::repository::wallet::AdjustWalletBalanceInBatchInput {
admin_user_id: admin_user_id.clone(),
idempotency_key: idempotency_key.to_string(),
user_id: item.user_id.clone(),
adjustment: aether_data::repository::wallet::AdjustWalletBalanceInput {
wallet_id: wallet.id,
amount_usd: signed_amount,
balance_type: "recharge".to_string(),
operator_id: Some(admin_user_id.clone()),
description: Some("管理员批量调整用户余额".to_string()),
clamp_deduction_to_available_balance: true,
batch_context: None,
},
},
)
.await;
match result {
Ok(AdminUserWalletBalanceBatchUserOutcome::Succeeded) => {
outcomes.insert(
item.user_id.clone(),
AdminUserWalletBalanceBatchUserOutcome::Succeeded,
);
completed_user_ids.push(item.user_id.clone());
success += 1;
}
Ok(AdminUserWalletBalanceBatchUserOutcome::Failed(reason)) => {
outcomes.insert(
item.user_id.clone(),
AdminUserWalletBalanceBatchUserOutcome::Failed(reason.clone()),
);
failures.push(json!({ "user_id": item.user_id, "reason": reason }));
}
Err(_) => {
failures.push(json!({
"user_id": item.user_id,
"reason": "余额调整结果未确认,批次已中止,请使用同一批次重试以核对结果",
}));
uncertain_user_ids.push(item.user_id.clone());
interrupted = true;
append_wallet_batch_unprocessed_suffix(
&resolved.items,
item_index + 1,
&outcomes,
&mut failures,
&mut unprocessed_user_ids,
);
break;
}
}
}
}
if interrupted {
for pending in resolved.items.iter().skip(item_index + 1) {
if outcomes.contains_key(&pending.user_id) {
continue;
}
failures.push(json!({
"user_id": pending.user_id,
"reason": "因前序错误未执行",
}));
unprocessed_user_ids.push(pending.user_id.clone());
}
break;
}
}
let failed = failures.len();
let total = success + failed;
let mut response_payload = json!({
"total": total,
"success": success,
"failed": failed,
"failures": failures,
"warnings": resolved.warnings,
"action": action,
"modified_fields": mutation.modified_fields,
"interrupted": interrupted,
});
if interrupted {
response_payload["completed_user_ids"] = json!(completed_user_ids);
response_payload["uncertain_user_ids"] = json!(uncertain_user_ids);
response_payload["unprocessed_user_ids"] = json!(unprocessed_user_ids);
}
let response = Json(response_payload).into_response();
Ok(attach_admin_audit_response(
response,
"admin_users_batch_action_executed",
"batch_update_users",
"user_batch",
"users",
))
}
fn append_wallet_batch_unprocessed_suffix(
items: &[AdminUserSelectionItem],
start_index: usize,
outcomes: &BTreeMap<String, AdminUserWalletBalanceBatchUserOutcome>,
failures: &mut Vec<Value>,
unprocessed_user_ids: &mut Vec<String>,
) {
for pending in items.iter().skip(start_index) {
if outcomes.contains_key(&pending.user_id) {
continue;
}
failures.push(json!({
"user_id": pending.user_id,
"reason": "因前序错误未执行",
}));
unprocessed_user_ids.push(pending.user_id.clone());
}
}
fn build_admin_user_batch_idempotency_conflict_response() -> Response<Body> {
(
http::StatusCode::CONFLICT,
Json(json!({
"detail": "idempotency_key was already used with a different request",
"error_code": "idempotency_key_conflict",
})),
)
.into_response()
}
fn parse_resolve_selection_request(
request_body: Option<&Bytes>,
) -> Result<AdminUserSelectionRequest, String> {
@@ -452,6 +857,7 @@ fn parse_batch_action_request(
selection: parse_selection_request_value(raw.selection)?,
action: raw.action,
payload: raw.payload,
idempotency_key: raw.idempotency_key,
})
}
_ => Err("Invalid JSON request body".to_string()),
@@ -893,26 +1299,29 @@ async fn apply_batch_user_wallet_limit_mode(
state: &AdminAppState<'_>,
user_id: &str,
unlimited: bool,
) -> Result<bool, GatewayError> {
) -> Result<bool, AdminBatchWalletLimitModeError> {
let desired_limit_mode = if unlimited { "unlimited" } else { "finite" };
match state
let wallet = state
.find_wallet(aether_data::repository::wallet::WalletLookupKey::UserId(
user_id,
))
.await?
{
.await
.map_err(|_| AdminBatchWalletLimitModeError::WalletLookup)?;
match wallet {
Some(wallet) => {
if wallet.limit_mode.eq_ignore_ascii_case(desired_limit_mode) {
return Ok(true);
}
Ok(state
.update_auth_user_wallet_limit_mode(user_id, desired_limit_mode)
.await?
.await
.map_err(|_| AdminBatchWalletLimitModeError::Mutation)?
.is_some())
}
None => Ok(state
.initialize_auth_user_wallet(user_id, 0.0, unlimited)
.await?
.await
.map_err(|_| AdminBatchWalletLimitModeError::Mutation)?
.is_some()),
}
}
+15
View File
@@ -496,6 +496,21 @@ pub struct AppState {
#[cfg(test)]
pub(crate) auth_wallet_lookup_error_for_tests: Option<String>,
#[cfg(test)]
pub(crate) auth_wallet_batch_store_for_tests: Option<
Arc<
StdMutex<
HashMap<
(String, String),
aether_data::repository::wallet::StoredAdminUserWalletBalanceBatch,
>,
>,
>,
>,
#[cfg(test)]
pub(crate) auth_wallet_batch_operation_lock_for_tests: Arc<TokioMutex<()>>,
#[cfg(test)]
pub(crate) auth_wallet_batch_failure_record_error_for_tests: Option<String>,
#[cfg(test)]
pub(crate) admin_wallet_payment_order_store:
Option<Arc<StdMutex<HashMap<String, AdminWalletPaymentOrderRecord>>>>,
#[cfg(test)]
+6
View File
@@ -470,6 +470,12 @@ impl AppState {
#[cfg(test)]
auth_wallet_lookup_error_for_tests: None,
#[cfg(test)]
auth_wallet_batch_store_for_tests: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
auth_wallet_batch_operation_lock_for_tests: Arc::new(TokioMutex::new(())),
#[cfg(test)]
auth_wallet_batch_failure_record_error_for_tests: None,
#[cfg(test)]
admin_wallet_payment_order_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
#[cfg(test)]
admin_payment_callback_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
@@ -1,8 +1,198 @@
use crate::{AdminWalletPaymentOrderRecord, AdminWalletTransactionRecord, AppState, GatewayError};
use aether_data::repository::wallet::{
AdjustWalletBalanceInBatchInput, AdminUserWalletBalanceBatchUserOutcome,
PrepareAdminUserWalletBalanceBatchInput, PrepareAdminUserWalletBalanceBatchOutcome,
StoredAdminUserWalletBalanceBatch,
};
use std::collections::BTreeMap;
use super::admin_wallet_build_order_no;
impl AppState {
pub(crate) async fn prepare_admin_user_wallet_balance_batch(
&self,
input: PrepareAdminUserWalletBalanceBatchInput,
) -> Result<PrepareAdminUserWalletBalanceBatchOutcome, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_batch_store_for_tests.as_ref() {
let mut batches = store.lock().expect("auth wallet batch store should lock");
let key = (input.admin_user_id.clone(), input.idempotency_key.clone());
if let Some(existing) = batches.get(&key) {
if existing.request_fingerprint != input.request_fingerprint {
return Ok(PrepareAdminUserWalletBalanceBatchOutcome::Conflict);
}
return Ok(PrepareAdminUserWalletBalanceBatchOutcome::Ready(
existing.clone(),
));
}
let batch = StoredAdminUserWalletBalanceBatch {
admin_user_id: input.admin_user_id,
idempotency_key: input.idempotency_key,
request_fingerprint: input.request_fingerprint,
target_user_ids: input.target_user_ids,
missing_user_ids: input.missing_user_ids,
warnings: input.warnings,
user_outcomes: BTreeMap::new(),
};
batches.insert(key, batch.clone());
return Ok(PrepareAdminUserWalletBalanceBatchOutcome::Ready(batch));
}
self.data
.prepare_admin_user_wallet_balance_batch(input)
.await
.map_err(|error| GatewayError::Internal(error.to_string()))?
.ok_or_else(|| {
GatewayError::Internal("admin wallet batch storage is unavailable".to_string())
})
}
pub(crate) async fn get_admin_user_wallet_balance_batch(
&self,
admin_user_id: &str,
idempotency_key: &str,
request_fingerprint: &str,
) -> Result<Option<PrepareAdminUserWalletBalanceBatchOutcome>, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_batch_store_for_tests.as_ref() {
let batches = store.lock().expect("auth wallet batch store should lock");
return Ok(batches
.get(&(admin_user_id.to_string(), idempotency_key.to_string()))
.map(|existing| {
if existing.request_fingerprint != request_fingerprint {
PrepareAdminUserWalletBalanceBatchOutcome::Conflict
} else {
PrepareAdminUserWalletBalanceBatchOutcome::Ready(existing.clone())
}
}));
}
self.data
.get_admin_user_wallet_balance_batch(
admin_user_id,
idempotency_key,
request_fingerprint,
)
.await
.map_err(|error| GatewayError::Internal(error.to_string()))
}
pub(crate) async fn adjust_admin_user_wallet_balance_batch_user(
&self,
input: AdjustWalletBalanceInBatchInput,
) -> Result<AdminUserWalletBalanceBatchUserOutcome, GatewayError> {
#[cfg(test)]
if let Some(store) = self.auth_wallet_batch_store_for_tests.as_ref() {
let _operation = self.auth_wallet_batch_operation_lock_for_tests.lock().await;
let key = (input.admin_user_id.clone(), input.idempotency_key.clone());
if let Some(existing) = store
.lock()
.expect("auth wallet batch store should lock")
.get(&key)
.and_then(|batch| batch.user_outcomes.get(&input.user_id))
.cloned()
{
return Ok(existing);
}
let target_exists = store
.lock()
.expect("auth wallet batch store should lock")
.get(&key)
.is_some_and(|batch| batch.target_user_ids.contains(&input.user_id));
if !target_exists {
return Err(GatewayError::Internal(
"user is outside the prepared admin wallet batch".to_string(),
));
}
let adjustment = input.adjustment;
let result = self
.admin_adjust_wallet_balance(
&adjustment.wallet_id,
adjustment.amount_usd,
&adjustment.balance_type,
adjustment.operator_id.as_deref(),
adjustment.description.as_deref(),
adjustment.clamp_deduction_to_available_balance,
)
.await?;
let outcome = if result.is_some() {
AdminUserWalletBalanceBatchUserOutcome::Succeeded
} else {
AdminUserWalletBalanceBatchUserOutcome::Failed("用户钱包不可用".to_string())
};
if let Some(batch) = store
.lock()
.expect("auth wallet batch store should lock")
.get_mut(&key)
{
batch.user_outcomes.insert(input.user_id, outcome.clone());
}
return Ok(outcome);
}
self.data
.adjust_admin_user_wallet_balance_batch_user(input)
.await
.map_err(|error| GatewayError::Internal(error.to_string()))?
.ok_or_else(|| {
GatewayError::Internal("admin wallet batch storage is unavailable".to_string())
})
}
pub(crate) async fn record_admin_user_wallet_balance_batch_failure(
&self,
admin_user_id: &str,
idempotency_key: &str,
user_id: &str,
reason: &str,
) -> Result<AdminUserWalletBalanceBatchUserOutcome, GatewayError> {
#[cfg(test)]
if self
.auth_wallet_batch_failure_record_error_for_tests
.as_deref()
== Some(user_id)
{
return Err(GatewayError::Internal(
"injected wallet batch failure-record error".to_string(),
));
}
#[cfg(test)]
if let Some(store) = self.auth_wallet_batch_store_for_tests.as_ref() {
let _operation = self.auth_wallet_batch_operation_lock_for_tests.lock().await;
let key = (admin_user_id.to_string(), idempotency_key.to_string());
let mut batches = store.lock().expect("auth wallet batch store should lock");
let batch = batches.get_mut(&key).ok_or_else(|| {
GatewayError::Internal("admin wallet batch was not prepared".to_string())
})?;
if !batch.target_user_ids.iter().any(|target| target == user_id) {
return Err(GatewayError::Internal(
"user is outside the prepared admin wallet batch".to_string(),
));
}
return Ok(batch
.user_outcomes
.entry(user_id.to_string())
.or_insert_with(|| {
AdminUserWalletBalanceBatchUserOutcome::Failed(reason.to_string())
})
.clone());
}
self.data
.record_admin_user_wallet_balance_batch_failure(
admin_user_id,
idempotency_key,
user_id,
reason,
)
.await
.map_err(|error| GatewayError::Internal(error.to_string()))?
.ok_or_else(|| {
GatewayError::Internal("admin wallet batch storage is unavailable".to_string())
})
}
pub(crate) async fn admin_adjust_wallet_balance(
&self,
wallet_id: &str,
@@ -117,6 +307,7 @@ impl AppState {
operator_id: operator_id.map(ToOwned::to_owned),
description: description.map(ToOwned::to_owned),
clamp_deduction_to_available_balance,
batch_context: None,
})
.await?
.map(|(wallet, transaction)| {
+8
View File
@@ -485,6 +485,14 @@ impl AppState {
self
}
pub(crate) fn fail_auth_wallet_batch_failure_record_for_tests(
mut self,
user_id: impl Into<String>,
) -> Self {
self.auth_wallet_batch_failure_record_error_for_tests = Some(user_id.into());
self
}
pub(crate) fn with_admin_wallet_payment_orders_for_tests<I>(mut self, orders: I) -> Self
where
I: IntoIterator<Item = crate::AdminWalletPaymentOrderRecord>,
@@ -1,3 +1,4 @@
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use aether_data::repository::management_tokens::InMemoryManagementTokenRepository;
@@ -68,6 +69,14 @@ fn sample_wallet(user_id: &str, balance: f64, gift_balance: f64) -> StoredWallet
}
async fn post_batch_action(client: &Client, gateway_url: &str, payload: Value) -> Response {
static NEXT_TEST_IDEMPOTENCY_KEY: AtomicU64 = AtomicU64::new(1);
let mut payload = payload;
if payload.get("action").and_then(Value::as_str) == Some("adjust_wallet_balance")
&& payload.get("idempotency_key").is_none()
{
let sequence = NEXT_TEST_IDEMPOTENCY_KEY.fetch_add(1, Ordering::Relaxed);
payload["idempotency_key"] = json!(format!("test-wallet-batch-{sequence}"));
}
admin_headers(client.post(format!("{gateway_url}/api/admin/users/batch-action")))
.json(&payload)
.send()
@@ -172,6 +181,132 @@ async fn gateway_batches_wallet_addition_deduction_and_clamped_deduction_per_use
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_replays_wallet_batch_idempotently_and_rejects_key_reuse() {
let state = AppState::new()
.expect("gateway should build")
.with_auth_users_for_tests([sample_user("user-1")])
.with_auth_wallets_for_tests([sample_wallet("user-1", 10.0, 0.0)]);
let (gateway_url, gateway_handle) = start_server(build_router_with_state(state)).await;
let client = Client::new();
let request = json!({
"selection": { "user_ids": ["user-1"] },
"action": "adjust_wallet_balance",
"payload": { "operation": "add", "amount": 5.0 },
"idempotency_key": "same-wallet-batch"
});
let first = post_batch_action(&client, &gateway_url, request.clone()).await;
assert_eq!(first.status(), StatusCode::OK);
let first_result: Value = first.json().await.expect("response should parse");
assert_eq!(first_result["success"], 1);
assert_eq!(
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
15.0
);
let replay = post_batch_action(&client, &gateway_url, request.clone()).await;
assert_eq!(replay.status(), StatusCode::OK);
let replay_result: Value = replay.json().await.expect("response should parse");
assert_eq!(replay_result["success"], 1);
assert_eq!(
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
15.0
);
let changed_request = json!({
"selection": { "user_ids": ["user-1"] },
"action": "adjust_wallet_balance",
"payload": { "operation": "add", "amount": 50.0 },
"idempotency_key": "same-wallet-batch"
});
let conflict = post_batch_action(&client, &gateway_url, changed_request).await;
assert_eq!(conflict.status(), StatusCode::CONFLICT);
assert_eq!(
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
15.0
);
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_returns_partial_results_when_failure_recording_fails() {
let state = AppState::new()
.expect("gateway should build")
.with_auth_users_for_tests([sample_user("user-1"), sample_user("user-2")])
.with_auth_wallets_for_tests([sample_wallet("user-1", 10.0, 0.0)])
.fail_auth_wallet_batch_failure_record_for_tests("user-2");
let (gateway_url, gateway_handle) = start_server(build_router_with_state(state)).await;
let client = Client::new();
let request = json!({
"selection": { "user_ids": ["user-1", "user-2"] },
"action": "adjust_wallet_balance",
"payload": { "operation": "add", "amount": 5.0 },
"idempotency_key": "failure-record-wallet-batch"
});
let response = post_batch_action(&client, &gateway_url, request).await;
assert_eq!(response.status(), StatusCode::OK);
let result: Value = response.json().await.expect("response should parse");
assert_eq!(result["success"], 1);
assert_eq!(result["failed"], 1);
assert_eq!(result["interrupted"], true);
assert_eq!(result["completed_user_ids"], json!(["user-1"]));
assert_eq!(result["uncertain_user_ids"], json!([]));
assert_eq!(result["unprocessed_user_ids"], json!(["user-2"]));
assert_eq!(
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
15.0
);
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_records_zero_delta_batch_for_later_replay() {
let state = AppState::new()
.expect("gateway should build")
.with_auth_users_for_tests([sample_user("user-1")])
.with_auth_wallets_for_tests([sample_wallet("user-1", 0.0, 0.0)]);
let (gateway_url, gateway_handle) = start_server(build_router_with_state(state)).await;
let client = Client::new();
let deduction = json!({
"selection": { "user_ids": ["user-1"] },
"action": "adjust_wallet_balance",
"payload": { "operation": "deduct", "amount": 5.0 },
"idempotency_key": "zero-delta-wallet-batch"
});
let first = post_batch_action(&client, &gateway_url, deduction.clone()).await;
assert_eq!(first.status(), StatusCode::OK);
let top_up = post_batch_action(
&client,
&gateway_url,
json!({
"selection": { "user_ids": ["user-1"] },
"action": "adjust_wallet_balance",
"payload": { "operation": "add", "amount": 10.0 },
"idempotency_key": "wallet-top-up-batch"
}),
)
.await;
assert_eq!(top_up.status(), StatusCode::OK);
assert_eq!(
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
10.0
);
let replay = post_batch_action(&client, &gateway_url, deduction).await;
assert_eq!(replay.status(), StatusCode::OK);
assert_eq!(
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
10.0
);
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_requires_wallet_write_permission_for_batch_balance_adjustments() {
let users_write_token = "ae-batch-users-write-only";
@@ -236,7 +371,8 @@ async fn gateway_requires_wallet_write_permission_for_batch_balance_adjustments(
let payload = json!({
"selection": { "user_ids": ["user-1"] },
"action": "adjust_wallet_balance",
"payload": { "operation": "add", "amount": 5.0 }
"payload": { "operation": "add", "amount": 5.0 },
"idempotency_key": "wallet-write-batch"
});
let denied = client
@@ -279,7 +415,12 @@ async fn gateway_requires_wallet_write_permission_for_batch_balance_adjustments(
.post(format!("{gateway_url}/api/admin/users/batch-action"))
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
.bearer_auth(wallet_admin_token)
.json(&payload)
.json(&json!({
"selection": payload["selection"],
"action": payload["action"],
"payload": payload["payload"],
"idempotency_key": "wallet-admin-batch"
}))
.send()
.await
.expect("wallet-admin management token request should complete");
@@ -401,6 +542,44 @@ async fn gateway_reports_wallet_lookup_failure_as_unprocessed() {
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_reports_wallet_limit_lookup_failure_as_unprocessed() {
let state = AppState::new()
.expect("gateway should build")
.with_auth_users_for_tests([
sample_user("user-1"),
sample_user("user-2"),
sample_user("user-3"),
])
.with_auth_wallets_for_tests([
sample_wallet("user-1", 10.0, 0.0),
sample_wallet("user-2", 20.0, 0.0),
sample_wallet("user-3", 30.0, 0.0),
])
.fail_auth_wallet_lookup_for_tests("user-2");
let (gateway_url, gateway_handle) = start_server(build_router_with_state(state)).await;
let client = Client::new();
let response = post_batch_action(
&client,
&gateway_url,
json!({
"selection": { "user_ids": ["user-1", "user-2", "user-3"] },
"action": "update_access_control",
"payload": { "unlimited": true }
}),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let result: Value = response.json().await.expect("response should parse");
assert_eq!(result["interrupted"], true);
assert_eq!(result["completed_user_ids"], json!(["user-1"]));
assert_eq!(result["uncertain_user_ids"], json!([]));
assert_eq!(result["unprocessed_user_ids"], json!(["user-2", "user-3"]));
gateway_handle.abort();
}
#[tokio::test]
async fn gateway_reports_missing_wallet_and_skips_zero_delta_for_non_positive_balance() {
let state = AppState::new()