mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 18:07:47 +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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
@@ -1066,13 +1068,81 @@ impl GatewayDataState {
|
||||
pub(crate) async fn adjust_wallet_balance(
|
||||
&self,
|
||||
input: AdjustWalletBalanceInput,
|
||||
) -> Result<Option<(StoredWalletSnapshot, StoredAdminWalletTransaction)>, DataLayerError> {
|
||||
) -> Result<Option<(StoredWalletSnapshot, Option<StoredAdminWalletTransaction>)>, DataLayerError>
|
||||
{
|
||||
match &self.wallet_writer {
|
||||
Some(repository) => repository.adjust_wallet_balance(input).await,
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -63,13 +63,14 @@ pub(in super::super) async fn build_admin_wallet_adjust_response(
|
||||
}
|
||||
let operator_id = admin_wallet_operator_id(request_context);
|
||||
let has_wallet_writer = state.has_wallet_data_writer();
|
||||
let Some((wallet, transaction)) = state
|
||||
let Some((wallet, Some(transaction))) = state
|
||||
.admin_adjust_wallet_balance(
|
||||
&wallet_id,
|
||||
amount_usd,
|
||||
&balance_type,
|
||||
operator_id.as_deref(),
|
||||
description.as_deref(),
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
|
||||
@@ -387,10 +387,11 @@ impl<'a> AdminAppState<'a> {
|
||||
balance_type: &str,
|
||||
operator_id: Option<&str>,
|
||||
description: Option<&str>,
|
||||
clamp_deduction_to_available_balance: bool,
|
||||
) -> Result<
|
||||
Option<(
|
||||
aether_data::repository::wallet::StoredWalletSnapshot,
|
||||
crate::AdminWalletTransactionRecord,
|
||||
Option<crate::AdminWalletTransactionRecord>,
|
||||
)>,
|
||||
GatewayError,
|
||||
> {
|
||||
@@ -401,6 +402,7 @@ impl<'a> AdminAppState<'a> {
|
||||
balance_type,
|
||||
operator_id,
|
||||
description,
|
||||
clamp_deduction_to_available_balance,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
use super::{
|
||||
build_admin_users_bad_request_response, build_admin_users_permission_denied_response,
|
||||
build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field,
|
||||
build_admin_users_read_only_response, build_admin_users_wallet_permission_denied_response,
|
||||
disabled_user_policy_detail, disabled_user_policy_field,
|
||||
management_token_may_adjust_admin_wallet_balance,
|
||||
management_token_may_administer_user_accounts, normalize_admin_user_role,
|
||||
};
|
||||
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,
|
||||
@@ -13,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>,
|
||||
@@ -27,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>,
|
||||
@@ -40,6 +46,7 @@ struct AdminUserBatchActionRequest {
|
||||
selection: AdminUserSelectionRequest,
|
||||
action: String,
|
||||
payload: Option<Value>,
|
||||
idempotency_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -48,6 +55,8 @@ struct RawAdminUserBatchActionRequest {
|
||||
action: String,
|
||||
#[serde(default)]
|
||||
payload: Option<Value>,
|
||||
#[serde(default)]
|
||||
idempotency_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -68,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,
|
||||
@@ -88,6 +97,7 @@ struct AdminUserBatchMutation {
|
||||
role: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
unlimited: Option<bool>,
|
||||
wallet_balance_adjustment: Option<AdminUserWalletBalanceAdjustment>,
|
||||
modified_fields: Vec<&'static str>,
|
||||
}
|
||||
|
||||
@@ -97,6 +107,28 @@ impl AdminUserBatchMutation {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct AdminUserWalletBalanceAdjustment {
|
||||
operation: AdminUserWalletBalanceOperation,
|
||||
amount: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum AdminUserWalletBalanceOperation {
|
||||
Add,
|
||||
Deduct,
|
||||
}
|
||||
|
||||
enum AdminBatchWalletBalanceAdjustmentError {
|
||||
WalletLookup,
|
||||
BalanceAdjustment,
|
||||
}
|
||||
|
||||
enum AdminBatchWalletLimitModeError {
|
||||
WalletLookup,
|
||||
Mutation,
|
||||
}
|
||||
|
||||
pub(in super::super) async fn build_admin_resolve_user_selection_response(
|
||||
state: &AdminAppState<'_>,
|
||||
_request_context: &AdminRequestContext<'_>,
|
||||
@@ -128,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)),
|
||||
@@ -177,9 +228,29 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
|
||||
.iter()
|
||||
.map(|user_id| json!({ "user_id": user_id, "reason": "用户不存在或已删除" }))
|
||||
.collect::<Vec<_>>();
|
||||
let mut completed_user_ids = Vec::new();
|
||||
let mut uncertain_user_ids = Vec::new();
|
||||
let mut unprocessed_user_ids = Vec::new();
|
||||
let mut interrupted = false;
|
||||
|
||||
for item in &resolved.items {
|
||||
if state.find_user_auth_by_id(&item.user_id).await?.is_none() {
|
||||
for (item_index, item) in resolved.items.iter().enumerate() {
|
||||
let user = match state.find_user_auth_by_id(&item.user_id).await {
|
||||
Ok(user) => user,
|
||||
Err(_) => {
|
||||
record_batch_action_interruption(
|
||||
&resolved.items,
|
||||
item_index,
|
||||
false,
|
||||
"读取用户状态失败,批次已中止,该用户未执行",
|
||||
&mut failures,
|
||||
&mut uncertain_user_ids,
|
||||
&mut unprocessed_user_ids,
|
||||
);
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if user.is_none() {
|
||||
failures.push(json!({
|
||||
"user_id": item.user_id,
|
||||
"reason": "用户不存在或已删除",
|
||||
@@ -202,17 +273,92 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
|
||||
}
|
||||
|
||||
if let Some(unlimited) = mutation.unlimited {
|
||||
if !apply_batch_user_wallet_limit_mode(state, &item.user_id, unlimited).await? {
|
||||
failures.push(json!({
|
||||
"user_id": item.user_id,
|
||||
"reason": "用户钱包不可用",
|
||||
}));
|
||||
continue;
|
||||
match apply_batch_user_wallet_limit_mode(state, &item.user_id, unlimited).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
failures.push(json!({
|
||||
"user_id": item.user_id,
|
||||
"reason": "用户钱包不可用",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
true,
|
||||
"用户钱包更新结果未确认,批次已中止,请核对钱包后再重试",
|
||||
&mut failures,
|
||||
&mut uncertain_user_ids,
|
||||
&mut unprocessed_user_ids,
|
||||
);
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mutation.has_auth_user_fields()
|
||||
&& state
|
||||
if let Some(adjustment) = mutation.wallet_balance_adjustment {
|
||||
match apply_batch_user_wallet_balance_adjustment(
|
||||
state,
|
||||
&item.user_id,
|
||||
adjustment,
|
||||
current_admin_user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
failures.push(json!({
|
||||
"user_id": item.user_id,
|
||||
"reason": "用户钱包不可用",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
Err(AdminBatchWalletBalanceAdjustmentError::WalletLookup) => {
|
||||
record_batch_action_interruption(
|
||||
&resolved.items,
|
||||
item_index,
|
||||
false,
|
||||
"读取用户钱包失败,批次已中止,该用户未执行",
|
||||
&mut failures,
|
||||
&mut uncertain_user_ids,
|
||||
&mut unprocessed_user_ids,
|
||||
);
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
Err(AdminBatchWalletBalanceAdjustmentError::BalanceAdjustment) => {
|
||||
record_batch_action_interruption(
|
||||
&resolved.items,
|
||||
item_index,
|
||||
true,
|
||||
"余额调整结果未确认,批次已中止,请核对钱包后再重试",
|
||||
&mut failures,
|
||||
&mut uncertain_user_ids,
|
||||
&mut unprocessed_user_ids,
|
||||
);
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if mutation.has_auth_user_fields() {
|
||||
let updated_user = match state
|
||||
.update_local_auth_user_admin_fields(
|
||||
&item.user_id,
|
||||
mutation.role.clone(),
|
||||
@@ -226,22 +372,39 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
|
||||
None,
|
||||
mutation.is_active,
|
||||
)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
failures.push(json!({
|
||||
"user_id": item.user_id,
|
||||
"reason": "用户不存在或已删除",
|
||||
}));
|
||||
continue;
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(_) => {
|
||||
record_batch_action_interruption(
|
||||
&resolved.items,
|
||||
item_index,
|
||||
true,
|
||||
"用户更新结果未确认,批次已中止,请核对后再重试",
|
||||
&mut failures,
|
||||
&mut uncertain_user_ids,
|
||||
&mut unprocessed_user_ids,
|
||||
);
|
||||
interrupted = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if updated_user.is_none() {
|
||||
failures.push(json!({
|
||||
"user_id": item.user_id,
|
||||
"reason": "用户不存在或已删除",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
success += 1;
|
||||
completed_user_ids.push(item.user_id.clone());
|
||||
}
|
||||
|
||||
let failed = failures.len();
|
||||
let total = success + failed;
|
||||
let response = Json(json!({
|
||||
let mut response_payload = json!({
|
||||
"total": total,
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
@@ -249,8 +412,14 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
|
||||
"warnings": resolved.warnings,
|
||||
"action": request.action.trim().to_ascii_lowercase(),
|
||||
"modified_fields": mutation.modified_fields,
|
||||
}))
|
||||
.into_response();
|
||||
"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,
|
||||
@@ -261,6 +430,408 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
|
||||
))
|
||||
}
|
||||
|
||||
fn record_batch_action_interruption(
|
||||
items: &[AdminUserSelectionItem],
|
||||
item_index: usize,
|
||||
current_result_uncertain: bool,
|
||||
reason: &str,
|
||||
failures: &mut Vec<Value>,
|
||||
uncertain_user_ids: &mut Vec<String>,
|
||||
unprocessed_user_ids: &mut Vec<String>,
|
||||
) {
|
||||
let current_item = &items[item_index];
|
||||
failures.push(json!({
|
||||
"user_id": current_item.user_id,
|
||||
"reason": reason,
|
||||
}));
|
||||
if current_result_uncertain {
|
||||
uncertain_user_ids.push(current_item.user_id.clone());
|
||||
} else {
|
||||
unprocessed_user_ids.push(current_item.user_id.clone());
|
||||
}
|
||||
|
||||
for item in items.iter().skip(item_index + 1) {
|
||||
failures.push(json!({
|
||||
"user_id": item.user_id,
|
||||
"reason": "因前序错误未执行",
|
||||
}));
|
||||
unprocessed_user_ids.push(item.user_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -286,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()),
|
||||
@@ -604,10 +1176,37 @@ fn parse_batch_mutation(
|
||||
}),
|
||||
"update_access_control" => parse_access_control_mutation(payload),
|
||||
"update_role" => parse_role_mutation(payload),
|
||||
"adjust_wallet_balance" => parse_wallet_balance_adjustment_mutation(payload),
|
||||
_ => Err("不支持的批量操作".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_wallet_balance_adjustment_mutation(
|
||||
payload: Option<Value>,
|
||||
) -> Result<AdminUserBatchMutation, String> {
|
||||
let Some(Value::Object(payload)) = payload else {
|
||||
return Err("payload 必须是对象".to_string());
|
||||
};
|
||||
let operation = match payload.get("operation").and_then(Value::as_str) {
|
||||
Some("add") => AdminUserWalletBalanceOperation::Add,
|
||||
Some("deduct") => AdminUserWalletBalanceOperation::Deduct,
|
||||
_ => return Err("operation 必须为 add 或 deduct".to_string()),
|
||||
};
|
||||
let amount = payload
|
||||
.get("amount")
|
||||
.and_then(Value::as_f64)
|
||||
.ok_or_else(|| "amount 必须为大于 0 的有限数字".to_string())?;
|
||||
if !amount.is_finite() || amount <= 0.0 {
|
||||
return Err("amount 必须为大于 0 的有限数字".to_string());
|
||||
}
|
||||
|
||||
Ok(AdminUserBatchMutation {
|
||||
wallet_balance_adjustment: Some(AdminUserWalletBalanceAdjustment { operation, amount }),
|
||||
modified_fields: vec!["wallet_balance"],
|
||||
..AdminUserBatchMutation::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_role_mutation(payload: Option<Value>) -> Result<AdminUserBatchMutation, String> {
|
||||
let Some(Value::Object(payload)) = payload else {
|
||||
return Err("payload 必须是对象".to_string());
|
||||
@@ -700,30 +1299,68 @@ 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()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_batch_user_wallet_balance_adjustment(
|
||||
state: &AdminAppState<'_>,
|
||||
user_id: &str,
|
||||
adjustment: AdminUserWalletBalanceAdjustment,
|
||||
operator_id: Option<&str>,
|
||||
) -> Result<bool, AdminBatchWalletBalanceAdjustmentError> {
|
||||
// Resolve only the wallet ID; the repository clamps the deduction under its row lock.
|
||||
let Some(wallet) = state
|
||||
.find_wallet(aether_data::repository::wallet::WalletLookupKey::UserId(
|
||||
user_id,
|
||||
))
|
||||
.await
|
||||
.map_err(|_| AdminBatchWalletBalanceAdjustmentError::WalletLookup)?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
let amount = match adjustment.operation {
|
||||
AdminUserWalletBalanceOperation::Add => adjustment.amount,
|
||||
AdminUserWalletBalanceOperation::Deduct => -adjustment.amount,
|
||||
};
|
||||
|
||||
state
|
||||
.admin_adjust_wallet_balance(
|
||||
&wallet.id,
|
||||
amount,
|
||||
"recharge",
|
||||
operator_id,
|
||||
Some("管理员批量调整用户余额"),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| AdminBatchWalletBalanceAdjustmentError::BalanceAdjustment)
|
||||
.map(|result| result.is_some())
|
||||
}
|
||||
|
||||
fn build_admin_user_batch_bad_request_response(detail: String) -> Response<Body> {
|
||||
if detail.as_str() == "缺少 user_id" {
|
||||
return build_admin_users_bad_request_response("缺少 user_id");
|
||||
|
||||
@@ -49,12 +49,14 @@ use self::shared::AdminUpdateUserPatch;
|
||||
use self::shared::{
|
||||
admin_default_user_initial_gift, build_admin_users_bad_request_response,
|
||||
build_admin_users_data_unavailable_response, build_admin_users_permission_denied_response,
|
||||
build_admin_users_read_only_response, disabled_user_policy_detail, disabled_user_policy_field,
|
||||
format_optional_datetime_iso8601, legacy_admin_list_policy_mode,
|
||||
legacy_admin_rate_limit_policy_mode, management_token_may_administer_user_accounts,
|
||||
normalize_admin_optional_user_email, normalize_admin_user_group_ids, normalize_admin_user_role,
|
||||
normalize_admin_username, validate_admin_user_password, AdminCreateUserApiKeyRequest,
|
||||
AdminCreateUserRequest, AdminToggleUserApiKeyLockRequest, AdminUpdateUserApiKeyRequest,
|
||||
build_admin_users_read_only_response, build_admin_users_wallet_permission_denied_response,
|
||||
disabled_user_policy_detail, disabled_user_policy_field, format_optional_datetime_iso8601,
|
||||
legacy_admin_list_policy_mode, legacy_admin_rate_limit_policy_mode,
|
||||
management_token_may_adjust_admin_wallet_balance,
|
||||
management_token_may_administer_user_accounts, normalize_admin_optional_user_email,
|
||||
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username,
|
||||
validate_admin_user_password, AdminCreateUserApiKeyRequest, AdminCreateUserRequest,
|
||||
AdminToggleUserApiKeyLockRequest, AdminUpdateUserApiKeyRequest,
|
||||
};
|
||||
pub(crate) use self::shared::{
|
||||
normalize_admin_list_policy_mode, normalize_admin_rate_limit_policy_mode,
|
||||
|
||||
@@ -165,6 +165,18 @@ pub(super) fn management_token_may_administer_user_accounts(
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn management_token_may_adjust_admin_wallet_balance(
|
||||
request_context: &crate::handlers::admin::request::AdminRequestContext<'_>,
|
||||
) -> bool {
|
||||
request_context.decision().is_some_and(|decision| {
|
||||
crate::control::management_token_principal_has_permission(decision, "admin:wallets:write")
|
||||
|| crate::control::management_token_principal_has_permission(
|
||||
decision,
|
||||
"admin:wallets:admin",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_users_permission_denied_response(
|
||||
request_context: &crate::handlers::admin::request::AdminRequestContext<'_>,
|
||||
) -> Response<Body> {
|
||||
@@ -192,6 +204,34 @@ pub(super) fn build_admin_users_permission_denied_response(
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn build_admin_users_wallet_permission_denied_response(
|
||||
request_context: &crate::handlers::admin::request::AdminRequestContext<'_>,
|
||||
) -> Response<Body> {
|
||||
let actor_id = request_context
|
||||
.decision()
|
||||
.and_then(|decision| decision.admin_principal.as_ref())
|
||||
.and_then(|principal| principal.management_token_id.as_deref())
|
||||
.unwrap_or("unknown");
|
||||
crate::handlers::admin::shared::attach_admin_audit_response(
|
||||
(
|
||||
http::StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"detail": "management token permission denied",
|
||||
"required_permissions": ["admin:wallets:write", "admin:wallets:admin"],
|
||||
"permission_mode": "any_of",
|
||||
"route_family": request_context.route_family(),
|
||||
"route_kind": request_context.route_kind(),
|
||||
"request_path": request_context.path(),
|
||||
})),
|
||||
)
|
||||
.into_response(),
|
||||
"admin_user_wallet_balance_permission_denied",
|
||||
"permission_denied",
|
||||
"admin_user_wallet_balance",
|
||||
actor_id,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn normalize_admin_optional_user_email(
|
||||
value: Option<&str>,
|
||||
) -> Result<Option<String>, String> {
|
||||
@@ -397,9 +437,52 @@ pub(super) fn format_optional_datetime_iso8601(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{normalize_admin_user_api_formats, AdminUpdateUserApiKeyRequest};
|
||||
use super::{
|
||||
build_admin_users_wallet_permission_denied_response, normalize_admin_user_api_formats,
|
||||
AdminUpdateUserApiKeyRequest,
|
||||
};
|
||||
use crate::control::{GatewayControlDecision, GatewayPublicRequestContext};
|
||||
use crate::handlers::admin::request::AdminRequestContext;
|
||||
use axum::http::{HeaderMap, Method, Uri};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn wallet_permission_denial_uses_wallet_audit_category() {
|
||||
let uri: Uri = "/api/admin/users/batch-action"
|
||||
.parse()
|
||||
.expect("uri should parse");
|
||||
let method = Method::POST;
|
||||
let headers = HeaderMap::new();
|
||||
let decision = GatewayControlDecision::synthetic(
|
||||
uri.path(),
|
||||
Some("admin_proxy".to_string()),
|
||||
Some("users_manage".to_string()),
|
||||
Some("batch_user_action".to_string()),
|
||||
Some("admin:users".to_string()),
|
||||
);
|
||||
let context = GatewayPublicRequestContext::from_request_parts(
|
||||
"trace-wallet-permission-denied",
|
||||
&method,
|
||||
&uri,
|
||||
&headers,
|
||||
Some(decision),
|
||||
);
|
||||
let request_context = AdminRequestContext::new(&context);
|
||||
|
||||
let response = build_admin_users_wallet_permission_denied_response(&request_context);
|
||||
let event = response
|
||||
.extensions()
|
||||
.get::<crate::audit::AdminAuditEvent>()
|
||||
.expect("wallet denial should attach an audit event");
|
||||
|
||||
assert_eq!(
|
||||
event.event_name,
|
||||
"admin_user_wallet_balance_permission_denied"
|
||||
);
|
||||
assert_eq!(event.action, "permission_denied");
|
||||
assert_eq!(event.target_type, "admin_user_wallet_balance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admin_user_api_formats_accept_current_canonical_signatures() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -492,6 +492,25 @@ pub struct AppState {
|
||||
Arc<StdMutex<HashMap<String, aether_data::repository::wallet::StoredWalletSnapshot>>>,
|
||||
>,
|
||||
#[cfg(test)]
|
||||
pub(crate) auth_wallet_adjustment_error_for_tests: Option<String>,
|
||||
#[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)]
|
||||
|
||||
@@ -471,6 +471,16 @@ impl AppState {
|
||||
#[cfg(test)]
|
||||
auth_wallet_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
|
||||
#[cfg(test)]
|
||||
auth_wallet_adjustment_error_for_tests: None,
|
||||
#[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,
|
||||
@@ -10,13 +200,21 @@ impl AppState {
|
||||
balance_type: &str,
|
||||
operator_id: Option<&str>,
|
||||
description: Option<&str>,
|
||||
clamp_deduction_to_available_balance: bool,
|
||||
) -> Result<
|
||||
Option<(
|
||||
aether_data::repository::wallet::StoredWalletSnapshot,
|
||||
AdminWalletTransactionRecord,
|
||||
Option<AdminWalletTransactionRecord>,
|
||||
)>,
|
||||
GatewayError,
|
||||
> {
|
||||
#[cfg(test)]
|
||||
if self.auth_wallet_adjustment_error_for_tests.as_deref() == Some(wallet_id) {
|
||||
return Err(GatewayError::Internal(
|
||||
"injected test wallet adjustment failure".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if let Some(store) = self.auth_wallet_store.as_ref() {
|
||||
let mut guard = store.lock().expect("auth wallet store should lock");
|
||||
@@ -27,6 +225,18 @@ impl AppState {
|
||||
let before_recharge = wallet.balance;
|
||||
let before_gift = wallet.gift_balance;
|
||||
let before_total = before_recharge + before_gift;
|
||||
let amount_usd = if clamp_deduction_to_available_balance && amount_usd < 0.0 {
|
||||
if before_total < 0.0 {
|
||||
-before_total
|
||||
} else {
|
||||
-(-amount_usd).min(before_total)
|
||||
}
|
||||
} else {
|
||||
amount_usd
|
||||
};
|
||||
if amount_usd == 0.0 {
|
||||
return Ok(Some((wallet.clone(), None)));
|
||||
}
|
||||
let mut after_recharge = before_recharge;
|
||||
let mut after_gift = before_gift;
|
||||
|
||||
@@ -90,7 +300,7 @@ impl AppState {
|
||||
let updated_wallet = wallet.clone();
|
||||
drop(guard);
|
||||
self.invalidate_auth_context_cache();
|
||||
return Ok(Some((updated_wallet, transaction)));
|
||||
return Ok(Some((updated_wallet, Some(transaction))));
|
||||
}
|
||||
|
||||
Ok(self
|
||||
@@ -100,10 +310,15 @@ impl AppState {
|
||||
balance_type: balance_type.to_string(),
|
||||
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)| {
|
||||
(wallet, stored_wallet_transaction_to_gateway(transaction))
|
||||
(
|
||||
wallet,
|
||||
transaction.map(stored_wallet_transaction_to_gateway),
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ impl AppState {
|
||||
) -> Result<
|
||||
Option<(
|
||||
aether_data::repository::wallet::StoredWalletSnapshot,
|
||||
aether_data::repository::wallet::StoredAdminWalletTransaction,
|
||||
Option<aether_data::repository::wallet::StoredAdminWalletTransaction>,
|
||||
)>,
|
||||
GatewayError,
|
||||
> {
|
||||
|
||||
@@ -5,6 +5,19 @@ impl AppState {
|
||||
&self,
|
||||
lookup: aether_data::repository::wallet::WalletLookupKey<'_>,
|
||||
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
|
||||
#[cfg(test)]
|
||||
if let Some(failed_user_id) = self.auth_wallet_lookup_error_for_tests.as_deref() {
|
||||
let lookup_user_id = match &lookup {
|
||||
aether_data::repository::wallet::WalletLookupKey::UserId(user_id) => Some(*user_id),
|
||||
_ => None,
|
||||
};
|
||||
if lookup_user_id == Some(failed_user_id) {
|
||||
return Err(GatewayError::Internal(
|
||||
"injected test wallet lookup failure".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
if let Some(store) = self.auth_wallet_store.as_ref() {
|
||||
let wallet = {
|
||||
|
||||
@@ -472,6 +472,27 @@ impl AppState {
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn fail_auth_wallet_adjustment_for_tests(
|
||||
mut self,
|
||||
wallet_id: impl Into<String>,
|
||||
) -> Self {
|
||||
self.auth_wallet_adjustment_error_for_tests = Some(wallet_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub(crate) fn fail_auth_wallet_lookup_for_tests(mut self, user_id: impl Into<String>) -> Self {
|
||||
self.auth_wallet_lookup_error_for_tests = Some(user_id.into());
|
||||
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>,
|
||||
|
||||
@@ -21,5 +21,6 @@ mod system;
|
||||
mod system_import;
|
||||
mod usage;
|
||||
mod users;
|
||||
mod users_batch;
|
||||
mod video_tasks;
|
||||
mod wallets;
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use aether_data::repository::management_tokens::InMemoryManagementTokenRepository;
|
||||
use aether_data::repository::users::{InMemoryUserReadRepository, StoredUserAuthRecord};
|
||||
use aether_data::repository::wallet::StoredWalletSnapshot;
|
||||
use axum::http::StatusCode;
|
||||
use chrono::Utc;
|
||||
use reqwest::{Client, RequestBuilder, Response};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use super::super::{
|
||||
build_router_with_state, hash_management_token, sample_management_token, start_server, AppState,
|
||||
};
|
||||
use crate::data::GatewayDataState;
|
||||
|
||||
fn admin_headers(request: RequestBuilder) -> RequestBuilder {
|
||||
request
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.header(crate::constants::TRUSTED_ADMIN_USER_ID_HEADER, "admin-user")
|
||||
.header(crate::constants::TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
|
||||
.header(
|
||||
crate::constants::TRUSTED_ADMIN_SESSION_ID_HEADER,
|
||||
"session-admin",
|
||||
)
|
||||
}
|
||||
|
||||
fn sample_user(user_id: &str) -> StoredUserAuthRecord {
|
||||
sample_user_with_role(user_id, "user")
|
||||
}
|
||||
|
||||
fn sample_user_with_role(user_id: &str, role: &str) -> StoredUserAuthRecord {
|
||||
StoredUserAuthRecord::new(
|
||||
user_id.to_string(),
|
||||
Some(format!("{user_id}@example.com")),
|
||||
true,
|
||||
user_id.to_string(),
|
||||
Some("hash".to_string()),
|
||||
role.to_string(),
|
||||
"local".to_string(),
|
||||
Some(json!(["openai"])),
|
||||
Some(json!(["openai:chat"])),
|
||||
Some(json!(["gpt-4.1"])),
|
||||
true,
|
||||
false,
|
||||
Some(Utc::now()),
|
||||
Some(Utc::now()),
|
||||
)
|
||||
.expect("test user should build")
|
||||
}
|
||||
|
||||
fn sample_wallet(user_id: &str, balance: f64, gift_balance: f64) -> StoredWalletSnapshot {
|
||||
StoredWalletSnapshot::new(
|
||||
format!("wallet-{user_id}"),
|
||||
Some(user_id.to_string()),
|
||||
None,
|
||||
balance,
|
||||
gift_balance,
|
||||
"finite".to_string(),
|
||||
"USD".to_string(),
|
||||
"active".to_string(),
|
||||
balance.max(0.0),
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1_710_000_000,
|
||||
)
|
||||
.expect("test wallet should build")
|
||||
}
|
||||
|
||||
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()
|
||||
.await
|
||||
.expect("batch request should complete")
|
||||
}
|
||||
|
||||
async fn wallet_detail(client: &Client, gateway_url: &str, user_id: &str) -> Value {
|
||||
admin_headers(client.get(format!("{gateway_url}/api/admin/wallets/wallet-{user_id}")))
|
||||
.send()
|
||||
.await
|
||||
.expect("wallet lookup should complete")
|
||||
.json()
|
||||
.await
|
||||
.expect("wallet response should parse")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_batches_wallet_addition_deduction_and_clamped_deduction_per_user() {
|
||||
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, 3.0),
|
||||
sample_wallet("user-2", 2.0, 1.0),
|
||||
]);
|
||||
let (gateway_url, gateway_handle) = start_server(build_router_with_state(state)).await;
|
||||
let client = Client::new();
|
||||
let selection = json!({ "user_ids": ["user-1", "user-2"] });
|
||||
|
||||
let add_response = post_batch_action(
|
||||
&client,
|
||||
&gateway_url,
|
||||
json!({
|
||||
"selection": selection.clone(),
|
||||
"action": "adjust_wallet_balance",
|
||||
"payload": { "operation": "add", "amount": 5.0 }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(add_response.status(), StatusCode::OK);
|
||||
let add_result: Value = add_response.json().await.expect("response should parse");
|
||||
assert_eq!(add_result["success"], 2);
|
||||
assert_eq!(add_result["failed"], 0);
|
||||
assert_eq!(add_result["modified_fields"], json!(["wallet_balance"]));
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
|
||||
18.0
|
||||
);
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-2").await["balance"],
|
||||
8.0
|
||||
);
|
||||
|
||||
let deduct_response = post_batch_action(
|
||||
&client,
|
||||
&gateway_url,
|
||||
json!({
|
||||
"selection": selection.clone(),
|
||||
"action": "adjust_wallet_balance",
|
||||
"payload": { "operation": "deduct", "amount": 4.0 }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(deduct_response.status(), StatusCode::OK);
|
||||
let deduct_result: Value = deduct_response.json().await.expect("response should parse");
|
||||
assert_eq!(deduct_result["success"], 2);
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
|
||||
14.0
|
||||
);
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-2").await["balance"],
|
||||
4.0
|
||||
);
|
||||
|
||||
let over_deduct_response = post_batch_action(
|
||||
&client,
|
||||
&gateway_url,
|
||||
json!({
|
||||
"selection": selection,
|
||||
"action": "adjust_wallet_balance",
|
||||
"payload": { "operation": "deduct", "amount": 100.0 }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(over_deduct_response.status(), StatusCode::OK);
|
||||
let over_deduct_result: Value = over_deduct_response
|
||||
.json()
|
||||
.await
|
||||
.expect("response should parse");
|
||||
assert_eq!(over_deduct_result["success"], 2);
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
|
||||
0.0
|
||||
);
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-2").await["balance"],
|
||||
0.0
|
||||
);
|
||||
|
||||
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";
|
||||
let wallet_write_token = "ae-batch-users-wallet-write";
|
||||
let wallet_admin_token = "ae-batch-users-wallet-admin";
|
||||
let token_owner = sample_user_with_role("token-owner", "admin");
|
||||
let target_user = sample_user("user-1");
|
||||
let mut users_only = sample_management_token(
|
||||
"token-users-write-only",
|
||||
&token_owner.id,
|
||||
&token_owner.username,
|
||||
true,
|
||||
);
|
||||
users_only.token.allowed_ips = None;
|
||||
users_only.token.permissions = Some(json!(["admin:users:write"]));
|
||||
let mut users_and_wallets = sample_management_token(
|
||||
"token-users-and-wallet-write",
|
||||
&token_owner.id,
|
||||
&token_owner.username,
|
||||
true,
|
||||
);
|
||||
users_and_wallets.token.allowed_ips = None;
|
||||
users_and_wallets.token.permissions = Some(json!(["admin:users:write", "admin:wallets:write"]));
|
||||
let mut wallets_admin = sample_management_token(
|
||||
"token-users-wallet-admin",
|
||||
&token_owner.id,
|
||||
&token_owner.username,
|
||||
true,
|
||||
);
|
||||
wallets_admin.token.allowed_ips = None;
|
||||
wallets_admin.token.permissions = Some(json!(["admin:users:write", "admin:wallets:admin"]));
|
||||
let token_repository = Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
||||
vec![users_only, users_and_wallets, wallets_admin],
|
||||
vec![
|
||||
(
|
||||
hash_management_token(users_write_token),
|
||||
"token-users-write-only".to_string(),
|
||||
),
|
||||
(
|
||||
hash_management_token(wallet_write_token),
|
||||
"token-users-and-wallet-write".to_string(),
|
||||
),
|
||||
(
|
||||
hash_management_token(wallet_admin_token),
|
||||
"token-users-wallet-admin".to_string(),
|
||||
),
|
||||
],
|
||||
));
|
||||
let user_repository = Arc::new(InMemoryUserReadRepository::seed_auth_users(vec![
|
||||
token_owner.clone(),
|
||||
target_user.clone(),
|
||||
]));
|
||||
let data = GatewayDataState::with_management_token_repository_for_tests(token_repository)
|
||||
.with_user_reader(user_repository);
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_data_state_for_tests(data)
|
||||
.with_auth_users_for_tests([token_owner, target_user])
|
||||
.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 payload = json!({
|
||||
"selection": { "user_ids": ["user-1"] },
|
||||
"action": "adjust_wallet_balance",
|
||||
"payload": { "operation": "add", "amount": 5.0 },
|
||||
"idempotency_key": "wallet-write-batch"
|
||||
});
|
||||
|
||||
let denied = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(users_write_token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("users-only management token request should complete");
|
||||
assert_eq!(denied.status(), StatusCode::FORBIDDEN);
|
||||
let denied_payload: Value = denied.json().await.expect("response should parse");
|
||||
assert_eq!(
|
||||
denied_payload["required_permissions"],
|
||||
json!(["admin:wallets:write", "admin:wallets:admin"])
|
||||
);
|
||||
assert_eq!(denied_payload["permission_mode"], "any_of");
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
|
||||
10.0
|
||||
);
|
||||
|
||||
let allowed = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(wallet_write_token)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.expect("wallet-write management token request should complete");
|
||||
assert_eq!(allowed.status(), StatusCode::OK);
|
||||
let result: Value = allowed.json().await.expect("response should parse");
|
||||
assert_eq!(result["success"], 1);
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
|
||||
15.0
|
||||
);
|
||||
|
||||
let allowed_with_wallet_admin = client
|
||||
.post(format!("{gateway_url}/api/admin/users/batch-action"))
|
||||
.header(crate::constants::GATEWAY_HEADER, "rust-phase3b")
|
||||
.bearer_auth(wallet_admin_token)
|
||||
.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");
|
||||
assert_eq!(allowed_with_wallet_admin.status(), StatusCode::OK);
|
||||
let result: Value = allowed_with_wallet_admin
|
||||
.json()
|
||||
.await
|
||||
.expect("response should parse");
|
||||
assert_eq!(result["success"], 1);
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
|
||||
20.0
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_reports_completed_uncertain_and_unprocessed_users_after_adjustment_error() {
|
||||
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_adjustment_for_tests("wallet-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": "adjust_wallet_balance",
|
||||
"payload": { "operation": "add", "amount": 5.0 }
|
||||
}),
|
||||
)
|
||||
.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["success"], 1);
|
||||
assert_eq!(result["failed"], 2);
|
||||
assert_eq!(result["completed_user_ids"], json!(["user-1"]));
|
||||
assert_eq!(result["uncertain_user_ids"], json!(["user-2"]));
|
||||
assert_eq!(result["unprocessed_user_ids"], json!(["user-3"]));
|
||||
assert_eq!(result["failures"][0]["user_id"], "user-2");
|
||||
assert_eq!(result["failures"][1]["user_id"], "user-3");
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
|
||||
15.0
|
||||
);
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-2").await["balance"],
|
||||
20.0
|
||||
);
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-3").await["balance"],
|
||||
30.0
|
||||
);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_reports_wallet_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": "adjust_wallet_balance",
|
||||
"payload": { "operation": "add", "amount": 5.0 }
|
||||
}),
|
||||
)
|
||||
.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"]));
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-1").await["balance"],
|
||||
15.0
|
||||
);
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-2").await["balance"],
|
||||
20.0
|
||||
);
|
||||
assert_eq!(
|
||||
wallet_detail(&client, &gateway_url, "user-3").await["balance"],
|
||||
30.0
|
||||
);
|
||||
|
||||
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_floors_negative_balance_on_deduction() {
|
||||
let state = AppState::new()
|
||||
.expect("gateway should build")
|
||||
.with_auth_users_for_tests([sample_user("user-negative"), sample_user("user-no-wallet")])
|
||||
.with_auth_wallets_for_tests([sample_wallet("user-negative", -2.0, 1.0)]);
|
||||
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-negative", "user-no-wallet"] },
|
||||
"action": "adjust_wallet_balance",
|
||||
"payload": { "operation": "deduct", "amount": 10.0 }
|
||||
}),
|
||||
)
|
||||
.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["failures"][0]["user_id"], "user-no-wallet");
|
||||
assert_eq!(result["failures"][0]["reason"], "用户钱包不可用");
|
||||
|
||||
let wallet = wallet_detail(&client, &gateway_url, "user-negative").await;
|
||||
assert_eq!(wallet["balance"], 0.0);
|
||||
assert_eq!(wallet["total_adjusted"], 1.0);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_rejects_zero_and_non_finite_batch_wallet_adjustments() {
|
||||
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 zero_response = post_batch_action(
|
||||
&client,
|
||||
&gateway_url,
|
||||
json!({
|
||||
"selection": { "user_ids": ["user-1"] },
|
||||
"action": "adjust_wallet_balance",
|
||||
"payload": { "operation": "add", "amount": 0.0 }
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(zero_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
let non_finite_response = admin_headers(
|
||||
client.post(format!("{gateway_url}/api/admin/users/batch-action")),
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
||||
.body(
|
||||
r#"{"selection":{"user_ids":["user-1"]},"action":"adjust_wallet_balance","payload":{"operation":"add","amount":1e999}}"#,
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.expect("non-finite amount request should complete");
|
||||
assert_eq!(non_finite_response.status(), StatusCode::BAD_REQUEST);
|
||||
|
||||
gateway_handle.abort();
|
||||
}
|
||||
Reference in New Issue
Block a user