mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +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:
@@ -603,6 +603,13 @@ jobs:
|
|||||||
AETHER_TEST_DATABASE_URL: postgres://aether:[email protected]:5432/aether_test
|
AETHER_TEST_DATABASE_URL: postgres://aether:[email protected]:5432/aether_test
|
||||||
run: cargo test -p aether-data-postgres live_payment_callback --lib -- --ignored --nocapture
|
run: cargo test -p aether-data-postgres live_payment_callback --lib -- --ignored --nocapture
|
||||||
|
|
||||||
|
- name: Run Postgres batch wallet deduction regression
|
||||||
|
env:
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
SCCACHE_GHA_ENABLED: "true"
|
||||||
|
AETHER_TEST_DATABASE_URL: postgres://aether:[email protected]:5432/aether_test
|
||||||
|
run: cargo test -p aether-data-postgres live_bulk_wallet_adjustment_persists_actual_delta_and_skips_zero_ledger --lib -- --ignored --nocapture
|
||||||
|
|
||||||
- name: Run Postgres API key lifecycle tests
|
- name: Run Postgres API key lifecycle tests
|
||||||
env:
|
env:
|
||||||
RUSTC_WRAPPER: sccache
|
RUSTC_WRAPPER: sccache
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ pub(crate) use aether_data::repository::users::{
|
|||||||
StoredUserPreferenceRecord, StoredUserSessionRecord,
|
StoredUserPreferenceRecord, StoredUserSessionRecord,
|
||||||
};
|
};
|
||||||
use aether_data::repository::wallet::{
|
use aether_data::repository::wallet::{
|
||||||
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
|
AdjustWalletBalanceInBatchInput, AdjustWalletBalanceInput, AdminPaymentOrderListQuery,
|
||||||
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
|
AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery,
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome, AdminWalletLedgerQuery, AdminWalletListQuery,
|
||||||
AdminWalletRefundRequestListQuery, CompareAndSwapPaymentOrderStripeClientSecretInput,
|
AdminWalletRefundRequestListQuery, CompareAndSwapPaymentOrderStripeClientSecretInput,
|
||||||
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
|
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
|
||||||
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
|
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
|
||||||
@@ -72,13 +73,14 @@ use aether_data::repository::wallet::{
|
|||||||
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
|
CreateWalletRefundRequestOutcome, CreditAdminPaymentOrderInput,
|
||||||
DeleteAdminRedeemCodeBatchInput, DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput,
|
DeleteAdminRedeemCodeBatchInput, DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput,
|
||||||
FailAdminWalletRefundInput, FailWalletRechargeCheckoutInput, InitializeAuthWalletOutcome,
|
FailAdminWalletRefundInput, FailWalletRechargeCheckoutInput, InitializeAuthWalletOutcome,
|
||||||
|
PrepareAdminUserWalletBalanceBatchInput, PrepareAdminUserWalletBalanceBatchOutcome,
|
||||||
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
||||||
ReclaimWalletRechargeCheckoutInput, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
ReclaimWalletRechargeCheckoutInput, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
||||||
StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder,
|
StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder,
|
||||||
StoredAdminPaymentOrderPage, StoredAdminRedeemCode, StoredAdminRedeemCodeBatch,
|
StoredAdminPaymentOrderPage, StoredAdminRedeemCode, StoredAdminRedeemCodeBatch,
|
||||||
StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminWalletLedgerPage,
|
StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminUserWalletBalanceBatch,
|
||||||
StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
|
StoredAdminWalletLedgerPage, StoredAdminWalletListPage, StoredAdminWalletRefund,
|
||||||
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
||||||
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
||||||
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput,
|
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput,
|
||||||
UpdateWalletRechargeCheckoutInput, WalletLookupKey, WalletMutationOutcome,
|
UpdateWalletRechargeCheckoutInput, WalletLookupKey, WalletMutationOutcome,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use super::{
|
use super::{
|
||||||
read_decision_trace, read_provider_transport_snapshot, read_request_candidate_trace,
|
read_decision_trace, read_provider_transport_snapshot, read_request_candidate_trace,
|
||||||
AdjustWalletBalanceInput, AdminBillingCollectorRecord, AdminBillingCollectorWriteInput,
|
AdjustWalletBalanceInBatchInput, AdjustWalletBalanceInput, AdminBillingCollectorRecord,
|
||||||
AdminBillingMutationOutcome, AdminBillingPresetApplyResult, AdminBillingRuleRecord,
|
AdminBillingCollectorWriteInput, AdminBillingMutationOutcome, AdminBillingPresetApplyResult,
|
||||||
AdminBillingRuleWriteInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
|
AdminBillingRuleRecord, AdminBillingRuleWriteInput, AdminPaymentOrderListQuery,
|
||||||
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
|
AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery,
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome, AdminWalletLedgerQuery, AdminWalletListQuery,
|
||||||
AdminWalletRefundRequestListQuery, AnnouncementListQuery, AuditLogListQuery,
|
AdminWalletRefundRequestListQuery, AnnouncementListQuery, AuditLogListQuery,
|
||||||
BackgroundTaskListQuery, BackgroundTaskSummary, BillingModelContextCacheKey,
|
BackgroundTaskListQuery, BackgroundTaskSummary, BillingModelContextCacheKey,
|
||||||
BillingModelContextCacheState, BillingModelContextInflightState, BillingPlanRecord,
|
BillingModelContextCacheState, BillingModelContextInflightState, BillingPlanRecord,
|
||||||
@@ -17,24 +18,25 @@ use super::{
|
|||||||
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
|
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
|
||||||
FailWalletRechargeCheckoutInput, GatewayDataState, GatewayProviderTransportSnapshot,
|
FailWalletRechargeCheckoutInput, GatewayDataState, GatewayProviderTransportSnapshot,
|
||||||
LocalVideoTaskReadResponse, PaymentGatewayConfigCasWriteInput, PaymentGatewayConfigRecord,
|
LocalVideoTaskReadResponse, PaymentGatewayConfigCasWriteInput, PaymentGatewayConfigRecord,
|
||||||
PaymentGatewayConfigWriteInput, PaymentGatewaySecretCasUpdate, ProcessAdminWalletRefundInput,
|
PaymentGatewayConfigWriteInput, PaymentGatewaySecretCasUpdate,
|
||||||
ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome, ReclaimWalletRechargeCheckoutInput,
|
PrepareAdminUserWalletBalanceBatchInput, PrepareAdminUserWalletBalanceBatchOutcome,
|
||||||
ReconcileUsagePolicyCostInput, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
||||||
ReleaseUsagePolicyRequestAdmissionInput, RequestAuditBundle, RequestCandidateTrace,
|
ReclaimWalletRechargeCheckoutInput, ReconcileUsagePolicyCostInput, RedeemWalletCodeInput,
|
||||||
ReserveUsagePolicyCostInput, ReserveUsagePolicyCostOutcome, ReserveUsagePolicyRequestInput,
|
RedeemWalletCodeOutcome, ReleaseUsagePolicyRequestAdmissionInput, RequestAuditBundle,
|
||||||
ReserveUsagePolicyRequestOutcome, StoredAdminAuditLogPage, StoredAdminPaymentCallbackPage,
|
RequestCandidateTrace, ReserveUsagePolicyCostInput, ReserveUsagePolicyCostOutcome,
|
||||||
StoredAdminPaymentOrder, StoredAdminPaymentOrderPage, StoredAdminRedeemCodeBatch,
|
ReserveUsagePolicyRequestInput, ReserveUsagePolicyRequestOutcome, StoredAdminAuditLogPage,
|
||||||
StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminWalletLedgerPage,
|
StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder, StoredAdminPaymentOrderPage,
|
||||||
StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
|
StoredAdminRedeemCodeBatch, StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage,
|
||||||
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
StoredAdminUserWalletBalanceBatch, StoredAdminWalletLedgerPage, StoredAdminWalletListPage,
|
||||||
StoredAdminWalletTransactionPage, StoredAnnouncement, StoredAnnouncementPage,
|
StoredAdminWalletRefund, StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestPage,
|
||||||
StoredBackgroundTaskEvent, StoredBackgroundTaskRun, StoredBackgroundTaskRunPage,
|
StoredAdminWalletTransaction, StoredAdminWalletTransactionPage, StoredAnnouncement,
|
||||||
StoredBillingModelContext, StoredProviderQuotaSnapshot, StoredProviderUsageSummary,
|
StoredAnnouncementPage, StoredBackgroundTaskEvent, StoredBackgroundTaskRun,
|
||||||
StoredRequestUsageAudit, StoredSuspiciousActivity, StoredUsagePolicyCostReservation,
|
StoredBackgroundTaskRunPage, StoredBillingModelContext, StoredProviderQuotaSnapshot,
|
||||||
StoredUsagePolicyRequestAdmission, StoredUsageSettlement, StoredUserAuditLogPage,
|
StoredProviderUsageSummary, StoredRequestUsageAudit, StoredSuspiciousActivity,
|
||||||
StoredUserAuthRecord, StoredUserExportRow, StoredUserSummary, StoredVideoTask,
|
StoredUsagePolicyCostReservation, StoredUsagePolicyRequestAdmission, StoredUsageSettlement,
|
||||||
StoredWalletDailyUsageLedger, StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot,
|
StoredUserAuditLogPage, StoredUserAuthRecord, StoredUserExportRow, StoredUserSummary,
|
||||||
UpdateAdminWalletRefundGatewayInput, UpdateAnnouncementRecord,
|
StoredVideoTask, StoredWalletDailyUsageLedger, StoredWalletDailyUsageLedgerPage,
|
||||||
|
StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput, UpdateAnnouncementRecord,
|
||||||
UpdateWalletRechargeCheckoutInput, UpsertBackgroundTaskEvent, UpsertBackgroundTaskRun,
|
UpdateWalletRechargeCheckoutInput, UpsertBackgroundTaskEvent, UpsertBackgroundTaskRun,
|
||||||
UpsertUsageRecord, UpsertVideoTask, UsageSettlementInput, UserDailyQuotaAvailabilityRecord,
|
UpsertUsageRecord, UpsertVideoTask, UsageSettlementInput, UserDailyQuotaAvailabilityRecord,
|
||||||
UserPlanEntitlementRecord, VideoTaskLookupKey, VideoTaskModelCount, VideoTaskQueryFilter,
|
UserPlanEntitlementRecord, VideoTaskLookupKey, VideoTaskModelCount, VideoTaskQueryFilter,
|
||||||
@@ -1066,13 +1068,81 @@ impl GatewayDataState {
|
|||||||
pub(crate) async fn adjust_wallet_balance(
|
pub(crate) async fn adjust_wallet_balance(
|
||||||
&self,
|
&self,
|
||||||
input: AdjustWalletBalanceInput,
|
input: AdjustWalletBalanceInput,
|
||||||
) -> Result<Option<(StoredWalletSnapshot, StoredAdminWalletTransaction)>, DataLayerError> {
|
) -> Result<Option<(StoredWalletSnapshot, Option<StoredAdminWalletTransaction>)>, DataLayerError>
|
||||||
|
{
|
||||||
match &self.wallet_writer {
|
match &self.wallet_writer {
|
||||||
Some(repository) => repository.adjust_wallet_balance(input).await,
|
Some(repository) => repository.adjust_wallet_balance(input).await,
|
||||||
None => Ok(None),
|
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(
|
pub(crate) async fn create_manual_wallet_recharge(
|
||||||
&self,
|
&self,
|
||||||
input: CreateManualWalletRechargeInput,
|
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 operator_id = admin_wallet_operator_id(request_context);
|
||||||
let has_wallet_writer = state.has_wallet_data_writer();
|
let has_wallet_writer = state.has_wallet_data_writer();
|
||||||
let Some((wallet, transaction)) = state
|
let Some((wallet, Some(transaction))) = state
|
||||||
.admin_adjust_wallet_balance(
|
.admin_adjust_wallet_balance(
|
||||||
&wallet_id,
|
&wallet_id,
|
||||||
amount_usd,
|
amount_usd,
|
||||||
&balance_type,
|
&balance_type,
|
||||||
operator_id.as_deref(),
|
operator_id.as_deref(),
|
||||||
description.as_deref(),
|
description.as_deref(),
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -387,10 +387,11 @@ impl<'a> AdminAppState<'a> {
|
|||||||
balance_type: &str,
|
balance_type: &str,
|
||||||
operator_id: Option<&str>,
|
operator_id: Option<&str>,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
clamp_deduction_to_available_balance: bool,
|
||||||
) -> Result<
|
) -> Result<
|
||||||
Option<(
|
Option<(
|
||||||
aether_data::repository::wallet::StoredWalletSnapshot,
|
aether_data::repository::wallet::StoredWalletSnapshot,
|
||||||
crate::AdminWalletTransactionRecord,
|
Option<crate::AdminWalletTransactionRecord>,
|
||||||
)>,
|
)>,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
> {
|
> {
|
||||||
@@ -401,6 +402,7 @@ impl<'a> AdminAppState<'a> {
|
|||||||
balance_type,
|
balance_type,
|
||||||
operator_id,
|
operator_id,
|
||||||
description,
|
description,
|
||||||
|
clamp_deduction_to_available_balance,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,64 @@ impl<'a> AdminAppState<'a> {
|
|||||||
pub(crate) fn cloned_app(&self) -> AppState {
|
pub(crate) fn cloned_app(&self) -> AppState {
|
||||||
self.app.clone()
|
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> {
|
impl<'a> AsRef<AppState> for AdminAppState<'a> {
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
use super::{
|
use super::{
|
||||||
build_admin_users_bad_request_response, build_admin_users_permission_denied_response,
|
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,
|
management_token_may_administer_user_accounts, normalize_admin_user_role,
|
||||||
};
|
};
|
||||||
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
|
||||||
use crate::handlers::admin::shared::attach_admin_audit_response;
|
use crate::handlers::admin::shared::attach_admin_audit_response;
|
||||||
use crate::GatewayError;
|
use crate::GatewayError;
|
||||||
|
use aether_data::repository::wallet::{
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome, PrepareAdminUserWalletBalanceBatchOutcome,
|
||||||
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
body::{Body, Bytes},
|
body::{Body, Bytes},
|
||||||
http,
|
http,
|
||||||
@@ -13,9 +18,10 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use sha2::{Digest as _, Sha256};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, serde::Deserialize)]
|
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
|
||||||
struct AdminUserSelectionFilters {
|
struct AdminUserSelectionFilters {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
search: Option<String>,
|
search: Option<String>,
|
||||||
@@ -27,7 +33,7 @@ struct AdminUserSelectionFilters {
|
|||||||
group_id: Option<String>,
|
group_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||||
struct AdminUserSelectionRequest {
|
struct AdminUserSelectionRequest {
|
||||||
user_ids: Vec<String>,
|
user_ids: Vec<String>,
|
||||||
group_ids: Vec<String>,
|
group_ids: Vec<String>,
|
||||||
@@ -40,6 +46,7 @@ struct AdminUserBatchActionRequest {
|
|||||||
selection: AdminUserSelectionRequest,
|
selection: AdminUserSelectionRequest,
|
||||||
action: String,
|
action: String,
|
||||||
payload: Option<Value>,
|
payload: Option<Value>,
|
||||||
|
idempotency_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
@@ -48,6 +55,8 @@ struct RawAdminUserBatchActionRequest {
|
|||||||
action: String,
|
action: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
payload: Option<Value>,
|
payload: Option<Value>,
|
||||||
|
#[serde(default)]
|
||||||
|
idempotency_key: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -68,7 +77,7 @@ struct AdminUserSelectionItem {
|
|||||||
matched_by: Vec<String>,
|
matched_by: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||||
struct AdminUserSelectionWarning {
|
struct AdminUserSelectionWarning {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
warning_type: String,
|
warning_type: String,
|
||||||
@@ -88,6 +97,7 @@ struct AdminUserBatchMutation {
|
|||||||
role: Option<String>,
|
role: Option<String>,
|
||||||
is_active: Option<bool>,
|
is_active: Option<bool>,
|
||||||
unlimited: Option<bool>,
|
unlimited: Option<bool>,
|
||||||
|
wallet_balance_adjustment: Option<AdminUserWalletBalanceAdjustment>,
|
||||||
modified_fields: Vec<&'static str>,
|
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(
|
pub(in super::super) async fn build_admin_resolve_user_selection_response(
|
||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
_request_context: &AdminRequestContext<'_>,
|
_request_context: &AdminRequestContext<'_>,
|
||||||
@@ -128,10 +160,29 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
|
|||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
|
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,
|
Ok(value) => value,
|
||||||
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
|
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 {
|
let resolved = match resolve_admin_user_selection(state, request.selection).await {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(detail) => return Ok(build_admin_user_batch_bad_request_response(detail)),
|
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()
|
.iter()
|
||||||
.map(|user_id| json!({ "user_id": user_id, "reason": "用户不存在或已删除" }))
|
.map(|user_id| json!({ "user_id": user_id, "reason": "用户不存在或已删除" }))
|
||||||
.collect::<Vec<_>>();
|
.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 {
|
for (item_index, item) in resolved.items.iter().enumerate() {
|
||||||
if state.find_user_auth_by_id(&item.user_id).await?.is_none() {
|
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!({
|
failures.push(json!({
|
||||||
"user_id": item.user_id,
|
"user_id": item.user_id,
|
||||||
"reason": "用户不存在或已删除",
|
"reason": "用户不存在或已删除",
|
||||||
@@ -202,17 +273,92 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(unlimited) = mutation.unlimited {
|
if let Some(unlimited) = mutation.unlimited {
|
||||||
if !apply_batch_user_wallet_limit_mode(state, &item.user_id, unlimited).await? {
|
match apply_batch_user_wallet_limit_mode(state, &item.user_id, unlimited).await {
|
||||||
failures.push(json!({
|
Ok(true) => {}
|
||||||
"user_id": item.user_id,
|
Ok(false) => {
|
||||||
"reason": "用户钱包不可用",
|
failures.push(json!({
|
||||||
}));
|
"user_id": item.user_id,
|
||||||
continue;
|
"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()
|
if let Some(adjustment) = mutation.wallet_balance_adjustment {
|
||||||
&& state
|
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(
|
.update_local_auth_user_admin_fields(
|
||||||
&item.user_id,
|
&item.user_id,
|
||||||
mutation.role.clone(),
|
mutation.role.clone(),
|
||||||
@@ -226,22 +372,39 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
|
|||||||
None,
|
None,
|
||||||
mutation.is_active,
|
mutation.is_active,
|
||||||
)
|
)
|
||||||
.await?
|
.await
|
||||||
.is_none()
|
{
|
||||||
{
|
Ok(user) => user,
|
||||||
failures.push(json!({
|
Err(_) => {
|
||||||
"user_id": item.user_id,
|
record_batch_action_interruption(
|
||||||
"reason": "用户不存在或已删除",
|
&resolved.items,
|
||||||
}));
|
item_index,
|
||||||
continue;
|
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;
|
success += 1;
|
||||||
|
completed_user_ids.push(item.user_id.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
let failed = failures.len();
|
let failed = failures.len();
|
||||||
let total = success + failed;
|
let total = success + failed;
|
||||||
let response = Json(json!({
|
let mut response_payload = json!({
|
||||||
"total": total,
|
"total": total,
|
||||||
"success": success,
|
"success": success,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
@@ -249,8 +412,14 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
|
|||||||
"warnings": resolved.warnings,
|
"warnings": resolved.warnings,
|
||||||
"action": request.action.trim().to_ascii_lowercase(),
|
"action": request.action.trim().to_ascii_lowercase(),
|
||||||
"modified_fields": mutation.modified_fields,
|
"modified_fields": mutation.modified_fields,
|
||||||
}))
|
"interrupted": interrupted,
|
||||||
.into_response();
|
});
|
||||||
|
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(
|
Ok(attach_admin_audit_response(
|
||||||
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(
|
fn parse_resolve_selection_request(
|
||||||
request_body: Option<&Bytes>,
|
request_body: Option<&Bytes>,
|
||||||
) -> Result<AdminUserSelectionRequest, String> {
|
) -> Result<AdminUserSelectionRequest, String> {
|
||||||
@@ -286,6 +857,7 @@ fn parse_batch_action_request(
|
|||||||
selection: parse_selection_request_value(raw.selection)?,
|
selection: parse_selection_request_value(raw.selection)?,
|
||||||
action: raw.action,
|
action: raw.action,
|
||||||
payload: raw.payload,
|
payload: raw.payload,
|
||||||
|
idempotency_key: raw.idempotency_key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
_ => Err("Invalid JSON request body".to_string()),
|
_ => Err("Invalid JSON request body".to_string()),
|
||||||
@@ -604,10 +1176,37 @@ fn parse_batch_mutation(
|
|||||||
}),
|
}),
|
||||||
"update_access_control" => parse_access_control_mutation(payload),
|
"update_access_control" => parse_access_control_mutation(payload),
|
||||||
"update_role" => parse_role_mutation(payload),
|
"update_role" => parse_role_mutation(payload),
|
||||||
|
"adjust_wallet_balance" => parse_wallet_balance_adjustment_mutation(payload),
|
||||||
_ => Err("不支持的批量操作".to_string()),
|
_ => 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> {
|
fn parse_role_mutation(payload: Option<Value>) -> Result<AdminUserBatchMutation, String> {
|
||||||
let Some(Value::Object(payload)) = payload else {
|
let Some(Value::Object(payload)) = payload else {
|
||||||
return Err("payload 必须是对象".to_string());
|
return Err("payload 必须是对象".to_string());
|
||||||
@@ -700,30 +1299,68 @@ async fn apply_batch_user_wallet_limit_mode(
|
|||||||
state: &AdminAppState<'_>,
|
state: &AdminAppState<'_>,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
unlimited: bool,
|
unlimited: bool,
|
||||||
) -> Result<bool, GatewayError> {
|
) -> Result<bool, AdminBatchWalletLimitModeError> {
|
||||||
let desired_limit_mode = if unlimited { "unlimited" } else { "finite" };
|
let desired_limit_mode = if unlimited { "unlimited" } else { "finite" };
|
||||||
match state
|
let wallet = state
|
||||||
.find_wallet(aether_data::repository::wallet::WalletLookupKey::UserId(
|
.find_wallet(aether_data::repository::wallet::WalletLookupKey::UserId(
|
||||||
user_id,
|
user_id,
|
||||||
))
|
))
|
||||||
.await?
|
.await
|
||||||
{
|
.map_err(|_| AdminBatchWalletLimitModeError::WalletLookup)?;
|
||||||
|
match wallet {
|
||||||
Some(wallet) => {
|
Some(wallet) => {
|
||||||
if wallet.limit_mode.eq_ignore_ascii_case(desired_limit_mode) {
|
if wallet.limit_mode.eq_ignore_ascii_case(desired_limit_mode) {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
Ok(state
|
Ok(state
|
||||||
.update_auth_user_wallet_limit_mode(user_id, desired_limit_mode)
|
.update_auth_user_wallet_limit_mode(user_id, desired_limit_mode)
|
||||||
.await?
|
.await
|
||||||
|
.map_err(|_| AdminBatchWalletLimitModeError::Mutation)?
|
||||||
.is_some())
|
.is_some())
|
||||||
}
|
}
|
||||||
None => Ok(state
|
None => Ok(state
|
||||||
.initialize_auth_user_wallet(user_id, 0.0, unlimited)
|
.initialize_auth_user_wallet(user_id, 0.0, unlimited)
|
||||||
.await?
|
.await
|
||||||
|
.map_err(|_| AdminBatchWalletLimitModeError::Mutation)?
|
||||||
.is_some()),
|
.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> {
|
fn build_admin_user_batch_bad_request_response(detail: String) -> Response<Body> {
|
||||||
if detail.as_str() == "缺少 user_id" {
|
if detail.as_str() == "缺少 user_id" {
|
||||||
return build_admin_users_bad_request_response("缺少 user_id");
|
return build_admin_users_bad_request_response("缺少 user_id");
|
||||||
|
|||||||
@@ -49,12 +49,14 @@ use self::shared::AdminUpdateUserPatch;
|
|||||||
use self::shared::{
|
use self::shared::{
|
||||||
admin_default_user_initial_gift, build_admin_users_bad_request_response,
|
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_data_unavailable_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,
|
||||||
format_optional_datetime_iso8601, legacy_admin_list_policy_mode,
|
disabled_user_policy_detail, disabled_user_policy_field, format_optional_datetime_iso8601,
|
||||||
legacy_admin_rate_limit_policy_mode, management_token_may_administer_user_accounts,
|
legacy_admin_list_policy_mode, legacy_admin_rate_limit_policy_mode,
|
||||||
normalize_admin_optional_user_email, normalize_admin_user_group_ids, normalize_admin_user_role,
|
management_token_may_adjust_admin_wallet_balance,
|
||||||
normalize_admin_username, validate_admin_user_password, AdminCreateUserApiKeyRequest,
|
management_token_may_administer_user_accounts, normalize_admin_optional_user_email,
|
||||||
AdminCreateUserRequest, AdminToggleUserApiKeyLockRequest, AdminUpdateUserApiKeyRequest,
|
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::{
|
pub(crate) use self::shared::{
|
||||||
normalize_admin_list_policy_mode, normalize_admin_rate_limit_policy_mode,
|
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(
|
pub(super) fn build_admin_users_permission_denied_response(
|
||||||
request_context: &crate::handlers::admin::request::AdminRequestContext<'_>,
|
request_context: &crate::handlers::admin::request::AdminRequestContext<'_>,
|
||||||
) -> Response<Body> {
|
) -> 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(
|
pub(super) fn normalize_admin_optional_user_email(
|
||||||
value: Option<&str>,
|
value: Option<&str>,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<Option<String>, String> {
|
||||||
@@ -397,9 +437,52 @@ pub(super) fn format_optional_datetime_iso8601(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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;
|
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]
|
#[test]
|
||||||
fn admin_user_api_formats_accept_current_canonical_signatures() {
|
fn admin_user_api_formats_accept_current_canonical_signatures() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -492,6 +492,25 @@ pub struct AppState {
|
|||||||
Arc<StdMutex<HashMap<String, aether_data::repository::wallet::StoredWalletSnapshot>>>,
|
Arc<StdMutex<HashMap<String, aether_data::repository::wallet::StoredWalletSnapshot>>>,
|
||||||
>,
|
>,
|
||||||
#[cfg(test)]
|
#[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:
|
pub(crate) admin_wallet_payment_order_store:
|
||||||
Option<Arc<StdMutex<HashMap<String, AdminWalletPaymentOrderRecord>>>>,
|
Option<Arc<StdMutex<HashMap<String, AdminWalletPaymentOrderRecord>>>>,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -471,6 +471,16 @@ impl AppState {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
auth_wallet_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
|
auth_wallet_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
|
||||||
#[cfg(test)]
|
#[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()))),
|
admin_wallet_payment_order_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
admin_payment_callback_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
|
admin_payment_callback_store: Some(Arc::new(StdMutex::new(HashMap::new()))),
|
||||||
|
|||||||
@@ -1,8 +1,198 @@
|
|||||||
use crate::{AdminWalletPaymentOrderRecord, AdminWalletTransactionRecord, AppState, GatewayError};
|
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;
|
use super::admin_wallet_build_order_no;
|
||||||
|
|
||||||
impl AppState {
|
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(
|
pub(crate) async fn admin_adjust_wallet_balance(
|
||||||
&self,
|
&self,
|
||||||
wallet_id: &str,
|
wallet_id: &str,
|
||||||
@@ -10,13 +200,21 @@ impl AppState {
|
|||||||
balance_type: &str,
|
balance_type: &str,
|
||||||
operator_id: Option<&str>,
|
operator_id: Option<&str>,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
clamp_deduction_to_available_balance: bool,
|
||||||
) -> Result<
|
) -> Result<
|
||||||
Option<(
|
Option<(
|
||||||
aether_data::repository::wallet::StoredWalletSnapshot,
|
aether_data::repository::wallet::StoredWalletSnapshot,
|
||||||
AdminWalletTransactionRecord,
|
Option<AdminWalletTransactionRecord>,
|
||||||
)>,
|
)>,
|
||||||
GatewayError,
|
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)]
|
#[cfg(test)]
|
||||||
if let Some(store) = self.auth_wallet_store.as_ref() {
|
if let Some(store) = self.auth_wallet_store.as_ref() {
|
||||||
let mut guard = store.lock().expect("auth wallet store should lock");
|
let mut guard = store.lock().expect("auth wallet store should lock");
|
||||||
@@ -27,6 +225,18 @@ impl AppState {
|
|||||||
let before_recharge = wallet.balance;
|
let before_recharge = wallet.balance;
|
||||||
let before_gift = wallet.gift_balance;
|
let before_gift = wallet.gift_balance;
|
||||||
let before_total = before_recharge + before_gift;
|
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_recharge = before_recharge;
|
||||||
let mut after_gift = before_gift;
|
let mut after_gift = before_gift;
|
||||||
|
|
||||||
@@ -90,7 +300,7 @@ impl AppState {
|
|||||||
let updated_wallet = wallet.clone();
|
let updated_wallet = wallet.clone();
|
||||||
drop(guard);
|
drop(guard);
|
||||||
self.invalidate_auth_context_cache();
|
self.invalidate_auth_context_cache();
|
||||||
return Ok(Some((updated_wallet, transaction)));
|
return Ok(Some((updated_wallet, Some(transaction))));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(self
|
Ok(self
|
||||||
@@ -100,10 +310,15 @@ impl AppState {
|
|||||||
balance_type: balance_type.to_string(),
|
balance_type: balance_type.to_string(),
|
||||||
operator_id: operator_id.map(ToOwned::to_owned),
|
operator_id: operator_id.map(ToOwned::to_owned),
|
||||||
description: description.map(ToOwned::to_owned),
|
description: description.map(ToOwned::to_owned),
|
||||||
|
clamp_deduction_to_available_balance,
|
||||||
|
batch_context: None,
|
||||||
})
|
})
|
||||||
.await?
|
.await?
|
||||||
.map(|(wallet, transaction)| {
|
.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<
|
) -> Result<
|
||||||
Option<(
|
Option<(
|
||||||
aether_data::repository::wallet::StoredWalletSnapshot,
|
aether_data::repository::wallet::StoredWalletSnapshot,
|
||||||
aether_data::repository::wallet::StoredAdminWalletTransaction,
|
Option<aether_data::repository::wallet::StoredAdminWalletTransaction>,
|
||||||
)>,
|
)>,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
> {
|
> {
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ impl AppState {
|
|||||||
&self,
|
&self,
|
||||||
lookup: aether_data::repository::wallet::WalletLookupKey<'_>,
|
lookup: aether_data::repository::wallet::WalletLookupKey<'_>,
|
||||||
) -> Result<Option<aether_data::repository::wallet::StoredWalletSnapshot>, GatewayError> {
|
) -> 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)]
|
#[cfg(test)]
|
||||||
if let Some(store) = self.auth_wallet_store.as_ref() {
|
if let Some(store) = self.auth_wallet_store.as_ref() {
|
||||||
let wallet = {
|
let wallet = {
|
||||||
|
|||||||
@@ -472,6 +472,27 @@ impl AppState {
|
|||||||
self
|
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
|
pub(crate) fn with_admin_wallet_payment_orders_for_tests<I>(mut self, orders: I) -> Self
|
||||||
where
|
where
|
||||||
I: IntoIterator<Item = crate::AdminWalletPaymentOrderRecord>,
|
I: IntoIterator<Item = crate::AdminWalletPaymentOrderRecord>,
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ mod system;
|
|||||||
mod system_import;
|
mod system_import;
|
||||||
mod usage;
|
mod usage;
|
||||||
mod users;
|
mod users;
|
||||||
|
mod users_batch;
|
||||||
mod video_tasks;
|
mod video_tasks;
|
||||||
mod wallets;
|
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();
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS public.admin_user_wallet_balance_batches (
|
||||||
|
admin_user_id character varying(64) NOT NULL,
|
||||||
|
idempotency_key character varying(128) NOT NULL,
|
||||||
|
request_fingerprint character varying(64) NOT NULL,
|
||||||
|
target_user_ids jsonb NOT NULL,
|
||||||
|
missing_user_ids jsonb NOT NULL,
|
||||||
|
warnings jsonb NOT NULL,
|
||||||
|
user_outcomes jsonb NOT NULL,
|
||||||
|
created_at_unix_secs bigint NOT NULL,
|
||||||
|
updated_at_unix_secs bigint NOT NULL,
|
||||||
|
CONSTRAINT admin_user_wallet_balance_batches_pkey PRIMARY KEY (admin_user_id, idempotency_key)
|
||||||
|
);
|
||||||
@@ -24,8 +24,9 @@ use aether_data_contracts::repository::wallet::{
|
|||||||
wallet_recharge_checkout_failed_response, wallet_recharge_checkout_uncertain_response,
|
wallet_recharge_checkout_failed_response, wallet_recharge_checkout_uncertain_response,
|
||||||
wallet_recharge_order_is_reclaimable_placeholder, wallet_recharge_replay_matches,
|
wallet_recharge_order_is_reclaimable_placeholder, wallet_recharge_replay_matches,
|
||||||
wallet_recharge_response_is_checkout_placeholder, wallet_refund_proof_is_success,
|
wallet_recharge_response_is_checkout_placeholder, wallet_refund_proof_is_success,
|
||||||
AdjustWalletBalanceInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
|
AdjustWalletBalanceInBatchInput, AdjustWalletBalanceInput, AdminPaymentOrderListQuery,
|
||||||
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
|
AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery, AdminUserWalletBalanceBatchContext,
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome, AdminWalletLedgerQuery, AdminWalletListQuery,
|
||||||
AdminWalletRefundRequestListQuery, CompareAndSwapPaymentOrderStripeClientSecretInput,
|
AdminWalletRefundRequestListQuery, CompareAndSwapPaymentOrderStripeClientSecretInput,
|
||||||
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
|
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
|
||||||
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
|
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
|
||||||
@@ -34,21 +35,23 @@ use aether_data_contracts::repository::wallet::{
|
|||||||
CreateWalletRefundRequestOutcome, CreatedAdminRedeemCodePlaintext,
|
CreateWalletRefundRequestOutcome, CreatedAdminRedeemCodePlaintext,
|
||||||
CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
|
CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
|
||||||
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
|
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
|
||||||
FailWalletRechargeCheckoutInput, InitializeAuthWalletOutcome, ProcessAdminWalletRefundInput,
|
FailWalletRechargeCheckoutInput, InitializeAuthWalletOutcome,
|
||||||
ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome, ReclaimWalletRechargeCheckoutInput,
|
PrepareAdminUserWalletBalanceBatchInput, PrepareAdminUserWalletBalanceBatchOutcome,
|
||||||
RedeemWalletCodeInput, RedeemWalletCodeOutcome, StoredAdminPaymentCallback,
|
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
||||||
StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder, StoredAdminPaymentOrderPage,
|
ReclaimWalletRechargeCheckoutInput, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
||||||
StoredAdminRedeemCode, StoredAdminRedeemCodeBatch, StoredAdminRedeemCodeBatchPage,
|
StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder,
|
||||||
StoredAdminRedeemCodePage, StoredAdminWalletLedgerItem, StoredAdminWalletLedgerPage,
|
StoredAdminPaymentOrderPage, StoredAdminRedeemCode, StoredAdminRedeemCodeBatch,
|
||||||
StoredAdminWalletListItem, StoredAdminWalletListPage, StoredAdminWalletRefund,
|
StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminUserWalletBalanceBatch,
|
||||||
StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestItem,
|
StoredAdminWalletLedgerItem, StoredAdminWalletLedgerPage, StoredAdminWalletListItem,
|
||||||
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
|
||||||
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
StoredAdminWalletRefundRequestItem, StoredAdminWalletRefundRequestPage,
|
||||||
|
StoredAdminWalletTransaction, StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
||||||
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput,
|
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput,
|
||||||
UpdateWalletRechargeCheckoutInput, WalletLookupKey, WalletMutationOutcome,
|
UpdateWalletRechargeCheckoutInput, WalletLookupKey, WalletMutationOutcome,
|
||||||
WalletReadRepository, WalletWriteRepository,
|
WalletReadRepository, WalletWriteRepository,
|
||||||
};
|
};
|
||||||
use aether_data_contracts::DataLayerError;
|
use aether_data_contracts::DataLayerError;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{postgres_error, SqlxResultExt},
|
error::{postgres_error, SqlxResultExt},
|
||||||
@@ -807,6 +810,44 @@ impl SqlxWalletRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn effective_wallet_adjustment_amount(input: &AdjustWalletBalanceInput, before_total: f64) -> f64 {
|
||||||
|
if input.clamp_deduction_to_available_balance && input.amount_usd < 0.0 {
|
||||||
|
if before_total < 0.0 {
|
||||||
|
// Clear legacy negative totals to zero and record the actual ledger delta.
|
||||||
|
-before_total
|
||||||
|
} else {
|
||||||
|
-(-input.amount_usd).min(before_total)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
input.amount_usd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_admin_wallet_batch_outcomes(
|
||||||
|
connection: &mut sqlx::PgConnection,
|
||||||
|
context: &AdminUserWalletBalanceBatchContext,
|
||||||
|
outcomes: &BTreeMap<String, AdminUserWalletBalanceBatchUserOutcome>,
|
||||||
|
) -> Result<(), DataLayerError> {
|
||||||
|
let outcomes = serde_json::to_value(outcomes).map_err(|error| {
|
||||||
|
DataLayerError::UnexpectedValue(format!("admin wallet batch outcomes are invalid: {error}"))
|
||||||
|
})?;
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE admin_user_wallet_balance_batches
|
||||||
|
SET user_outcomes = $3, updated_at_unix_secs = $4
|
||||||
|
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&context.admin_user_id)
|
||||||
|
.bind(&context.idempotency_key)
|
||||||
|
.bind(outcomes)
|
||||||
|
.bind(Utc::now().timestamp().max(0))
|
||||||
|
.execute(connection)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl WalletReadRepository for SqlxWalletRepository {
|
impl WalletReadRepository for SqlxWalletRepository {
|
||||||
async fn find(
|
async fn find(
|
||||||
@@ -4262,7 +4303,8 @@ RETURNING
|
|||||||
async fn adjust_wallet_balance(
|
async fn adjust_wallet_balance(
|
||||||
&self,
|
&self,
|
||||||
input: AdjustWalletBalanceInput,
|
input: AdjustWalletBalanceInput,
|
||||||
) -> Result<Option<(StoredWalletSnapshot, StoredAdminWalletTransaction)>, DataLayerError> {
|
) -> Result<Option<(StoredWalletSnapshot, Option<StoredAdminWalletTransaction>)>, DataLayerError>
|
||||||
|
{
|
||||||
if !input.amount_usd.is_finite() || input.amount_usd == 0.0 {
|
if !input.amount_usd.is_finite() || input.amount_usd == 0.0 {
|
||||||
return Err(DataLayerError::InvalidInput(
|
return Err(DataLayerError::InvalidInput(
|
||||||
"adjustment amount must be finite and non-zero".to_string(),
|
"adjustment amount must be finite and non-zero".to_string(),
|
||||||
@@ -4271,6 +4313,58 @@ RETURNING
|
|||||||
self.tx_runner
|
self.tx_runner
|
||||||
.run_read_write(|tx| {
|
.run_read_write(|tx| {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
|
let batch_context = input.batch_context.clone();
|
||||||
|
let mut batch_user_outcomes = if let Some(context) = &batch_context {
|
||||||
|
let batch_row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT target_user_ids, user_outcomes
|
||||||
|
FROM admin_user_wallet_balance_batches
|
||||||
|
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||||
|
FOR UPDATE
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&context.admin_user_id)
|
||||||
|
.bind(&context.idempotency_key)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DataLayerError::InvalidInput(
|
||||||
|
"admin wallet batch was not prepared".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let target_user_ids: Vec<String> =
|
||||||
|
serde_json::from_value(row_get(&batch_row, "target_user_ids")?)
|
||||||
|
.map_err(|error| {
|
||||||
|
DataLayerError::UnexpectedValue(format!(
|
||||||
|
"admin wallet batch target list is invalid: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if !target_user_ids.iter().any(|id| id == &context.user_id) {
|
||||||
|
return Err(DataLayerError::InvalidInput(
|
||||||
|
"user is outside the prepared admin wallet batch".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let outcomes: BTreeMap<String, AdminUserWalletBalanceBatchUserOutcome> =
|
||||||
|
serde_json::from_value(row_get(&batch_row, "user_outcomes")?).map_err(
|
||||||
|
|error| {
|
||||||
|
DataLayerError::UnexpectedValue(format!(
|
||||||
|
"admin wallet batch outcomes are invalid: {error}"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
if let Some(outcome) = outcomes.get(&context.user_id) {
|
||||||
|
match outcome {
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome::Succeeded => {}
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome::Failed(_) => {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(outcomes)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
let Some(row) = sqlx::query(
|
let Some(row) = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
@@ -4285,13 +4379,20 @@ SELECT
|
|||||||
CAST(total_recharged AS DOUBLE PRECISION) AS total_recharged,
|
CAST(total_recharged AS DOUBLE PRECISION) AS total_recharged,
|
||||||
CAST(total_consumed AS DOUBLE PRECISION) AS total_consumed,
|
CAST(total_consumed AS DOUBLE PRECISION) AS total_consumed,
|
||||||
CAST(total_refunded AS DOUBLE PRECISION) AS total_refunded,
|
CAST(total_refunded AS DOUBLE PRECISION) AS total_refunded,
|
||||||
CAST(total_adjusted AS DOUBLE PRECISION) AS total_adjusted
|
CAST(total_adjusted AS DOUBLE PRECISION) AS total_adjusted,
|
||||||
|
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs
|
||||||
FROM wallets
|
FROM wallets
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
|
AND ($2::character varying IS NULL OR user_id = $2::character varying)
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&input.wallet_id)
|
.bind(&input.wallet_id)
|
||||||
|
.bind(
|
||||||
|
batch_context
|
||||||
|
.as_ref()
|
||||||
|
.map(|context| context.user_id.as_str()),
|
||||||
|
)
|
||||||
.fetch_optional(&mut **tx)
|
.fetch_optional(&mut **tx)
|
||||||
.await
|
.await
|
||||||
.map_postgres_err()?
|
.map_postgres_err()?
|
||||||
@@ -4312,17 +4413,40 @@ FOR UPDATE
|
|||||||
"wallet balance is invalid".to_string(),
|
"wallet balance is invalid".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let wallet = map_wallet_row(&row)?;
|
||||||
|
let already_applied = batch_context.as_ref().is_some_and(|context| {
|
||||||
|
batch_user_outcomes.as_ref().is_some_and(|outcomes| {
|
||||||
|
outcomes.get(&context.user_id)
|
||||||
|
== Some(&AdminUserWalletBalanceBatchUserOutcome::Succeeded)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if already_applied {
|
||||||
|
return Ok(Some((wallet, None)));
|
||||||
|
}
|
||||||
|
let amount_usd = effective_wallet_adjustment_amount(&input, before_total);
|
||||||
|
if amount_usd == 0.0 {
|
||||||
|
if let (Some(context), Some(outcomes)) =
|
||||||
|
(batch_context.as_ref(), batch_user_outcomes.as_mut())
|
||||||
|
{
|
||||||
|
outcomes.insert(
|
||||||
|
context.user_id.clone(),
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome::Succeeded,
|
||||||
|
);
|
||||||
|
persist_admin_wallet_batch_outcomes(tx, context, outcomes).await?;
|
||||||
|
}
|
||||||
|
return Ok(Some((wallet, None)));
|
||||||
|
}
|
||||||
let mut after_recharge = before_recharge;
|
let mut after_recharge = before_recharge;
|
||||||
let mut after_gift = before_gift;
|
let mut after_gift = before_gift;
|
||||||
|
|
||||||
if input.amount_usd > 0.0 {
|
if amount_usd > 0.0 {
|
||||||
if input.balance_type.eq_ignore_ascii_case("gift") {
|
if input.balance_type.eq_ignore_ascii_case("gift") {
|
||||||
after_gift += input.amount_usd;
|
after_gift += amount_usd;
|
||||||
} else {
|
} else {
|
||||||
after_recharge += input.amount_usd;
|
after_recharge += amount_usd;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let mut remaining = -input.amount_usd;
|
let mut remaining = -amount_usd;
|
||||||
let consume_positive_bucket = |balance: &mut f64, to_consume: &mut f64| {
|
let consume_positive_bucket = |balance: &mut f64, to_consume: &mut f64| {
|
||||||
if *to_consume <= 0.0 {
|
if *to_consume <= 0.0 {
|
||||||
return;
|
return;
|
||||||
@@ -4344,7 +4468,7 @@ FOR UPDATE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let after_total = after_recharge + after_gift;
|
let after_total = after_recharge + after_gift;
|
||||||
let after_total_adjusted = before_total_adjusted + input.amount_usd;
|
let after_total_adjusted = before_total_adjusted + amount_usd;
|
||||||
if !after_recharge.is_finite()
|
if !after_recharge.is_finite()
|
||||||
|| !after_gift.is_finite()
|
|| !after_gift.is_finite()
|
||||||
|| !after_total.is_finite()
|
|| !after_total.is_finite()
|
||||||
@@ -4383,7 +4507,7 @@ RETURNING
|
|||||||
.bind(&input.wallet_id)
|
.bind(&input.wallet_id)
|
||||||
.bind(after_recharge)
|
.bind(after_recharge)
|
||||||
.bind(after_gift)
|
.bind(after_gift)
|
||||||
.bind(input.amount_usd)
|
.bind(amount_usd)
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await
|
.await
|
||||||
.map_postgres_err()?;
|
.map_postgres_err()?;
|
||||||
@@ -4439,7 +4563,7 @@ VALUES (
|
|||||||
)
|
)
|
||||||
.bind(&transaction_id)
|
.bind(&transaction_id)
|
||||||
.bind(&input.wallet_id)
|
.bind(&input.wallet_id)
|
||||||
.bind(input.amount_usd)
|
.bind(amount_usd)
|
||||||
.bind(before_total)
|
.bind(before_total)
|
||||||
.bind(after_total)
|
.bind(after_total)
|
||||||
.bind(before_recharge)
|
.bind(before_recharge)
|
||||||
@@ -4453,14 +4577,24 @@ VALUES (
|
|||||||
.await
|
.await
|
||||||
.map_postgres_err()?;
|
.map_postgres_err()?;
|
||||||
|
|
||||||
|
if let (Some(context), Some(outcomes)) =
|
||||||
|
(batch_context.as_ref(), batch_user_outcomes.as_mut())
|
||||||
|
{
|
||||||
|
outcomes.insert(
|
||||||
|
context.user_id.clone(),
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome::Succeeded,
|
||||||
|
);
|
||||||
|
persist_admin_wallet_batch_outcomes(tx, context, outcomes).await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Some((
|
Ok(Some((
|
||||||
wallet,
|
wallet,
|
||||||
StoredAdminWalletTransaction {
|
Some(StoredAdminWalletTransaction {
|
||||||
id: transaction_id,
|
id: transaction_id,
|
||||||
wallet_id: input.wallet_id,
|
wallet_id: input.wallet_id,
|
||||||
category: "adjust".to_string(),
|
category: "adjust".to_string(),
|
||||||
reason_code: "adjust_admin".to_string(),
|
reason_code: "adjust_admin".to_string(),
|
||||||
amount: input.amount_usd,
|
amount: amount_usd,
|
||||||
balance_before: before_total,
|
balance_before: before_total,
|
||||||
balance_after: after_total,
|
balance_after: after_total,
|
||||||
recharge_balance_before: before_recharge,
|
recharge_balance_before: before_recharge,
|
||||||
@@ -4474,13 +4608,244 @@ VALUES (
|
|||||||
operator_email: None,
|
operator_email: None,
|
||||||
description: Some(description),
|
description: Some(description),
|
||||||
created_at_unix_ms: Some(created_at),
|
created_at_unix_ms: Some(created_at),
|
||||||
},
|
}),
|
||||||
)))
|
)))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn prepare_admin_user_wallet_balance_batch(
|
||||||
|
&self,
|
||||||
|
input: PrepareAdminUserWalletBalanceBatchInput,
|
||||||
|
) -> Result<PrepareAdminUserWalletBalanceBatchOutcome, DataLayerError> {
|
||||||
|
let target_user_ids = serde_json::to_value(&input.target_user_ids).map_err(|error| {
|
||||||
|
DataLayerError::InvalidInput(format!("invalid batch target users: {error}"))
|
||||||
|
})?;
|
||||||
|
let missing_user_ids = serde_json::to_value(&input.missing_user_ids).map_err(|error| {
|
||||||
|
DataLayerError::InvalidInput(format!("invalid missing batch users: {error}"))
|
||||||
|
})?;
|
||||||
|
let warnings = serde_json::to_value(&input.warnings).map_err(|error| {
|
||||||
|
DataLayerError::InvalidInput(format!("invalid batch warnings: {error}"))
|
||||||
|
})?;
|
||||||
|
let now = Utc::now().timestamp().max(0);
|
||||||
|
self.tx_runner
|
||||||
|
.run_read_write(|tx| {
|
||||||
|
Box::pin(async move {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
INSERT INTO admin_user_wallet_balance_batches (
|
||||||
|
admin_user_id, idempotency_key, request_fingerprint, target_user_ids,
|
||||||
|
missing_user_ids, warnings, user_outcomes, created_at_unix_secs, updated_at_unix_secs
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, '{}'::jsonb, $7, $7)
|
||||||
|
ON CONFLICT (admin_user_id, idempotency_key) DO NOTHING
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&input.admin_user_id)
|
||||||
|
.bind(&input.idempotency_key)
|
||||||
|
.bind(&input.request_fingerprint)
|
||||||
|
.bind(target_user_ids)
|
||||||
|
.bind(missing_user_ids)
|
||||||
|
.bind(warnings)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?;
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT admin_user_id, idempotency_key, request_fingerprint, target_user_ids,
|
||||||
|
missing_user_ids, warnings, user_outcomes
|
||||||
|
FROM admin_user_wallet_balance_batches
|
||||||
|
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||||
|
FOR UPDATE
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&input.admin_user_id)
|
||||||
|
.bind(&input.idempotency_key)
|
||||||
|
.fetch_one(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?;
|
||||||
|
let request_fingerprint: String = row_get(&row, "request_fingerprint")?;
|
||||||
|
if request_fingerprint != input.request_fingerprint {
|
||||||
|
return Ok(PrepareAdminUserWalletBalanceBatchOutcome::Conflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
let read_json = |column: &str| -> Result<serde_json::Value, DataLayerError> {
|
||||||
|
row_get(&row, column)
|
||||||
|
};
|
||||||
|
let batch = StoredAdminUserWalletBalanceBatch {
|
||||||
|
admin_user_id: row_get(&row, "admin_user_id")?,
|
||||||
|
idempotency_key: row_get(&row, "idempotency_key")?,
|
||||||
|
request_fingerprint,
|
||||||
|
target_user_ids: serde_json::from_value(read_json("target_user_ids")?)
|
||||||
|
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||||
|
missing_user_ids: serde_json::from_value(read_json("missing_user_ids")?)
|
||||||
|
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||||
|
warnings: serde_json::from_value(read_json("warnings")?)
|
||||||
|
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||||
|
user_outcomes: serde_json::from_value(read_json("user_outcomes")?)
|
||||||
|
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||||
|
};
|
||||||
|
Ok(PrepareAdminUserWalletBalanceBatchOutcome::Ready(batch))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_admin_user_wallet_balance_batch(
|
||||||
|
&self,
|
||||||
|
admin_user_id: &str,
|
||||||
|
idempotency_key: &str,
|
||||||
|
expected_fingerprint: &str,
|
||||||
|
) -> Result<Option<PrepareAdminUserWalletBalanceBatchOutcome>, DataLayerError> {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT admin_user_id, idempotency_key, request_fingerprint, target_user_ids,
|
||||||
|
missing_user_ids, warnings, user_outcomes
|
||||||
|
FROM admin_user_wallet_balance_batches
|
||||||
|
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(admin_user_id)
|
||||||
|
.bind(idempotency_key)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?;
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let request_fingerprint: String = row_get(&row, "request_fingerprint")?;
|
||||||
|
if request_fingerprint != expected_fingerprint {
|
||||||
|
return Ok(Some(PrepareAdminUserWalletBalanceBatchOutcome::Conflict));
|
||||||
|
}
|
||||||
|
let read_json =
|
||||||
|
|column: &str| -> Result<serde_json::Value, DataLayerError> { row_get(&row, column) };
|
||||||
|
let batch = StoredAdminUserWalletBalanceBatch {
|
||||||
|
admin_user_id: row_get(&row, "admin_user_id")?,
|
||||||
|
idempotency_key: row_get(&row, "idempotency_key")?,
|
||||||
|
request_fingerprint,
|
||||||
|
target_user_ids: serde_json::from_value(read_json("target_user_ids")?)
|
||||||
|
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||||
|
missing_user_ids: serde_json::from_value(read_json("missing_user_ids")?)
|
||||||
|
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||||
|
warnings: serde_json::from_value(read_json("warnings")?)
|
||||||
|
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||||
|
user_outcomes: serde_json::from_value(read_json("user_outcomes")?)
|
||||||
|
.map_err(|error| DataLayerError::UnexpectedValue(error.to_string()))?,
|
||||||
|
};
|
||||||
|
Ok(Some(PrepareAdminUserWalletBalanceBatchOutcome::Ready(
|
||||||
|
batch,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn adjust_admin_user_wallet_balance_batch_user(
|
||||||
|
&self,
|
||||||
|
input: AdjustWalletBalanceInBatchInput,
|
||||||
|
) -> Result<AdminUserWalletBalanceBatchUserOutcome, DataLayerError> {
|
||||||
|
let context = AdminUserWalletBalanceBatchContext {
|
||||||
|
admin_user_id: input.admin_user_id.clone(),
|
||||||
|
idempotency_key: input.idempotency_key.clone(),
|
||||||
|
user_id: input.user_id.clone(),
|
||||||
|
};
|
||||||
|
let mut adjustment = input.adjustment;
|
||||||
|
adjustment.batch_context = Some(context);
|
||||||
|
if self.adjust_wallet_balance(adjustment).await?.is_some() {
|
||||||
|
return Ok(AdminUserWalletBalanceBatchUserOutcome::Succeeded);
|
||||||
|
}
|
||||||
|
self.record_admin_user_wallet_balance_batch_failure(
|
||||||
|
&input.admin_user_id,
|
||||||
|
&input.idempotency_key,
|
||||||
|
&input.user_id,
|
||||||
|
"用户钱包不可用",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_admin_user_wallet_balance_batch_failure(
|
||||||
|
&self,
|
||||||
|
admin_user_id: &str,
|
||||||
|
idempotency_key: &str,
|
||||||
|
user_id: &str,
|
||||||
|
reason: &str,
|
||||||
|
) -> Result<AdminUserWalletBalanceBatchUserOutcome, DataLayerError> {
|
||||||
|
let admin_user_id = admin_user_id.to_string();
|
||||||
|
let idempotency_key = idempotency_key.to_string();
|
||||||
|
let user_id = user_id.to_string();
|
||||||
|
let reason = reason.to_string();
|
||||||
|
self.tx_runner
|
||||||
|
.run_read_write(|tx| {
|
||||||
|
Box::pin(async move {
|
||||||
|
let row = sqlx::query(
|
||||||
|
r#"
|
||||||
|
SELECT target_user_ids, user_outcomes
|
||||||
|
FROM admin_user_wallet_balance_batches
|
||||||
|
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||||
|
FOR UPDATE
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&admin_user_id)
|
||||||
|
.bind(&idempotency_key)
|
||||||
|
.fetch_optional(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DataLayerError::InvalidInput(
|
||||||
|
"admin wallet batch was not prepared".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let target_user_ids: Vec<String> =
|
||||||
|
serde_json::from_value(row_get(&row, "target_user_ids")?).map_err(
|
||||||
|
|error| {
|
||||||
|
DataLayerError::UnexpectedValue(format!(
|
||||||
|
"admin wallet batch target list is invalid: {error}"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
if !target_user_ids.iter().any(|id| id == &user_id) {
|
||||||
|
return Err(DataLayerError::InvalidInput(
|
||||||
|
"user is outside the prepared admin wallet batch".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut outcomes: BTreeMap<String, AdminUserWalletBalanceBatchUserOutcome> =
|
||||||
|
serde_json::from_value(row_get(&row, "user_outcomes")?).map_err(
|
||||||
|
|error| {
|
||||||
|
DataLayerError::UnexpectedValue(format!(
|
||||||
|
"admin wallet batch outcomes are invalid: {error}"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
if let Some(outcome) = outcomes.get(&user_id) {
|
||||||
|
return Ok(outcome.clone());
|
||||||
|
}
|
||||||
|
let outcome = AdminUserWalletBalanceBatchUserOutcome::Failed(reason);
|
||||||
|
outcomes.insert(user_id, outcome.clone());
|
||||||
|
let value = serde_json::to_value(&outcomes).map_err(|error| {
|
||||||
|
DataLayerError::UnexpectedValue(format!(
|
||||||
|
"admin wallet batch outcomes are invalid: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
UPDATE admin_user_wallet_balance_batches
|
||||||
|
SET user_outcomes = $3, updated_at_unix_secs = $4
|
||||||
|
WHERE admin_user_id = $1 AND idempotency_key = $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&admin_user_id)
|
||||||
|
.bind(&idempotency_key)
|
||||||
|
.bind(value)
|
||||||
|
.bind(Utc::now().timestamp().max(0))
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_postgres_err()?;
|
||||||
|
Ok(outcome)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_manual_wallet_recharge(
|
async fn create_manual_wallet_recharge(
|
||||||
&self,
|
&self,
|
||||||
mut input: CreateManualWalletRechargeInput,
|
mut input: CreateManualWalletRechargeInput,
|
||||||
@@ -8489,13 +8854,16 @@ VALUES ($1, $2, 'gift', 'gift_initial', $3, 0, $3, 0, 0, 0, $3, 'system_task', $
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use aether_data_contracts::repository::wallet::{
|
use aether_data_contracts::repository::wallet::{
|
||||||
CreateManualWalletRechargeInput, CreditAdminPaymentOrderInput, ProcessPaymentCallbackInput,
|
AdjustWalletBalanceInBatchInput, AdjustWalletBalanceInput,
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome, CreateManualWalletRechargeInput,
|
||||||
|
CreditAdminPaymentOrderInput, PrepareAdminUserWalletBalanceBatchInput,
|
||||||
|
PrepareAdminUserWalletBalanceBatchOutcome, ProcessPaymentCallbackInput,
|
||||||
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
||||||
WalletLookupKey, WalletMutationOutcome, WalletReadRepository, WalletWriteRepository,
|
WalletLookupKey, WalletMutationOutcome, WalletReadRepository, WalletWriteRepository,
|
||||||
};
|
};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
use super::SqlxWalletRepository;
|
use super::{effective_wallet_adjustment_amount, SqlxWalletRepository};
|
||||||
use crate::{PostgresPoolConfig, PostgresPoolFactory};
|
use crate::{PostgresPoolConfig, PostgresPoolFactory};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -8575,6 +8943,7 @@ mod tests {
|
|||||||
"payment_orders",
|
"payment_orders",
|
||||||
"payment_callbacks",
|
"payment_callbacks",
|
||||||
"wallet_transactions",
|
"wallet_transactions",
|
||||||
|
"admin_user_wallet_balance_batches",
|
||||||
"user_plan_entitlements",
|
"user_plan_entitlements",
|
||||||
"redeem_code_batches",
|
"redeem_code_batches",
|
||||||
"redeem_codes",
|
"redeem_codes",
|
||||||
@@ -9221,6 +9590,264 @@ mod tests {
|
|||||||
pool.close().await;
|
pool.close().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bulk_adjustment_clamp_is_opt_in_and_uses_available_total() {
|
||||||
|
let input = AdjustWalletBalanceInput {
|
||||||
|
wallet_id: "wallet-1".to_string(),
|
||||||
|
amount_usd: -100.0,
|
||||||
|
balance_type: "recharge".to_string(),
|
||||||
|
operator_id: None,
|
||||||
|
description: None,
|
||||||
|
clamp_deduction_to_available_balance: true,
|
||||||
|
batch_context: None,
|
||||||
|
};
|
||||||
|
assert_eq!(effective_wallet_adjustment_amount(&input, 13.0), -13.0);
|
||||||
|
assert_eq!(effective_wallet_adjustment_amount(&input, 0.0), -0.0);
|
||||||
|
assert_eq!(effective_wallet_adjustment_amount(&input, -1.0), 1.0);
|
||||||
|
|
||||||
|
let legacy_input = AdjustWalletBalanceInput {
|
||||||
|
clamp_deduction_to_available_balance: false,
|
||||||
|
..input
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
effective_wallet_adjustment_amount(&legacy_input, 13.0),
|
||||||
|
-100.0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"]
|
||||||
|
async fn live_bulk_wallet_adjustment_persists_actual_delta_and_skips_zero_ledger() {
|
||||||
|
let pool = isolated_wallet_test_pool().await;
|
||||||
|
let (wallet_id, _) = seed_wallet(&pool).await;
|
||||||
|
let repository = SqlxWalletRepository::new(pool.clone());
|
||||||
|
|
||||||
|
let (wallet, transaction) = repository
|
||||||
|
.adjust_wallet_balance(AdjustWalletBalanceInput {
|
||||||
|
wallet_id: wallet_id.clone(),
|
||||||
|
amount_usd: -100.0,
|
||||||
|
balance_type: "recharge".to_string(),
|
||||||
|
operator_id: Some("admin-user".to_string()),
|
||||||
|
description: Some("bulk deduction".to_string()),
|
||||||
|
clamp_deduction_to_available_balance: true,
|
||||||
|
batch_context: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("bulk adjustment should succeed")
|
||||||
|
.expect("wallet should exist");
|
||||||
|
let transaction =
|
||||||
|
transaction.expect("positive available balance should create a ledger row");
|
||||||
|
assert_eq!(transaction.amount, -13.0);
|
||||||
|
assert_eq!(transaction.balance_before, 13.0);
|
||||||
|
assert_eq!(transaction.balance_after, 0.0);
|
||||||
|
assert_eq!(wallet.balance + wallet.gift_balance, 0.0);
|
||||||
|
let persisted_amount: f64 = sqlx::query_scalar(
|
||||||
|
"SELECT amount::double precision FROM wallet_transactions WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(&transaction.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("ledger should store the effective deduction");
|
||||||
|
assert_eq!(persisted_amount, -13.0);
|
||||||
|
|
||||||
|
let (wallet, transaction) = repository
|
||||||
|
.adjust_wallet_balance(AdjustWalletBalanceInput {
|
||||||
|
wallet_id: wallet_id.clone(),
|
||||||
|
amount_usd: -1.0,
|
||||||
|
balance_type: "recharge".to_string(),
|
||||||
|
operator_id: Some("admin-user".to_string()),
|
||||||
|
description: Some("bulk deduction at zero".to_string()),
|
||||||
|
clamp_deduction_to_available_balance: true,
|
||||||
|
batch_context: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("zero-balance adjustment should succeed")
|
||||||
|
.expect("wallet should still exist");
|
||||||
|
assert_eq!(wallet.balance + wallet.gift_balance, 0.0);
|
||||||
|
assert!(
|
||||||
|
transaction.is_none(),
|
||||||
|
"zero effective delta must not create a ledger row"
|
||||||
|
);
|
||||||
|
let transaction_count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM wallet_transactions WHERE wallet_id = $1")
|
||||||
|
.bind(&wallet_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("ledger row count should be readable");
|
||||||
|
assert_eq!(transaction_count, 1);
|
||||||
|
|
||||||
|
sqlx::query("UPDATE wallets SET balance = -2, gift_balance = 1 WHERE id = $1")
|
||||||
|
.bind(&wallet_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.expect("legacy negative wallet balance should be seeded");
|
||||||
|
let (wallet, transaction) = repository
|
||||||
|
.adjust_wallet_balance(AdjustWalletBalanceInput {
|
||||||
|
wallet_id: wallet_id.clone(),
|
||||||
|
amount_usd: -1.0,
|
||||||
|
balance_type: "recharge".to_string(),
|
||||||
|
operator_id: Some("admin-user".to_string()),
|
||||||
|
description: Some("bulk deduction floors a negative balance".to_string()),
|
||||||
|
clamp_deduction_to_available_balance: true,
|
||||||
|
batch_context: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("legacy negative wallet should be floored at zero")
|
||||||
|
.expect("wallet should still exist");
|
||||||
|
assert_eq!(wallet.balance + wallet.gift_balance, 0.0);
|
||||||
|
let transaction = transaction.expect("negative balance correction should be ledgered");
|
||||||
|
assert_eq!(transaction.amount, 1.0);
|
||||||
|
assert_eq!(transaction.balance_before, -1.0);
|
||||||
|
assert_eq!(transaction.balance_after, 0.0);
|
||||||
|
let persisted_correction_amount: f64 = sqlx::query_scalar(
|
||||||
|
"SELECT amount::double precision FROM wallet_transactions WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(&transaction.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("ledger should store the negative-balance correction");
|
||||||
|
assert_eq!(persisted_correction_amount, 1.0);
|
||||||
|
let transaction_count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM wallet_transactions WHERE wallet_id = $1")
|
||||||
|
.bind(&wallet_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.expect("ledger row count should be readable");
|
||||||
|
assert_eq!(transaction_count, 2);
|
||||||
|
pool.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL bootstrap schema"]
|
||||||
|
async fn live_admin_wallet_balance_batch_replays_committed_and_zero_delta_results_once() {
|
||||||
|
let pool = isolated_wallet_test_pool().await;
|
||||||
|
let (wallet_id, user_id) = seed_wallet(&pool).await;
|
||||||
|
let repository = SqlxWalletRepository::new(pool.clone());
|
||||||
|
let admin_user_id = "admin-user".to_string();
|
||||||
|
let make_adjustment =
|
||||||
|
|idempotency_key: &str, amount_usd: f64| AdjustWalletBalanceInBatchInput {
|
||||||
|
admin_user_id: admin_user_id.clone(),
|
||||||
|
idempotency_key: idempotency_key.to_string(),
|
||||||
|
user_id: user_id.clone(),
|
||||||
|
adjustment: AdjustWalletBalanceInput {
|
||||||
|
wallet_id: wallet_id.clone(),
|
||||||
|
amount_usd,
|
||||||
|
balance_type: "recharge".to_string(),
|
||||||
|
operator_id: Some(admin_user_id.clone()),
|
||||||
|
description: Some("idempotency integration test".to_string()),
|
||||||
|
clamp_deduction_to_available_balance: true,
|
||||||
|
batch_context: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let prepare_batch = |idempotency_key: &str, request_fingerprint: &str| {
|
||||||
|
PrepareAdminUserWalletBalanceBatchInput {
|
||||||
|
admin_user_id: admin_user_id.clone(),
|
||||||
|
idempotency_key: idempotency_key.to_string(),
|
||||||
|
request_fingerprint: request_fingerprint.to_string(),
|
||||||
|
target_user_ids: vec![user_id.clone()],
|
||||||
|
missing_user_ids: Vec::new(),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
repository
|
||||||
|
.prepare_admin_user_wallet_balance_batch(prepare_batch(
|
||||||
|
"deduct-key",
|
||||||
|
"deduct-fingerprint"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
PrepareAdminUserWalletBalanceBatchOutcome::Ready(_)
|
||||||
|
));
|
||||||
|
let deduct = make_adjustment("deduct-key", -50.0);
|
||||||
|
assert_eq!(
|
||||||
|
repository
|
||||||
|
.adjust_admin_user_wallet_balance_batch_user(deduct.clone())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome::Succeeded
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
repository
|
||||||
|
.adjust_admin_user_wallet_balance_batch_user(deduct.clone())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome::Succeeded
|
||||||
|
);
|
||||||
|
let wallet = repository
|
||||||
|
.find(WalletLookupKey::WalletId(&wallet_id))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(wallet.balance + wallet.gift_balance, 0.0);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
repository
|
||||||
|
.prepare_admin_user_wallet_balance_batch(prepare_batch(
|
||||||
|
"zero-key",
|
||||||
|
"zero-fingerprint"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
PrepareAdminUserWalletBalanceBatchOutcome::Ready(_)
|
||||||
|
));
|
||||||
|
let zero_delta = make_adjustment("zero-key", -5.0);
|
||||||
|
assert_eq!(
|
||||||
|
repository
|
||||||
|
.adjust_admin_user_wallet_balance_batch_user(zero_delta.clone())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome::Succeeded
|
||||||
|
);
|
||||||
|
repository
|
||||||
|
.adjust_wallet_balance(AdjustWalletBalanceInput {
|
||||||
|
wallet_id: wallet_id.clone(),
|
||||||
|
amount_usd: 8.0,
|
||||||
|
balance_type: "recharge".to_string(),
|
||||||
|
operator_id: Some(admin_user_id.clone()),
|
||||||
|
description: Some("recharge after zero-delta batch".to_string()),
|
||||||
|
clamp_deduction_to_available_balance: true,
|
||||||
|
batch_context: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
for replay in [zero_delta, deduct] {
|
||||||
|
assert_eq!(
|
||||||
|
repository
|
||||||
|
.adjust_admin_user_wallet_balance_batch_user(replay)
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
AdminUserWalletBalanceBatchUserOutcome::Succeeded
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
repository
|
||||||
|
.prepare_admin_user_wallet_balance_batch(prepare_batch(
|
||||||
|
"zero-key",
|
||||||
|
"different-fingerprint"
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
PrepareAdminUserWalletBalanceBatchOutcome::Conflict
|
||||||
|
);
|
||||||
|
let wallet = repository
|
||||||
|
.find(WalletLookupKey::WalletId(&wallet_id))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(wallet.balance + wallet.gift_balance, 8.0);
|
||||||
|
let transaction_count: i64 =
|
||||||
|
sqlx::query_scalar("SELECT COUNT(*) FROM wallet_transactions WHERE wallet_id = $1")
|
||||||
|
.bind(&wallet_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(transaction_count, 2);
|
||||||
|
pool.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn repository_constructs_from_lazy_pool() {
|
async fn repository_constructs_from_lazy_pool() {
|
||||||
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
|
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
|
||||||
|
|||||||
@@ -2462,6 +2462,59 @@ pub struct AdjustWalletBalanceInput {
|
|||||||
pub balance_type: String,
|
pub balance_type: String,
|
||||||
pub operator_id: Option<String>,
|
pub operator_id: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub clamp_deduction_to_available_balance: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub batch_context: Option<AdminUserWalletBalanceBatchContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct AdminUserWalletBalanceBatchContext {
|
||||||
|
pub admin_user_id: String,
|
||||||
|
pub idempotency_key: String,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct PrepareAdminUserWalletBalanceBatchInput {
|
||||||
|
pub admin_user_id: String,
|
||||||
|
pub idempotency_key: String,
|
||||||
|
pub request_fingerprint: String,
|
||||||
|
pub target_user_ids: Vec<String>,
|
||||||
|
pub missing_user_ids: Vec<String>,
|
||||||
|
pub warnings: Vec<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct StoredAdminUserWalletBalanceBatch {
|
||||||
|
pub admin_user_id: String,
|
||||||
|
pub idempotency_key: String,
|
||||||
|
pub request_fingerprint: String,
|
||||||
|
pub target_user_ids: Vec<String>,
|
||||||
|
pub missing_user_ids: Vec<String>,
|
||||||
|
pub warnings: Vec<serde_json::Value>,
|
||||||
|
pub user_outcomes: std::collections::BTreeMap<String, AdminUserWalletBalanceBatchUserOutcome>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub enum PrepareAdminUserWalletBalanceBatchOutcome {
|
||||||
|
Ready(StoredAdminUserWalletBalanceBatch),
|
||||||
|
Conflict,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(tag = "status", content = "reason", rename_all = "snake_case")]
|
||||||
|
pub enum AdminUserWalletBalanceBatchUserOutcome {
|
||||||
|
Succeeded,
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct AdjustWalletBalanceInBatchInput {
|
||||||
|
pub admin_user_id: String,
|
||||||
|
pub idempotency_key: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub adjustment: AdjustWalletBalanceInput,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -2893,7 +2946,51 @@ pub trait WalletWriteRepository: Send + Sync {
|
|||||||
async fn adjust_wallet_balance(
|
async fn adjust_wallet_balance(
|
||||||
&self,
|
&self,
|
||||||
input: AdjustWalletBalanceInput,
|
input: AdjustWalletBalanceInput,
|
||||||
) -> Result<Option<(StoredWalletSnapshot, StoredAdminWalletTransaction)>, crate::DataLayerError>;
|
) -> Result<
|
||||||
|
Option<(StoredWalletSnapshot, Option<StoredAdminWalletTransaction>)>,
|
||||||
|
crate::DataLayerError,
|
||||||
|
>;
|
||||||
|
|
||||||
|
async fn prepare_admin_user_wallet_balance_batch(
|
||||||
|
&self,
|
||||||
|
_input: PrepareAdminUserWalletBalanceBatchInput,
|
||||||
|
) -> Result<PrepareAdminUserWalletBalanceBatchOutcome, crate::DataLayerError> {
|
||||||
|
Err(crate::DataLayerError::InvalidInput(
|
||||||
|
"idempotent admin wallet batches are not available".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_admin_user_wallet_balance_batch(
|
||||||
|
&self,
|
||||||
|
_admin_user_id: &str,
|
||||||
|
_idempotency_key: &str,
|
||||||
|
_request_fingerprint: &str,
|
||||||
|
) -> Result<Option<PrepareAdminUserWalletBalanceBatchOutcome>, crate::DataLayerError> {
|
||||||
|
Err(crate::DataLayerError::InvalidInput(
|
||||||
|
"idempotent admin wallet batches are not available".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn adjust_admin_user_wallet_balance_batch_user(
|
||||||
|
&self,
|
||||||
|
_input: AdjustWalletBalanceInBatchInput,
|
||||||
|
) -> Result<AdminUserWalletBalanceBatchUserOutcome, crate::DataLayerError> {
|
||||||
|
Err(crate::DataLayerError::InvalidInput(
|
||||||
|
"idempotent admin wallet batches are not available".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_admin_user_wallet_balance_batch_failure(
|
||||||
|
&self,
|
||||||
|
_admin_user_id: &str,
|
||||||
|
_idempotency_key: &str,
|
||||||
|
_user_id: &str,
|
||||||
|
_reason: &str,
|
||||||
|
) -> Result<AdminUserWalletBalanceBatchUserOutcome, crate::DataLayerError> {
|
||||||
|
Err(crate::DataLayerError::InvalidInput(
|
||||||
|
"idempotent admin wallet batches are not available".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_manual_wallet_recharge(
|
async fn create_manual_wallet_recharge(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -343,3 +343,17 @@ CREATE INDEX IF NOT EXISTS idx_redeem_codes_status ON public.redeem_codes USING
|
|||||||
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_user ON public.redeem_codes USING btree (redeemed_by_user_id, redeemed_at);
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_user ON public.redeem_codes USING btree (redeemed_by_user_id, redeemed_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_order ON public.redeem_codes USING btree (redeemed_payment_order_id);
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_redeemed_order ON public.redeem_codes USING btree (redeemed_payment_order_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.admin_user_wallet_balance_batches (
|
||||||
|
admin_user_id character varying(64) NOT NULL,
|
||||||
|
idempotency_key character varying(128) NOT NULL,
|
||||||
|
request_fingerprint character varying(64) NOT NULL,
|
||||||
|
target_user_ids jsonb NOT NULL,
|
||||||
|
missing_user_ids jsonb NOT NULL,
|
||||||
|
warnings jsonb NOT NULL,
|
||||||
|
user_outcomes jsonb NOT NULL,
|
||||||
|
created_at_unix_secs bigint NOT NULL,
|
||||||
|
updated_at_unix_secs bigint NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.admin_user_wallet_balance_batches ADD CONSTRAINT admin_user_wallet_balance_batches_pkey PRIMARY KEY (admin_user_id, idempotency_key);
|
||||||
|
|
||||||
|
|||||||
@@ -1366,3 +1366,47 @@ columns = ["redeemed_by_user_id", "redeemed_at"]
|
|||||||
[[table.redeem_codes.indexes]]
|
[[table.redeem_codes.indexes]]
|
||||||
name = "idx_redeem_codes_redeemed_order"
|
name = "idx_redeem_codes_redeemed_order"
|
||||||
columns = ["redeemed_payment_order_id"]
|
columns = ["redeemed_payment_order_id"]
|
||||||
|
|
||||||
|
[table.admin_user_wallet_balance_batches]
|
||||||
|
domain = "wallet_billing"
|
||||||
|
order = 110
|
||||||
|
primary_key = ["admin_user_id", "idempotency_key"]
|
||||||
|
|
||||||
|
[[table.admin_user_wallet_balance_batches.columns]]
|
||||||
|
name = "admin_user_id"
|
||||||
|
type = "text_id"
|
||||||
|
length = 64
|
||||||
|
|
||||||
|
[[table.admin_user_wallet_balance_batches.columns]]
|
||||||
|
name = "idempotency_key"
|
||||||
|
type = "text"
|
||||||
|
length = 128
|
||||||
|
|
||||||
|
[[table.admin_user_wallet_balance_batches.columns]]
|
||||||
|
name = "request_fingerprint"
|
||||||
|
type = "text"
|
||||||
|
length = 64
|
||||||
|
|
||||||
|
[[table.admin_user_wallet_balance_batches.columns]]
|
||||||
|
name = "target_user_ids"
|
||||||
|
type = "json"
|
||||||
|
|
||||||
|
[[table.admin_user_wallet_balance_batches.columns]]
|
||||||
|
name = "missing_user_ids"
|
||||||
|
type = "json"
|
||||||
|
|
||||||
|
[[table.admin_user_wallet_balance_batches.columns]]
|
||||||
|
name = "warnings"
|
||||||
|
type = "json"
|
||||||
|
|
||||||
|
[[table.admin_user_wallet_balance_batches.columns]]
|
||||||
|
name = "user_outcomes"
|
||||||
|
type = "json"
|
||||||
|
|
||||||
|
[[table.admin_user_wallet_balance_batches.columns]]
|
||||||
|
name = "created_at_unix_secs"
|
||||||
|
type = "unix_seconds"
|
||||||
|
|
||||||
|
[[table.admin_user_wallet_balance_batches.columns]]
|
||||||
|
name = "updated_at_unix_secs"
|
||||||
|
type = "unix_seconds"
|
||||||
|
|||||||
@@ -1575,6 +1575,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
|
|||||||
20260901000000,
|
20260901000000,
|
||||||
20260903000000,
|
20260903000000,
|
||||||
20260908000000,
|
20260908000000,
|
||||||
|
20260923000000,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2322,8 +2322,13 @@ impl WalletWriteRepository for InMemoryWalletRepository {
|
|||||||
async fn adjust_wallet_balance(
|
async fn adjust_wallet_balance(
|
||||||
&self,
|
&self,
|
||||||
_input: AdjustWalletBalanceInput,
|
_input: AdjustWalletBalanceInput,
|
||||||
) -> Result<Option<(StoredWalletSnapshot, super::StoredAdminWalletTransaction)>, DataLayerError>
|
) -> Result<
|
||||||
{
|
Option<(
|
||||||
|
StoredWalletSnapshot,
|
||||||
|
Option<super::StoredAdminWalletTransaction>,
|
||||||
|
)>,
|
||||||
|
DataLayerError,
|
||||||
|
> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,27 +16,30 @@ pub use aether_data_contracts::repository::wallet::{
|
|||||||
wallet_recharge_order_is_checkout_placeholder,
|
wallet_recharge_order_is_checkout_placeholder,
|
||||||
wallet_recharge_order_is_reclaimable_placeholder, wallet_recharge_replay_matches,
|
wallet_recharge_order_is_reclaimable_placeholder, wallet_recharge_replay_matches,
|
||||||
wallet_recharge_response_is_checkout_placeholder, wallet_refund_proof_is_success,
|
wallet_recharge_response_is_checkout_placeholder, wallet_refund_proof_is_success,
|
||||||
AdjustWalletBalanceInput, AdminPaymentCallbackRecord, AdminPaymentOrderListQuery,
|
AdjustWalletBalanceInBatchInput, AdjustWalletBalanceInput, AdminPaymentCallbackRecord,
|
||||||
AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery, AdminWalletLedgerQuery,
|
AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery, AdminRedeemCodeListQuery,
|
||||||
AdminWalletListQuery, AdminWalletPaymentOrderRecord, AdminWalletRefundRecord,
|
AdminUserWalletBalanceBatchContext, AdminUserWalletBalanceBatchUserOutcome,
|
||||||
AdminWalletRefundRequestListQuery, AdminWalletTransactionRecord, CanonicalWalletRefundFields,
|
AdminWalletLedgerQuery, AdminWalletListQuery, AdminWalletPaymentOrderRecord,
|
||||||
CompareAndSwapPaymentOrderStripeClientSecretInput, CompleteAdminWalletRefundInput,
|
AdminWalletRefundRecord, AdminWalletRefundRequestListQuery, AdminWalletTransactionRecord,
|
||||||
CreateAdminRedeemCodeBatchInput, CreateAdminRedeemCodeBatchResult,
|
CanonicalWalletRefundFields, CompareAndSwapPaymentOrderStripeClientSecretInput,
|
||||||
CreateManualWalletRechargeInput, CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome,
|
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
|
||||||
CreateWalletRechargeOrderInput, CreateWalletRechargeOrderOutcome,
|
CreateAdminRedeemCodeBatchResult, CreateManualWalletRechargeInput,
|
||||||
CreateWalletRefundRequestInput, CreateWalletRefundRequestOutcome,
|
CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome, CreateWalletRechargeOrderInput,
|
||||||
CreatedAdminRedeemCodePlaintext, CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
|
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
|
||||||
|
CreateWalletRefundRequestOutcome, CreatedAdminRedeemCodePlaintext,
|
||||||
|
CreditAdminPaymentOrderInput, DeleteAdminRedeemCodeBatchInput,
|
||||||
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
|
DisableAdminRedeemCodeBatchInput, DisableAdminRedeemCodeInput, FailAdminWalletRefundInput,
|
||||||
FailWalletRechargeCheckoutInput, InitializeAuthWalletOutcome, ProcessAdminWalletRefundInput,
|
FailWalletRechargeCheckoutInput, InitializeAuthWalletOutcome,
|
||||||
ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome, ReclaimWalletRechargeCheckoutInput,
|
PrepareAdminUserWalletBalanceBatchInput, PrepareAdminUserWalletBalanceBatchOutcome,
|
||||||
RedeemWalletCodeInput, RedeemWalletCodeOutcome, StoredAdminPaymentCallback,
|
ProcessAdminWalletRefundInput, ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome,
|
||||||
StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder, StoredAdminPaymentOrderPage,
|
ReclaimWalletRechargeCheckoutInput, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
|
||||||
StoredAdminRedeemCode, StoredAdminRedeemCodeBatch, StoredAdminRedeemCodeBatchPage,
|
StoredAdminPaymentCallback, StoredAdminPaymentCallbackPage, StoredAdminPaymentOrder,
|
||||||
StoredAdminRedeemCodePage, StoredAdminWalletLedgerItem, StoredAdminWalletLedgerPage,
|
StoredAdminPaymentOrderPage, StoredAdminRedeemCode, StoredAdminRedeemCodeBatch,
|
||||||
StoredAdminWalletListItem, StoredAdminWalletListPage, StoredAdminWalletRefund,
|
StoredAdminRedeemCodeBatchPage, StoredAdminRedeemCodePage, StoredAdminUserWalletBalanceBatch,
|
||||||
StoredAdminWalletRefundPage, StoredAdminWalletRefundRequestItem,
|
StoredAdminWalletLedgerItem, StoredAdminWalletLedgerPage, StoredAdminWalletListItem,
|
||||||
StoredAdminWalletRefundRequestPage, StoredAdminWalletTransaction,
|
StoredAdminWalletListPage, StoredAdminWalletRefund, StoredAdminWalletRefundPage,
|
||||||
StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
StoredAdminWalletRefundRequestItem, StoredAdminWalletRefundRequestPage,
|
||||||
|
StoredAdminWalletTransaction, StoredAdminWalletTransactionPage, StoredWalletDailyUsageLedger,
|
||||||
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput,
|
StoredWalletDailyUsageLedgerPage, StoredWalletSnapshot, UpdateAdminWalletRefundGatewayInput,
|
||||||
UpdateWalletRechargeCheckoutInput, WalletLookupKey, WalletMutationOutcome,
|
UpdateWalletRechargeCheckoutInput, WalletLookupKey, WalletMutationOutcome,
|
||||||
WalletReadRepository, WalletReadSeed, WalletReadSnapshot, WalletRepository,
|
WalletReadRepository, WalletReadSeed, WalletReadSnapshot, WalletRepository,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ vi.mock('@/utils/cache', () => ({
|
|||||||
cachedRequest: cachedRequestMock,
|
cachedRequest: cachedRequestMock,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
import { usersApi } from '@/api/users'
|
import { buildUserBatchBalanceAdjustmentPayload, usersApi } from '@/api/users'
|
||||||
|
|
||||||
describe('usersApi admin list query', () => {
|
describe('usersApi admin list query', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -104,3 +104,25 @@ describe('usersApi admin list query', () => {
|
|||||||
expect(getMock).toHaveBeenCalledWith('/api/admin/users/target-user/api-keys')
|
expect(getMock).toHaveBeenCalledWith('/api/admin/users/target-user/api-keys')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('user batch wallet balance payload', () => {
|
||||||
|
it('builds an addition payload from a valid positive amount', () => {
|
||||||
|
expect(buildUserBatchBalanceAdjustmentPayload('add', '12.5')).toEqual({
|
||||||
|
operation: 'add',
|
||||||
|
amount: 12.5,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds a deduction payload from a valid positive amount', () => {
|
||||||
|
expect(buildUserBatchBalanceAdjustmentPayload('deduct', 4)).toEqual({
|
||||||
|
operation: 'deduct',
|
||||||
|
amount: 4,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects zero, blank, and non-finite amounts', () => {
|
||||||
|
expect(buildUserBatchBalanceAdjustmentPayload('add', '0')).toBeNull()
|
||||||
|
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '')).toBeNull()
|
||||||
|
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '1e999')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -120,9 +120,28 @@ export interface UserBatchRolePayload {
|
|||||||
role: UserRole
|
role: UserRole
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role'
|
export type UserBatchBalanceOperation = 'add' | 'deduct'
|
||||||
|
|
||||||
export type UserBatchActionPayload = UserBatchAccessControlPayload | UserBatchRolePayload
|
export interface UserBatchBalanceAdjustmentPayload {
|
||||||
|
operation: UserBatchBalanceOperation
|
||||||
|
amount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUserBatchBalanceAdjustmentPayload(
|
||||||
|
operation: UserBatchBalanceOperation,
|
||||||
|
amountInput: string | number,
|
||||||
|
): UserBatchBalanceAdjustmentPayload | null {
|
||||||
|
const amount = typeof amountInput === 'number' ? amountInput : Number(amountInput.trim())
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) return null
|
||||||
|
return { operation, amount }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role' | 'adjust_wallet_balance'
|
||||||
|
|
||||||
|
export type UserBatchActionPayload =
|
||||||
|
| UserBatchAccessControlPayload
|
||||||
|
| UserBatchRolePayload
|
||||||
|
| UserBatchBalanceAdjustmentPayload
|
||||||
|
|
||||||
export interface UserBatchToggleActionRequest {
|
export interface UserBatchToggleActionRequest {
|
||||||
selection: UserBatchSelection
|
selection: UserBatchSelection
|
||||||
@@ -142,10 +161,18 @@ export interface UserBatchRoleActionRequest {
|
|||||||
payload: UserBatchRolePayload
|
payload: UserBatchRolePayload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserBatchBalanceActionRequest {
|
||||||
|
selection: UserBatchSelection
|
||||||
|
action: 'adjust_wallet_balance'
|
||||||
|
payload: UserBatchBalanceAdjustmentPayload
|
||||||
|
idempotency_key: string
|
||||||
|
}
|
||||||
|
|
||||||
export type UserBatchActionRequest =
|
export type UserBatchActionRequest =
|
||||||
| UserBatchToggleActionRequest
|
| UserBatchToggleActionRequest
|
||||||
| UserBatchAccessControlActionRequest
|
| UserBatchAccessControlActionRequest
|
||||||
| UserBatchRoleActionRequest
|
| UserBatchRoleActionRequest
|
||||||
|
| UserBatchBalanceActionRequest
|
||||||
|
|
||||||
export interface UserBatchActionFailure {
|
export interface UserBatchActionFailure {
|
||||||
user_id: string
|
user_id: string
|
||||||
@@ -157,6 +184,10 @@ export interface UserBatchActionResponse {
|
|||||||
success: number
|
success: number
|
||||||
failed: number
|
failed: number
|
||||||
failures: UserBatchActionFailure[]
|
failures: UserBatchActionFailure[]
|
||||||
|
interrupted?: boolean
|
||||||
|
completed_user_ids?: string[]
|
||||||
|
uncertain_user_ids?: string[]
|
||||||
|
unprocessed_user_ids?: string[]
|
||||||
warnings?: UserBatchSelectionWarning[]
|
warnings?: UserBatchSelectionWarning[]
|
||||||
action?: string
|
action?: string
|
||||||
modified_fields?: string[]
|
modified_fields?: string[]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
:model-value="open"
|
:model-value="open"
|
||||||
:title="legacyT('用户批量操作')"
|
:title="legacyT('用户批量操作')"
|
||||||
:description="legacyT('按当前选择批量调整用户状态、角色和额度')"
|
:description="legacyT('按当前选择批量调整用户状态、角色、额度和钱包余额')"
|
||||||
size="2xl"
|
size="2xl"
|
||||||
persistent
|
persistent
|
||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
@@ -39,6 +39,88 @@
|
|||||||
v-model="quotaMode"
|
v-model="quotaMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedAction === 'adjust_wallet_balance'"
|
||||||
|
class="space-y-3 rounded-xl border border-border bg-background p-4"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Label for="user-batch-wallet-amount" class="text-sm font-medium">
|
||||||
|
{{ legacyT('调整金额 (USD)') }}
|
||||||
|
</Label>
|
||||||
|
<div class="inline-flex rounded-md border border-border p-0.5" role="group" :aria-label="legacyT('余额调整方式')">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
:variant="balanceOperation === 'add' ? 'default' : 'ghost'"
|
||||||
|
:aria-pressed="balanceOperation === 'add'"
|
||||||
|
@click="balanceOperation = 'add'"
|
||||||
|
>
|
||||||
|
<Plus class="mr-1.5 h-4 w-4" />
|
||||||
|
{{ legacyT('增加') }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
:variant="balanceOperation === 'deduct' ? 'default' : 'ghost'"
|
||||||
|
:aria-pressed="balanceOperation === 'deduct'"
|
||||||
|
@click="balanceOperation = 'deduct'"
|
||||||
|
>
|
||||||
|
<Minus class="mr-1.5 h-4 w-4" />
|
||||||
|
{{ legacyT('扣减') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="user-batch-wallet-amount"
|
||||||
|
:model-value="balanceAmount"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
inputmode="decimal"
|
||||||
|
:aria-invalid="balanceAmount !== '' && balancePayload === null"
|
||||||
|
@update:model-value="balanceAmount = String($event)"
|
||||||
|
/>
|
||||||
|
<p v-if="balanceAmount !== '' && balancePayload === null" class="text-xs text-destructive">
|
||||||
|
{{ legacyT('请输入大于 0 的有限金额') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{{ legacyT('扣减超过单个用户可用余额时,该用户余额将归零。') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="pendingWalletBatch"
|
||||||
|
role="alert"
|
||||||
|
class="space-y-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
{{ legacyT('存在未决的钱包批量调整') }}:{{ pendingWalletOperationLabel }} {{ pendingWalletBatch.request.payload.amount }} USD。{{ legacyT('结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。') }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="walletRequestMismatch"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
{{ legacyT('当前表单与原请求不同;新钱包调整已禁用,请先重试原请求。') }}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="executing"
|
||||||
|
@click="retryPendingWalletBatch"
|
||||||
|
>
|
||||||
|
<RotateCcw class="mr-1.5 h-4 w-4" />
|
||||||
|
{{ legacyT('重试原钱包批次') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-else-if="pendingWalletReadError"
|
||||||
|
role="alert"
|
||||||
|
class="rounded-md border border-amber-300 bg-amber-50 px-3 py-2.5 text-sm text-amber-900 dark:border-amber-900/60 dark:bg-amber-950/30 dark:text-amber-100"
|
||||||
|
>
|
||||||
|
{{ legacyT('无法读取未决的钱包批量请求。为避免重复扣款,钱包余额调整已禁用;请先核对余额操作结果。') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<UserBatchResultSummary
|
<UserBatchResultSummary
|
||||||
:result="lastResult"
|
:result="lastResult"
|
||||||
:label="lastResultLabel"
|
:label="lastResultLabel"
|
||||||
@@ -69,8 +151,12 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
Button,
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
|
import { Minus, Plus, RotateCcw } from 'lucide-vue-next'
|
||||||
import { useUsersStore } from '@/stores/users'
|
import { useUsersStore } from '@/stores/users'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
@@ -82,11 +168,27 @@ import UserBatchRolePanel from './UserBatchRolePanel.vue'
|
|||||||
import UserBatchTargetSummary from './UserBatchTargetSummary.vue'
|
import UserBatchTargetSummary from './UserBatchTargetSummary.vue'
|
||||||
import { USER_BATCH_ACTION_OPTIONS } from './user-management-config'
|
import { USER_BATCH_ACTION_OPTIONS } from './user-management-config'
|
||||||
import type { UserBatchQuotaMode } from './user-management-types'
|
import type { UserBatchQuotaMode } from './user-management-types'
|
||||||
|
import { buildUserBatchBalanceAdjustmentPayload } from '@/api/users'
|
||||||
|
import {
|
||||||
|
createUserBatchWalletRetryCoordinator,
|
||||||
|
matchesPendingWalletRequest,
|
||||||
|
WalletIdempotencyCoordinationUnavailableError,
|
||||||
|
WalletIdempotencyPersistenceUnavailableError,
|
||||||
|
WalletIdempotencyRequestInProgressError,
|
||||||
|
WalletIdempotencyScopeChangedError,
|
||||||
|
WalletIdempotencyScopeUnavailableError,
|
||||||
|
WalletIdempotencyUnavailableError,
|
||||||
|
} from '../utils/userBatchWalletIdempotency'
|
||||||
|
import type {
|
||||||
|
PendingUserBatchWalletRequest,
|
||||||
|
UserBatchWalletAdjustmentRequest,
|
||||||
|
} from '../utils/userBatchWalletIdempotency'
|
||||||
import type {
|
import type {
|
||||||
UserBatchAccessControlPayload,
|
UserBatchAccessControlPayload,
|
||||||
UserBatchAction,
|
UserBatchAction,
|
||||||
UserBatchActionRequest,
|
UserBatchActionRequest,
|
||||||
UserBatchActionResponse,
|
UserBatchActionResponse,
|
||||||
|
UserBatchBalanceOperation,
|
||||||
UserBatchRolePayload,
|
UserBatchRolePayload,
|
||||||
UserBatchSelection,
|
UserBatchSelection,
|
||||||
UserBatchSelectionFilters,
|
UserBatchSelectionFilters,
|
||||||
@@ -110,22 +212,51 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const usersStore = useUsersStore()
|
const usersStore = useUsersStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const walletRetryCoordinator = createUserBatchWalletRetryCoordinator({
|
||||||
|
scope: () => authStore.user?.id ?? null,
|
||||||
|
})
|
||||||
const { success, warning, error } = useToast()
|
const { success, warning, error } = useToast()
|
||||||
const { legacyT, locale } = useI18n()
|
const { legacyT, locale } = useI18n()
|
||||||
|
|
||||||
const selectedAction = ref<UserBatchAction>('enable')
|
const selectedAction = ref<UserBatchAction>('enable')
|
||||||
const targetRole = ref<UserRole>('user')
|
const targetRole = ref<UserRole>('user')
|
||||||
const quotaMode = ref<UserBatchQuotaMode>('skip')
|
const quotaMode = ref<UserBatchQuotaMode>('skip')
|
||||||
|
const balanceOperation = ref<UserBatchBalanceOperation>('add')
|
||||||
|
const balanceAmount = ref('')
|
||||||
const selectedGroupIds = ref<string[]>([])
|
const selectedGroupIds = ref<string[]>([])
|
||||||
const previewLoading = ref(false)
|
const previewLoading = ref(false)
|
||||||
const previewItems = ref<UserBatchSelectionItem[]>([])
|
const previewItems = ref<UserBatchSelectionItem[]>([])
|
||||||
const resolvedTotal = ref<number | null>(null)
|
const resolvedTotal = ref<number | null>(null)
|
||||||
const executing = ref(false)
|
const executing = ref(false)
|
||||||
const lastResult = ref<UserBatchActionResponse | null>(null)
|
const lastResult = ref<UserBatchActionResponse | null>(null)
|
||||||
|
const pendingWalletBatch = ref<PendingUserBatchWalletRequest | null>(null)
|
||||||
|
const pendingWalletReadError = ref(false)
|
||||||
|
|
||||||
const hasAnyTarget = computed(() => props.selectedCount > 0 || selectedGroupIds.value.length > 0)
|
const hasAnyTarget = computed(() => props.selectedCount > 0 || selectedGroupIds.value.length > 0)
|
||||||
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
|
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
|
||||||
const canExecute = computed(() => hasAnyTarget.value && !previewLoading.value && !executing.value)
|
const balancePayload = computed(() => buildUserBatchBalanceAdjustmentPayload(
|
||||||
|
balanceOperation.value,
|
||||||
|
balanceAmount.value,
|
||||||
|
))
|
||||||
|
const walletAdjustmentRequest = computed<UserBatchWalletAdjustmentRequest | null>(() => (
|
||||||
|
balancePayload.value === null
|
||||||
|
? null
|
||||||
|
: { selection: buildSelection(), action: 'adjust_wallet_balance', payload: balancePayload.value }
|
||||||
|
))
|
||||||
|
const walletRequestMismatch = computed(() => (
|
||||||
|
pendingWalletBatch.value !== null
|
||||||
|
&& selectedAction.value === 'adjust_wallet_balance'
|
||||||
|
&& (walletAdjustmentRequest.value === null
|
||||||
|
|| !matchesPendingWalletRequest(pendingWalletBatch.value, walletAdjustmentRequest.value))
|
||||||
|
))
|
||||||
|
const canExecute = computed(() => (
|
||||||
|
hasAnyTarget.value
|
||||||
|
&& !previewLoading.value
|
||||||
|
&& !executing.value
|
||||||
|
&& (selectedAction.value !== 'adjust_wallet_balance'
|
||||||
|
|| (balancePayload.value !== null && !pendingWalletReadError.value && !walletRequestMismatch.value))
|
||||||
|
))
|
||||||
const selectedActionLabel = computed(() => (
|
const selectedActionLabel = computed(() => (
|
||||||
USER_BATCH_ACTION_OPTIONS.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
|
USER_BATCH_ACTION_OPTIONS.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
|
||||||
))
|
))
|
||||||
@@ -143,13 +274,25 @@ const targetRoleWarning = computed(() => {
|
|||||||
return legacyT('提示:设置为普通用户会移除目标用户的管理员权限。')
|
return legacyT('提示:设置为普通用户会移除目标用户的管理员权限。')
|
||||||
})
|
})
|
||||||
const executeButtonLabel = computed(() => legacyT(`确认${selectedActionLabel.value}(${impactCount.value})`))
|
const executeButtonLabel = computed(() => legacyT(`确认${selectedActionLabel.value}(${impactCount.value})`))
|
||||||
|
const pendingWalletOperationLabel = computed(() => (
|
||||||
|
pendingWalletBatch.value?.request.payload.operation === 'deduct'
|
||||||
|
? legacyT('扣减')
|
||||||
|
: legacyT('增加')
|
||||||
|
))
|
||||||
const lastResultLabel = computed(() => {
|
const lastResultLabel = computed(() => {
|
||||||
if (!lastResult.value) return ''
|
if (!lastResult.value) return ''
|
||||||
|
if (lastResult.value.interrupted) {
|
||||||
|
return legacyT(
|
||||||
|
`批量操作中断:成功 ${lastResult.value.success} 个,结果待确认 ${lastResult.value.uncertain_user_ids?.length ?? 0} 个,尚未执行 ${lastResult.value.unprocessed_user_ids?.length ?? 0} 个`,
|
||||||
|
)
|
||||||
|
}
|
||||||
return legacyT(`成功 ${lastResult.value.success} 个,失败 ${lastResult.value.failed} 个`)
|
return legacyT(`成功 ${lastResult.value.success} 个,失败 ${lastResult.value.failed} 个`)
|
||||||
})
|
})
|
||||||
const lastResultFailuresLabel = computed(() => {
|
const lastResultFailuresLabel = computed(() => {
|
||||||
if (!lastResult.value || lastResult.value.failures.length === 0) return ''
|
if (!lastResult.value || lastResult.value.failures.length === 0) return ''
|
||||||
const failures = lastResult.value.failures.slice(0, 3).map((item) => `${item.user_id} ${item.reason}`).join(locale.value === 'en-US' ? '; ' : ';')
|
const failures = lastResult.value.failures.slice(0, 3)
|
||||||
|
.map((item) => `${item.user_id} ${legacyT(item.reason)}`)
|
||||||
|
.join(locale.value === 'en-US' ? '; ' : ';')
|
||||||
return locale.value === 'en-US' ? `: ${failures}` : `:${failures}`
|
return locale.value === 'en-US' ? `: ${failures}` : `:${failures}`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,8 +301,15 @@ watch(
|
|||||||
(open) => {
|
(open) => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
resetLocalState()
|
resetLocalState()
|
||||||
|
refreshPendingWalletBatch()
|
||||||
void resolvePreview()
|
void resolvePreview()
|
||||||
},
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => authStore.user?.id,
|
||||||
|
() => refreshPendingWalletBatch(),
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -177,10 +327,22 @@ function resetLocalState(): void {
|
|||||||
selectedAction.value = 'enable'
|
selectedAction.value = 'enable'
|
||||||
targetRole.value = 'user'
|
targetRole.value = 'user'
|
||||||
quotaMode.value = 'skip'
|
quotaMode.value = 'skip'
|
||||||
|
balanceOperation.value = 'add'
|
||||||
|
balanceAmount.value = ''
|
||||||
selectedGroupIds.value = []
|
selectedGroupIds.value = []
|
||||||
lastResult.value = null
|
lastResult.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshPendingWalletBatch(): void {
|
||||||
|
try {
|
||||||
|
pendingWalletBatch.value = walletRetryCoordinator.getPending()
|
||||||
|
pendingWalletReadError.value = false
|
||||||
|
} catch {
|
||||||
|
pendingWalletBatch.value = null
|
||||||
|
pendingWalletReadError.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildSelection(): UserBatchSelection {
|
function buildSelection(): UserBatchSelection {
|
||||||
const group_ids = selectedGroupIds.value.length > 0 ? [...selectedGroupIds.value] : undefined
|
const group_ids = selectedGroupIds.value.length > 0 ? [...selectedGroupIds.value] : undefined
|
||||||
if (props.selectAllFiltered) {
|
if (props.selectAllFiltered) {
|
||||||
@@ -227,7 +389,8 @@ function buildRolePayload(): UserBatchRolePayload {
|
|||||||
async function executeBatchAction(): Promise<void> {
|
async function executeBatchAction(): Promise<void> {
|
||||||
if (!canExecute.value) return
|
if (!canExecute.value) return
|
||||||
const selection = buildSelection()
|
const selection = buildSelection()
|
||||||
let request: UserBatchActionRequest
|
let request: Exclude<UserBatchActionRequest, { action: 'adjust_wallet_balance' }> | null = null
|
||||||
|
let walletRequest: UserBatchWalletAdjustmentRequest | null = null
|
||||||
if (selectedAction.value === 'update_access_control') {
|
if (selectedAction.value === 'update_access_control') {
|
||||||
const payload = buildAccessControlPayload()
|
const payload = buildAccessControlPayload()
|
||||||
if (payload === null) {
|
if (payload === null) {
|
||||||
@@ -235,6 +398,12 @@ async function executeBatchAction(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
request = { selection, action: 'update_access_control', payload }
|
request = { selection, action: 'update_access_control', payload }
|
||||||
|
} else if (selectedAction.value === 'adjust_wallet_balance') {
|
||||||
|
if (walletAdjustmentRequest.value === null) {
|
||||||
|
warning(legacyT('请输入大于 0 的有限金额'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
walletRequest = walletAdjustmentRequest.value
|
||||||
} else if (selectedAction.value === 'update_role') {
|
} else if (selectedAction.value === 'update_role') {
|
||||||
request = { selection, action: 'update_role', payload: buildRolePayload() }
|
request = { selection, action: 'update_role', payload: buildRolePayload() }
|
||||||
} else {
|
} else {
|
||||||
@@ -243,8 +412,22 @@ async function executeBatchAction(): Promise<void> {
|
|||||||
|
|
||||||
executing.value = true
|
executing.value = true
|
||||||
try {
|
try {
|
||||||
|
if (walletRequest) {
|
||||||
|
const result = await walletRetryCoordinator.execute(
|
||||||
|
walletRequest,
|
||||||
|
(keyedRequest) => usersStore.batchAction(keyedRequest),
|
||||||
|
)
|
||||||
|
handleWalletBatchResult(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (request === null) return
|
||||||
const result = await usersStore.batchAction(request)
|
const result = await usersStore.batchAction(request)
|
||||||
lastResult.value = result
|
lastResult.value = result
|
||||||
|
if (result.interrupted) {
|
||||||
|
warning(`${lastResultLabel.value};${legacyT('请核对余额后再重试,勿直接重试整批')}`)
|
||||||
|
emit('completed', result)
|
||||||
|
return
|
||||||
|
}
|
||||||
const message = legacyT(`批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
|
const message = legacyT(`批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
|
||||||
if (result.failed > 0) {
|
if (result.failed > 0) {
|
||||||
warning(message)
|
warning(message)
|
||||||
@@ -253,9 +436,71 @@ async function executeBatchAction(): Promise<void> {
|
|||||||
}
|
}
|
||||||
emit('completed', result)
|
emit('completed', result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error(parseApiError(err, '批量操作失败'), legacyT('批量操作失败'))
|
if (walletRequest) {
|
||||||
|
refreshPendingWalletBatch()
|
||||||
|
if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
|
||||||
|
warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
|
||||||
|
} else if (err instanceof WalletIdempotencyRequestInProgressError) {
|
||||||
|
warning(legacyT('另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。'))
|
||||||
|
} else if (err instanceof WalletIdempotencyCoordinationUnavailableError) {
|
||||||
|
warning(legacyT('当前浏览器无法保护跨标签页的钱包批量请求,请使用支持此功能的浏览器。请求未发送。'))
|
||||||
|
} else if (
|
||||||
|
err instanceof WalletIdempotencyUnavailableError
|
||||||
|
|| err instanceof WalletIdempotencyScopeUnavailableError
|
||||||
|
|| err instanceof WalletIdempotencyScopeChangedError
|
||||||
|
) {
|
||||||
|
warning(legacyT('无法确认管理员身份或安全生成钱包批量请求标识,请求未发送。'))
|
||||||
|
} else {
|
||||||
|
warning(legacyT('钱包批量调整结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。'))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error(legacyT(parseApiError(err, '批量操作失败')), legacyT('批量操作失败'))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
executing.value = false
|
executing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function retryPendingWalletBatch(): Promise<void> {
|
||||||
|
if (executing.value) return
|
||||||
|
executing.value = true
|
||||||
|
try {
|
||||||
|
const result = await walletRetryCoordinator.retry(
|
||||||
|
(request) => usersStore.batchAction(request),
|
||||||
|
)
|
||||||
|
if (result) handleWalletBatchResult(result)
|
||||||
|
else refreshPendingWalletBatch()
|
||||||
|
} catch (err) {
|
||||||
|
refreshPendingWalletBatch()
|
||||||
|
if (err instanceof WalletIdempotencyPersistenceUnavailableError) {
|
||||||
|
warning(legacyT('浏览器无法安全保存钱包批量请求,本次请求未发送。'))
|
||||||
|
} else if (err instanceof WalletIdempotencyRequestInProgressError) {
|
||||||
|
warning(legacyT('另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。'))
|
||||||
|
} else if (err instanceof WalletIdempotencyCoordinationUnavailableError) {
|
||||||
|
warning(legacyT('当前浏览器无法保护跨标签页的钱包批量请求,请使用支持此功能的浏览器。请求未发送。'))
|
||||||
|
} else if (
|
||||||
|
err instanceof WalletIdempotencyScopeUnavailableError
|
||||||
|
|| err instanceof WalletIdempotencyScopeChangedError
|
||||||
|
) {
|
||||||
|
warning(legacyT('无法确认管理员身份,请求未发送。'))
|
||||||
|
} else {
|
||||||
|
warning(legacyT('钱包批量调整结果未知或可能部分完成。请重试原请求,不要开始新的余额调整。'))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
executing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWalletBatchResult(result: UserBatchActionResponse): void {
|
||||||
|
lastResult.value = result
|
||||||
|
refreshPendingWalletBatch()
|
||||||
|
if (result.interrupted) {
|
||||||
|
warning(`${lastResultLabel.value};${legacyT('结果可能部分完成,请仅重试原请求,不要开始新的余额调整。')}`)
|
||||||
|
} else {
|
||||||
|
const message = legacyT(`批量操作完成:成功 ${result.success} 个,失败 ${result.failed} 个`)
|
||||||
|
if (result.failed > 0) warning(message)
|
||||||
|
else success(message)
|
||||||
|
}
|
||||||
|
emit('completed', result)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,11 +7,26 @@
|
|||||||
<span v-if="failuresLabel">
|
<span v-if="failuresLabel">
|
||||||
{{ failuresLabel }}
|
{{ failuresLabel }}
|
||||||
</span>
|
</span>
|
||||||
|
<details v-if="result.interrupted" class="mt-2 border-t border-border/60 pt-2">
|
||||||
|
<summary class="cursor-pointer select-none">{{ legacyT('查看批次中断详情') }}</summary>
|
||||||
|
<div class="mt-2 max-h-32 space-y-1 overflow-auto break-all">
|
||||||
|
<p>{{ legacyT('已完成用户 ID') }}:{{ userIds(result.completed_user_ids) }}</p>
|
||||||
|
<p>{{ legacyT('结果待核对用户 ID') }}:{{ userIds(result.uncertain_user_ids) }}</p>
|
||||||
|
<p>{{ legacyT('尚未执行用户 ID') }}:{{ userIds(result.unprocessed_user_ids) }}</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserBatchActionResponse } from '@/api/users'
|
import type { UserBatchActionResponse } from '@/api/users'
|
||||||
|
import { useI18n } from '@/i18n'
|
||||||
|
|
||||||
|
const { legacyT } = useI18n()
|
||||||
|
|
||||||
|
function userIds(ids?: string[]): string {
|
||||||
|
return ids && ids.length > 0 ? ids.join(', ') : '-'
|
||||||
|
}
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
result: UserBatchActionResponse | null
|
result: UserBatchActionResponse | null
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
UserCog,
|
UserCog,
|
||||||
|
Wallet,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import type { UserBatchAction, UserRole } from '@/api/users'
|
import type { UserBatchAction, UserRole } from '@/api/users'
|
||||||
@@ -64,6 +65,12 @@ export const USER_BATCH_ACTION_OPTIONS: UserBatchActionOption[] = [
|
|||||||
description: '批量设为普通用户或管理员',
|
description: '批量设为普通用户或管理员',
|
||||||
icon: UserCog,
|
icon: UserCog,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'adjust_wallet_balance',
|
||||||
|
label: '调整余额',
|
||||||
|
description: '批量增加或扣减钱包余额',
|
||||||
|
icon: Wallet,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function formatUserRoleLabel(role: UserRole | string): string {
|
export function formatUserRoleLabel(role: UserRole | string): string {
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { UserBatchActionResponse, UserBatchBalanceActionRequest } from '@/api/users'
|
||||||
|
import {
|
||||||
|
createUserBatchWalletRetryCoordinator,
|
||||||
|
WalletIdempotencyCoordinationUnavailableError,
|
||||||
|
WalletIdempotencyRequestInProgressError,
|
||||||
|
UnresolvedWalletRequestMismatchError,
|
||||||
|
WalletIdempotencyPersistenceUnavailableError,
|
||||||
|
WalletIdempotencyUnavailableError,
|
||||||
|
} from '../userBatchWalletIdempotency'
|
||||||
|
|
||||||
|
function createStorage() {
|
||||||
|
const values = new Map<string, string>()
|
||||||
|
return {
|
||||||
|
getItem: (key: string) => values.get(key) ?? null,
|
||||||
|
setItem: (key: string, value: string) => values.set(key, value),
|
||||||
|
removeItem: (key: string) => values.delete(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const walletRequest = {
|
||||||
|
selection: { user_ids: ['user-1', 'user-2'], group_ids: ['group-1'] },
|
||||||
|
action: 'adjust_wallet_balance' as const,
|
||||||
|
payload: { operation: 'deduct' as const, amount: 17.25 },
|
||||||
|
}
|
||||||
|
const defaultStorageKey = 'admin.users.batch.wallet-adjustment.pending.v1:default'
|
||||||
|
let testFallback: Map<string, string>
|
||||||
|
let testLockManager: Pick<LockManager, 'request'>
|
||||||
|
|
||||||
|
type CoordinatorOptions = NonNullable<Parameters<typeof createUserBatchWalletRetryCoordinator>[0]>
|
||||||
|
|
||||||
|
function createTestLockManager(): Pick<LockManager, 'request'> {
|
||||||
|
const heldNames = new Set<string>()
|
||||||
|
const request = async <T>(
|
||||||
|
name: string,
|
||||||
|
_options: LockOptions,
|
||||||
|
callback: (lock: Lock | null) => Promise<T>,
|
||||||
|
): Promise<T> => {
|
||||||
|
if (heldNames.has(name)) return callback(null)
|
||||||
|
heldNames.add(name)
|
||||||
|
try {
|
||||||
|
return await callback({ name, mode: 'exclusive' } as Lock)
|
||||||
|
} finally {
|
||||||
|
heldNames.delete(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { request } as unknown as Pick<LockManager, 'request'>
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCoordinator(options: Omit<CoordinatorOptions, 'lockManager'> = {}) {
|
||||||
|
return createUserBatchWalletRetryCoordinator({ ...options, lockManager: testLockManager })
|
||||||
|
}
|
||||||
|
|
||||||
|
function response(interrupted = false): UserBatchActionResponse {
|
||||||
|
return { total: 2, success: 1, failed: 0, failures: [], interrupted }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('user batch wallet idempotency', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear()
|
||||||
|
localStorage.clear()
|
||||||
|
testFallback = new Map()
|
||||||
|
testLockManager = createTestLockManager()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('serializes the key with the exact top-level wallet request before sending', async () => {
|
||||||
|
const storage = createStorage()
|
||||||
|
const coordinator = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => 'wallet-key-1',
|
||||||
|
})
|
||||||
|
let storedDuringSend: string | null = null
|
||||||
|
let sentRequest: UserBatchBalanceActionRequest | undefined
|
||||||
|
|
||||||
|
await coordinator.execute(walletRequest, async (request) => {
|
||||||
|
sentRequest = request
|
||||||
|
storedDuringSend = storage.getItem(defaultStorageKey)
|
||||||
|
return response(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(sentRequest).toEqual({ ...walletRequest, idempotency_key: 'wallet-key-1' })
|
||||||
|
expect(JSON.parse(storedDuringSend ?? 'null')).toEqual({
|
||||||
|
idempotency_key: 'wallet-key-1',
|
||||||
|
request: sentRequest,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a second tab while the same wallet batch request is in progress', async () => {
|
||||||
|
const storage = createStorage()
|
||||||
|
const first = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => 'wallet-key-first-tab',
|
||||||
|
})
|
||||||
|
const second = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => 'wallet-key-second-tab',
|
||||||
|
})
|
||||||
|
let resolveFirst!: (value: UserBatchActionResponse) => void
|
||||||
|
const firstSend = vi.fn(() => new Promise<UserBatchActionResponse>((resolve) => {
|
||||||
|
resolveFirst = resolve
|
||||||
|
}))
|
||||||
|
const secondSend = vi.fn(async () => response())
|
||||||
|
const firstExecution = first.execute(walletRequest, firstSend)
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(firstSend).toHaveBeenCalledOnce())
|
||||||
|
await expect(second.execute(walletRequest, secondSend)).rejects.toBeInstanceOf(
|
||||||
|
WalletIdempotencyRequestInProgressError,
|
||||||
|
)
|
||||||
|
expect(secondSend).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
resolveFirst(response())
|
||||||
|
await expect(firstExecution).resolves.toEqual(response())
|
||||||
|
expect(firstSend).toHaveBeenCalledOnce()
|
||||||
|
expect(storage.getItem(defaultStorageKey)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails closed when cross-tab request coordination is unavailable', async () => {
|
||||||
|
const storage = createStorage()
|
||||||
|
const send = vi.fn(async () => response())
|
||||||
|
const coordinator = createUserBatchWalletRetryCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
lockManager: null,
|
||||||
|
createKey: () => 'wallet-key-without-locks',
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
|
||||||
|
WalletIdempotencyCoordinationUnavailableError,
|
||||||
|
)
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
expect(storage.getItem(defaultStorageKey)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains a transport failure and reopens with the exact request for retry', async () => {
|
||||||
|
const storage = createStorage()
|
||||||
|
const first = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => 'wallet-key-2',
|
||||||
|
})
|
||||||
|
const sendFailure = new Error('connection lost')
|
||||||
|
await expect(first.execute(walletRequest, async () => { throw sendFailure })).rejects.toBe(sendFailure)
|
||||||
|
|
||||||
|
const reopened = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => 'must-not-be-used',
|
||||||
|
})
|
||||||
|
const pending = reopened.getPending()
|
||||||
|
expect(pending).toEqual({
|
||||||
|
idempotency_key: 'wallet-key-2',
|
||||||
|
request: { ...walletRequest, idempotency_key: 'wallet-key-2' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const send = vi.fn(async () => response())
|
||||||
|
await expect(reopened.retry(send)).resolves.toEqual(response())
|
||||||
|
expect(send).toHaveBeenCalledWith(pending?.request)
|
||||||
|
expect(storage.getItem(defaultStorageKey)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps unresolved requests in persistent browser storage across coordinators', async () => {
|
||||||
|
const first = createCoordinator({
|
||||||
|
createKey: () => 'wallet-key-persistent',
|
||||||
|
scope: () => 'admin-1',
|
||||||
|
})
|
||||||
|
await expect(first.execute(walletRequest, async () => {
|
||||||
|
throw new Error('connection lost')
|
||||||
|
})).rejects.toThrow('connection lost')
|
||||||
|
|
||||||
|
const reopened = createCoordinator({ scope: () => 'admin-1' })
|
||||||
|
const pending = reopened.getPending()
|
||||||
|
expect(pending?.request).toEqual({
|
||||||
|
...walletRequest,
|
||||||
|
idempotency_key: 'wallet-key-persistent',
|
||||||
|
})
|
||||||
|
|
||||||
|
const send = vi.fn(async () => response())
|
||||||
|
await reopened.retry(send)
|
||||||
|
expect(send).toHaveBeenCalledWith(pending?.request)
|
||||||
|
expect(localStorage.getItem(
|
||||||
|
'admin.users.batch.wallet-adjustment.pending.v1:admin-1',
|
||||||
|
)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps unresolved requests isolated by authenticated administrator', async () => {
|
||||||
|
const storage = createStorage()
|
||||||
|
const adminA = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
scope: () => 'admin-a',
|
||||||
|
createKey: () => 'wallet-key-admin-a',
|
||||||
|
})
|
||||||
|
await expect(adminA.execute(walletRequest, async () => { throw new Error('connection lost') }))
|
||||||
|
.rejects.toThrow('connection lost')
|
||||||
|
|
||||||
|
const adminB = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
scope: () => 'admin-b',
|
||||||
|
createKey: () => 'wallet-key-admin-b',
|
||||||
|
})
|
||||||
|
expect(adminB.getPending()).toBeNull()
|
||||||
|
await adminB.execute(walletRequest, async () => response(true))
|
||||||
|
|
||||||
|
expect(adminA.getPending()?.idempotency_key).toBe('wallet-key-admin-a')
|
||||||
|
expect(adminB.getPending()?.idempotency_key).toBe('wallet-key-admin-b')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send if the authenticated administrator changes before dispatch', async () => {
|
||||||
|
const storage = createStorage()
|
||||||
|
let scopeReads = 0
|
||||||
|
const send = vi.fn(async () => response())
|
||||||
|
const coordinator = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
scope: () => (++scopeReads === 1 ? 'admin-a' : 'admin-b'),
|
||||||
|
createKey: () => 'wallet-key-scope-change',
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(coordinator.execute(walletRequest, send)).rejects.toThrow(
|
||||||
|
'authenticated administrator changed',
|
||||||
|
)
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
expect(storage.getItem('admin.users.batch.wallet-adjustment.pending.v1:admin-a')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains interrupted requests and reuses their key until a terminal response', async () => {
|
||||||
|
const storage = createStorage()
|
||||||
|
const first = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => 'wallet-key-3',
|
||||||
|
})
|
||||||
|
await first.execute(walletRequest, async () => response(true))
|
||||||
|
const reopened = createCoordinator({ storage, fallback: testFallback })
|
||||||
|
const pending = reopened.getPending()
|
||||||
|
const send = vi.fn(async () => response(true))
|
||||||
|
|
||||||
|
await reopened.retry(send)
|
||||||
|
|
||||||
|
expect(send).toHaveBeenCalledWith(pending?.request)
|
||||||
|
expect(reopened.getPending()).toEqual(pending)
|
||||||
|
await reopened.retry(async (request) => {
|
||||||
|
expect(request).toEqual(pending?.request)
|
||||||
|
return response()
|
||||||
|
})
|
||||||
|
expect(reopened.getPending()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('matches the same serialized request when optional filter fields are omitted', async () => {
|
||||||
|
const storage = createStorage()
|
||||||
|
const requestWithUndefinedField = {
|
||||||
|
...walletRequest,
|
||||||
|
selection: { filters: { search: 'active', is_active: undefined } },
|
||||||
|
}
|
||||||
|
const first = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => 'wallet-key-filter',
|
||||||
|
})
|
||||||
|
await first.execute(requestWithUndefinedField, async () => response(true))
|
||||||
|
|
||||||
|
const reopened = createCoordinator({ storage, fallback: testFallback })
|
||||||
|
const send = vi.fn(async () => response())
|
||||||
|
await reopened.execute({
|
||||||
|
...walletRequest,
|
||||||
|
selection: { filters: { search: 'active' } },
|
||||||
|
}, send)
|
||||||
|
|
||||||
|
expect(send).toHaveBeenCalledWith({
|
||||||
|
...walletRequest,
|
||||||
|
selection: { filters: { search: 'active' } },
|
||||||
|
idempotency_key: 'wallet-key-filter',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks changed payloads while unresolved and gives a later adjustment a new key', async () => {
|
||||||
|
const storage = createStorage()
|
||||||
|
let nextKey = 0
|
||||||
|
const coordinator = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => `wallet-key-${++nextKey}`,
|
||||||
|
})
|
||||||
|
await expect(coordinator.execute(walletRequest, async () => { throw new Error('connection lost') }))
|
||||||
|
.rejects.toThrow('connection lost')
|
||||||
|
const changedRequest = {
|
||||||
|
...walletRequest,
|
||||||
|
payload: { operation: 'add' as const, amount: 20 },
|
||||||
|
}
|
||||||
|
const send = vi.fn(async () => response())
|
||||||
|
|
||||||
|
await expect(coordinator.execute(changedRequest, send)).rejects.toBeInstanceOf(
|
||||||
|
UnresolvedWalletRequestMismatchError,
|
||||||
|
)
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
expect(coordinator.getPending()?.idempotency_key).toBe('wallet-key-1')
|
||||||
|
|
||||||
|
await coordinator.retry(async () => response())
|
||||||
|
let newRequest
|
||||||
|
await coordinator.execute(changedRequest, async (request) => {
|
||||||
|
newRequest = request
|
||||||
|
return response()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(newRequest).toEqual({ ...changedRequest, idempotency_key: 'wallet-key-2' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails closed when persistent storage cannot save the request', async () => {
|
||||||
|
const unavailableStorage = {
|
||||||
|
getItem: () => null,
|
||||||
|
setItem: () => { throw new Error('storage unavailable') },
|
||||||
|
removeItem: () => undefined,
|
||||||
|
}
|
||||||
|
const send = vi.fn(async () => response(true))
|
||||||
|
const coordinator = createCoordinator({
|
||||||
|
storage: unavailableStorage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => 'wallet-key-fallback',
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
|
||||||
|
WalletIdempotencyPersistenceUnavailableError,
|
||||||
|
)
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
expect(testFallback.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not send when persistent storage readback does not match', async () => {
|
||||||
|
let wasWritten = false
|
||||||
|
const mismatchedStorage = {
|
||||||
|
getItem: () => wasWritten ? 'different request' : null,
|
||||||
|
setItem: () => { wasWritten = true },
|
||||||
|
removeItem: () => undefined,
|
||||||
|
}
|
||||||
|
const send = vi.fn(async () => response())
|
||||||
|
const coordinator = createCoordinator({
|
||||||
|
storage: mismatchedStorage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => 'wallet-key-readback',
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
|
||||||
|
WalletIdempotencyPersistenceUnavailableError,
|
||||||
|
)
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
expect(testFallback.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not create a new request when persistent storage cannot be read', async () => {
|
||||||
|
const unavailableStorage = {
|
||||||
|
getItem: () => { throw new Error('storage unavailable') },
|
||||||
|
setItem: () => undefined,
|
||||||
|
removeItem: () => undefined,
|
||||||
|
}
|
||||||
|
const send = vi.fn(async () => response())
|
||||||
|
const coordinator = createCoordinator({
|
||||||
|
storage: unavailableStorage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => 'must-not-be-used',
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
|
||||||
|
WalletIdempotencyPersistenceUnavailableError,
|
||||||
|
)
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
expect(testFallback.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails closed when secure UUID generation is unavailable', async () => {
|
||||||
|
const storage = createStorage()
|
||||||
|
const send = vi.fn(async () => response())
|
||||||
|
const coordinator = createCoordinator({
|
||||||
|
storage,
|
||||||
|
fallback: testFallback,
|
||||||
|
createKey: () => { throw new WalletIdempotencyUnavailableError() },
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(coordinator.execute(walletRequest, send)).rejects.toBeInstanceOf(
|
||||||
|
WalletIdempotencyUnavailableError,
|
||||||
|
)
|
||||||
|
expect(send).not.toHaveBeenCalled()
|
||||||
|
expect(storage.getItem(defaultStorageKey)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
import type {
|
||||||
|
UserBatchActionResponse,
|
||||||
|
UserBatchBalanceActionRequest,
|
||||||
|
} from '@/api/users'
|
||||||
|
|
||||||
|
export type UserBatchWalletAdjustmentRequest = Omit<
|
||||||
|
UserBatchBalanceActionRequest,
|
||||||
|
'idempotency_key'
|
||||||
|
>
|
||||||
|
|
||||||
|
export interface PendingUserBatchWalletRequest {
|
||||||
|
idempotency_key: string
|
||||||
|
request: UserBatchBalanceActionRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StringStorage {
|
||||||
|
getItem(key: string): string | null
|
||||||
|
setItem(key: string, value: string): void
|
||||||
|
removeItem(key: string): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoordinatorOptions {
|
||||||
|
storage?: StringStorage | null
|
||||||
|
createKey?: () => string
|
||||||
|
fallback?: Map<string, string>
|
||||||
|
lockManager?: Pick<LockManager, 'request'> | null
|
||||||
|
scope?: () => string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'admin.users.batch.wallet-adjustment.pending.v1'
|
||||||
|
const inMemoryFallback = new Map<string, string>()
|
||||||
|
|
||||||
|
export class UnresolvedWalletRequestMismatchError extends Error {
|
||||||
|
constructor(readonly pending: PendingUserBatchWalletRequest) {
|
||||||
|
super('A different wallet batch request is still unresolved')
|
||||||
|
this.name = 'UnresolvedWalletRequestMismatchError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletIdempotencyUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('crypto.randomUUID is unavailable')
|
||||||
|
this.name = 'WalletIdempotencyUnavailableError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletIdempotencyPersistenceUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Persistent browser storage is unavailable')
|
||||||
|
this.name = 'WalletIdempotencyPersistenceUnavailableError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletIdempotencyCoordinationUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Cross-tab wallet request coordination is unavailable')
|
||||||
|
this.name = 'WalletIdempotencyCoordinationUnavailableError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletIdempotencyRequestInProgressError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('A wallet batch request is already in progress in another tab')
|
||||||
|
this.name = 'WalletIdempotencyRequestInProgressError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletIdempotencyScopeUnavailableError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('The authenticated administrator identity is unavailable')
|
||||||
|
this.name = 'WalletIdempotencyScopeUnavailableError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletIdempotencyScopeChangedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('The authenticated administrator changed before the request was sent')
|
||||||
|
this.name = 'WalletIdempotencyScopeChangedError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidPendingWalletRequestError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('The stored wallet batch request is invalid')
|
||||||
|
this.name = 'InvalidPendingWalletRequestError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserPersistentStorage(): StringStorage | null {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserLockManager(): Pick<LockManager, 'request'> | null {
|
||||||
|
try {
|
||||||
|
return globalThis.navigator?.locks ?? null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function secureRandomUUID(): string {
|
||||||
|
try {
|
||||||
|
const cryptoApi = globalThis.crypto
|
||||||
|
if (typeof cryptoApi?.randomUUID === 'function') {
|
||||||
|
return cryptoApi.randomUUID()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Treat unavailable secure randomness as a hard failure.
|
||||||
|
}
|
||||||
|
throw new WalletIdempotencyUnavailableError()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPendingRequest(value: unknown): value is PendingUserBatchWalletRequest {
|
||||||
|
if (typeof value !== 'object' || value === null) return false
|
||||||
|
const record = value as Partial<PendingUserBatchWalletRequest>
|
||||||
|
const request = record.request
|
||||||
|
return typeof record.idempotency_key === 'string'
|
||||||
|
&& record.idempotency_key.length > 0
|
||||||
|
&& typeof request === 'object'
|
||||||
|
&& request !== null
|
||||||
|
&& request.action === 'adjust_wallet_balance'
|
||||||
|
&& request.idempotency_key === record.idempotency_key
|
||||||
|
&& typeof request.selection === 'object'
|
||||||
|
&& request.selection !== null
|
||||||
|
&& typeof request.payload === 'object'
|
||||||
|
&& request.payload !== null
|
||||||
|
&& (request.payload.operation === 'add' || request.payload.operation === 'deduct')
|
||||||
|
&& Number.isFinite(request.payload.amount)
|
||||||
|
&& request.payload.amount > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePendingRequest(serialized: string): PendingUserBatchWalletRequest {
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(serialized)
|
||||||
|
if (isPendingRequest(value)) return value
|
||||||
|
} catch {
|
||||||
|
// Invalid persisted state must not allow a fresh adjustment to be sent.
|
||||||
|
}
|
||||||
|
throw new InvalidPendingWalletRequestError()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableSerialize(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `[${value.map((item) => item === undefined ? 'null' : stableSerialize(item)).join(',')}]`
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
const fields = Object.entries(value as Record<string, unknown>)
|
||||||
|
.filter(([, item]) => item !== undefined)
|
||||||
|
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
||||||
|
return `{${fields.map(([key, item]) => `${JSON.stringify(key)}:${stableSerialize(item)}`).join(',')}}`
|
||||||
|
}
|
||||||
|
return JSON.stringify(value) ?? 'null'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function matchesPendingWalletRequest(
|
||||||
|
pending: PendingUserBatchWalletRequest,
|
||||||
|
request: UserBatchWalletAdjustmentRequest,
|
||||||
|
): boolean {
|
||||||
|
const { idempotency_key: _key, ...pendingPayload } = pending.request
|
||||||
|
return stableSerialize(pendingPayload) === stableSerialize(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserBatchWalletRetryCoordinator(options: CoordinatorOptions = {}) {
|
||||||
|
const storage = 'storage' in options ? options.storage ?? null : browserPersistentStorage()
|
||||||
|
const fallback = options.fallback ?? inMemoryFallback
|
||||||
|
const createKey = options.createKey ?? secureRandomUUID
|
||||||
|
const lockManager = 'lockManager' in options
|
||||||
|
? options.lockManager ?? null
|
||||||
|
: browserLockManager()
|
||||||
|
const getScope = options.scope ?? (() => 'default')
|
||||||
|
|
||||||
|
function getStorageKey(): string {
|
||||||
|
const scope = getScope()
|
||||||
|
if (!scope) throw new WalletIdempotencyScopeUnavailableError()
|
||||||
|
return `${STORAGE_KEY}:${encodeURIComponent(scope)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPending(storageKey: string): PendingUserBatchWalletRequest | null {
|
||||||
|
let serialized: string | null = null
|
||||||
|
let storageReadFailed = false
|
||||||
|
try {
|
||||||
|
serialized = storage?.getItem(storageKey) ?? null
|
||||||
|
} catch {
|
||||||
|
storageReadFailed = true
|
||||||
|
serialized = null
|
||||||
|
}
|
||||||
|
serialized ??= fallback.get(storageKey) ?? null
|
||||||
|
if (serialized === null && (!storage || storageReadFailed)) {
|
||||||
|
throw new WalletIdempotencyPersistenceUnavailableError()
|
||||||
|
}
|
||||||
|
return serialized === null ? null : parsePendingRequest(serialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(storageKey: string, pending: PendingUserBatchWalletRequest): void {
|
||||||
|
const serialized = JSON.stringify(pending)
|
||||||
|
if (!storage) throw new WalletIdempotencyPersistenceUnavailableError()
|
||||||
|
try {
|
||||||
|
storage.setItem(storageKey, serialized)
|
||||||
|
if (storage.getItem(storageKey) !== serialized) {
|
||||||
|
throw new Error('Stored wallet batch request could not be verified')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw new WalletIdempotencyPersistenceUnavailableError()
|
||||||
|
}
|
||||||
|
fallback.set(storageKey, serialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(storageKey: string): void {
|
||||||
|
fallback.delete(storageKey)
|
||||||
|
try {
|
||||||
|
storage?.removeItem(storageKey)
|
||||||
|
} catch {
|
||||||
|
// A stale persisted request is safe to replay and will fail closed on mismatch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withExclusiveLock<T>(storageKey: string, task: () => Promise<T>): Promise<T> {
|
||||||
|
if (!lockManager) throw new WalletIdempotencyCoordinationUnavailableError()
|
||||||
|
|
||||||
|
let taskStarted = false
|
||||||
|
try {
|
||||||
|
return await lockManager.request(
|
||||||
|
storageKey,
|
||||||
|
{ mode: 'exclusive', ifAvailable: true },
|
||||||
|
async (lock) => {
|
||||||
|
if (lock === null) throw new WalletIdempotencyRequestInProgressError()
|
||||||
|
taskStarted = true
|
||||||
|
return task()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
if (taskStarted || error instanceof WalletIdempotencyRequestInProgressError) throw error
|
||||||
|
throw new WalletIdempotencyCoordinationUnavailableError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreate(
|
||||||
|
storageKey: string,
|
||||||
|
request: UserBatchWalletAdjustmentRequest,
|
||||||
|
): UserBatchBalanceActionRequest {
|
||||||
|
const pending = readPending(storageKey)
|
||||||
|
if (pending) {
|
||||||
|
if (!matchesPendingWalletRequest(pending, request)) {
|
||||||
|
throw new UnresolvedWalletRequestMismatchError(pending)
|
||||||
|
}
|
||||||
|
persist(storageKey, pending)
|
||||||
|
return pending.request
|
||||||
|
}
|
||||||
|
|
||||||
|
const idempotencyKey = createKey()
|
||||||
|
if (!idempotencyKey) throw new WalletIdempotencyUnavailableError()
|
||||||
|
const keyedRequest: UserBatchBalanceActionRequest = {
|
||||||
|
...request,
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
}
|
||||||
|
persist(storageKey, { idempotency_key: idempotencyKey, request: keyedRequest })
|
||||||
|
return keyedRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendAndResolve(
|
||||||
|
request: UserBatchBalanceActionRequest,
|
||||||
|
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
|
||||||
|
storageKey: string,
|
||||||
|
): Promise<UserBatchActionResponse> {
|
||||||
|
const response = await send(request)
|
||||||
|
if (!response.interrupted) clear(storageKey)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getPending() {
|
||||||
|
return readPending(getStorageKey())
|
||||||
|
},
|
||||||
|
async execute(
|
||||||
|
request: UserBatchWalletAdjustmentRequest,
|
||||||
|
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
|
||||||
|
) {
|
||||||
|
const storageKey = getStorageKey()
|
||||||
|
return withExclusiveLock(storageKey, async () => {
|
||||||
|
const keyedRequest = getOrCreate(storageKey, request)
|
||||||
|
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
|
||||||
|
return sendAndResolve(keyedRequest, send, storageKey)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async retry(
|
||||||
|
send: (request: UserBatchBalanceActionRequest) => Promise<UserBatchActionResponse>,
|
||||||
|
): Promise<UserBatchActionResponse | null> {
|
||||||
|
const storageKey = getStorageKey()
|
||||||
|
return withExclusiveLock(storageKey, async () => {
|
||||||
|
const pending = readPending(storageKey)
|
||||||
|
if (!pending) return null
|
||||||
|
persist(storageKey, pending)
|
||||||
|
if (getStorageKey() !== storageKey) throw new WalletIdempotencyScopeChangedError()
|
||||||
|
return sendAndResolve(pending.request, send, storageKey)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -613,6 +613,8 @@ export const legacyAdminEnglishMessages: Record<string, string> = {
|
|||||||
'系统调账': 'System adjustment',
|
'系统调账': 'System adjustment',
|
||||||
'退款扣减': 'Refund debit',
|
'退款扣减': 'Refund debit',
|
||||||
'退款回补': 'Refund recredit',
|
'退款回补': 'Refund recredit',
|
||||||
|
'另一个标签页正在处理钱包批量调整,请稍后刷新状态再试。此次未发送新请求。': 'Another tab is processing a wallet batch adjustment. Refresh the status and try again later. No new request was sent from this tab.',
|
||||||
|
'当前浏览器无法保护跨标签页的钱包批量请求,请使用支持此功能的浏览器。请求未发送。': 'This browser cannot protect wallet batch requests across tabs. Use a browser that supports this feature. The request was not sent.',
|
||||||
'兑换码批次已创建': 'Redemption code batch created',
|
'兑换码批次已创建': 'Redemption code batch created',
|
||||||
'CSV 已导出': 'CSV exported',
|
'CSV 已导出': 'CSV exported',
|
||||||
'批次已停用': 'Batch disabled',
|
'批次已停用': 'Batch disabled',
|
||||||
|
|||||||
@@ -1453,6 +1453,27 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
|||||||
'暂无选项': 'No options',
|
'暂无选项': 'No options',
|
||||||
'用户批量操作': 'User batch actions',
|
'用户批量操作': 'User batch actions',
|
||||||
'按当前选择批量调整用户状态、角色和额度': 'Batch update user status, role, and quota for the current selection',
|
'按当前选择批量调整用户状态、角色和额度': 'Batch update user status, role, and quota for the current selection',
|
||||||
|
'按当前选择批量调整用户状态、角色、额度和钱包余额': 'Batch update user status, role, quota, and wallet balances for the current selection',
|
||||||
|
'调整余额': 'Adjust balance',
|
||||||
|
'批量增加或扣减钱包余额': 'Add or deduct wallet balances in bulk',
|
||||||
|
'调整金额 (USD)': 'Adjustment amount (USD)',
|
||||||
|
'余额调整方式': 'Balance adjustment mode',
|
||||||
|
'增加': 'Add',
|
||||||
|
'扣减': 'Deduct',
|
||||||
|
'请输入大于 0 的有限金额': 'Enter a finite amount greater than 0',
|
||||||
|
'扣减超过单个用户可用余额时,该用户余额将归零。': 'Deductions above an individual user’s available balance will be clamped to zero.',
|
||||||
|
'用户钱包不可用': 'User wallet is unavailable',
|
||||||
|
'当前为只读模式,无法批量调整用户钱包余额': 'Cannot adjust user wallet balances in read-only mode',
|
||||||
|
'余额调整结果未确认,批次已中止,请核对钱包后再重试': 'Balance adjustment result is uncertain; the batch stopped. Verify the wallet before retrying.',
|
||||||
|
'用户钱包更新结果未确认,批次已中止,请核对钱包后再重试': 'User wallet update result is uncertain; the batch stopped. Verify the wallet before retrying.',
|
||||||
|
'用户更新结果未确认,批次已中止,请核对后再重试': 'User update result is uncertain; the batch stopped. Verify the result before retrying.',
|
||||||
|
'读取用户状态失败,批次已中止,该用户未执行': 'Could not read user state; the batch stopped before processing this user.',
|
||||||
|
'因前序错误未执行': 'Not processed because an earlier operation failed',
|
||||||
|
'请核对余额后再重试,勿直接重试整批': 'Verify balances before retrying; do not retry the entire batch blindly.',
|
||||||
|
'查看批次中断详情': 'View interrupted batch details',
|
||||||
|
'已完成用户 ID': 'Completed user IDs',
|
||||||
|
'结果待核对用户 ID': 'User IDs with uncertain results',
|
||||||
|
'尚未执行用户 ID': 'Unprocessed user IDs',
|
||||||
'影响用户:': 'Affected users:',
|
'影响用户:': 'Affected users:',
|
||||||
'目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。': 'Targets all users matching the current filters; the backend will resolve the selection again before execution.',
|
'目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。': 'Targets all users matching the current filters; the backend will resolve the selection again before execution.',
|
||||||
'目标为当前已勾选的用户,重复 ID 会自动去重。': 'Targets the currently selected users; duplicate IDs are deduplicated automatically.',
|
'目标为当前已勾选的用户,重复 ID 会自动去重。': 'Targets the currently selected users; duplicate IDs are deduplicated automatically.',
|
||||||
@@ -3052,6 +3073,7 @@ const legacyDynamicPatterns: Array<[RegExp, (match: RegExpMatchArray) => string]
|
|||||||
[/^确认(启用|禁用|删除|重置)((.+))$/u, match => `Confirm ${translateLegacyText(match[1], 'en-US').toLowerCase()} (${match[2]})`],
|
[/^确认(启用|禁用|删除|重置)((.+))$/u, match => `Confirm ${translateLegacyText(match[1], 'en-US').toLowerCase()} (${match[2]})`],
|
||||||
[/^成功 (.+) 个,失败 (.+) 个$/u, match => `Succeeded ${match[1]}, failed ${match[2]}`],
|
[/^成功 (.+) 个,失败 (.+) 个$/u, match => `Succeeded ${match[1]}, failed ${match[2]}`],
|
||||||
[/^批量操作完成:成功 (.+) 个,失败 (.+) 个$/u, match => `Batch action complete: succeeded ${match[1]}, failed ${match[2]}`],
|
[/^批量操作完成:成功 (.+) 个,失败 (.+) 个$/u, match => `Batch action complete: succeeded ${match[1]}, failed ${match[2]}`],
|
||||||
|
[/^批量操作中断:成功 (.+) 个,结果待确认 (.+) 个,尚未执行 (.+) 个$/u, match => `Batch interrupted: succeeded ${match[1]}, uncertain ${match[2]}, not processed ${match[3]}`],
|
||||||
[/^匹配 (.+) 个,当前页 (.+) 个,已选 (.+) 个$/u, match => `Matched ${match[1]} items, current page ${match[2]} items, selected ${match[3]} items`],
|
[/^匹配 (.+) 个,当前页 (.+) 个,已选 (.+) 个$/u, match => `Matched ${match[1]} items, current page ${match[2]} items, selected ${match[3]} items`],
|
||||||
[/^已选 (.+) 个$/u, match => `Selected ${match[1]} items`],
|
[/^已选 (.+) 个$/u, match => `Selected ${match[1]} items`],
|
||||||
[/^已强制下线 (.+) 个设备$/u, match => `Signed out ${match[1]} devices`],
|
[/^已强制下线 (.+) 个设备$/u, match => `Signed out ${match[1]} devices`],
|
||||||
|
|||||||
Reference in New Issue
Block a user