feat(admin): batch adjust user wallet balances

This commit is contained in:
RWDai
2026-09-23 11:30:33 +08:00
parent ec95989e02
commit e25e240d16
16 changed files with 655 additions and 33 deletions
@@ -1066,7 +1066,8 @@ impl GatewayDataState {
pub(crate) async fn adjust_wallet_balance(
&self,
input: AdjustWalletBalanceInput,
) -> Result<Option<(StoredWalletSnapshot, StoredAdminWalletTransaction)>, DataLayerError> {
) -> Result<Option<(StoredWalletSnapshot, Option<StoredAdminWalletTransaction>)>, DataLayerError>
{
match &self.wallet_writer {
Some(repository) => repository.adjust_wallet_balance(input).await,
None => Ok(None),
@@ -63,13 +63,14 @@ pub(in super::super) async fn build_admin_wallet_adjust_response(
}
let operator_id = admin_wallet_operator_id(request_context);
let has_wallet_writer = state.has_wallet_data_writer();
let Some((wallet, transaction)) = state
let Some((wallet, Some(transaction))) = state
.admin_adjust_wallet_balance(
&wallet_id,
amount_usd,
&balance_type,
operator_id.as_deref(),
description.as_deref(),
false,
)
.await?
else {
@@ -387,10 +387,11 @@ impl<'a> AdminAppState<'a> {
balance_type: &str,
operator_id: Option<&str>,
description: Option<&str>,
clamp_deduction_to_available_balance: bool,
) -> Result<
Option<(
aether_data::repository::wallet::StoredWalletSnapshot,
crate::AdminWalletTransactionRecord,
Option<crate::AdminWalletTransactionRecord>,
)>,
GatewayError,
> {
@@ -401,6 +402,7 @@ impl<'a> AdminAppState<'a> {
balance_type,
operator_id,
description,
clamp_deduction_to_available_balance,
)
.await
}
@@ -88,6 +88,7 @@ struct AdminUserBatchMutation {
role: Option<String>,
is_active: Option<bool>,
unlimited: Option<bool>,
wallet_balance_adjustment: Option<AdminUserWalletBalanceAdjustment>,
modified_fields: Vec<&'static str>,
}
@@ -97,6 +98,18 @@ impl AdminUserBatchMutation {
}
}
#[derive(Debug, Clone, Copy)]
struct AdminUserWalletBalanceAdjustment {
operation: AdminUserWalletBalanceOperation,
amount: f64,
}
#[derive(Debug, Clone, Copy)]
enum AdminUserWalletBalanceOperation {
Add,
Deduct,
}
pub(in super::super) async fn build_admin_resolve_user_selection_response(
state: &AdminAppState<'_>,
_request_context: &AdminRequestContext<'_>,
@@ -160,6 +173,11 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
"当前为只读模式,无法批量更新用户钱包",
));
}
if mutation.wallet_balance_adjustment.is_some() && !state.has_auth_wallet_write_capability() {
return Ok(build_admin_users_read_only_response(
"当前为只读模式,无法批量调整用户钱包余额",
));
}
let active_admin_demotions = count_active_admin_demotions(&mutation, &resolved.items);
let active_admin_count = if active_admin_demotions > 0 {
state.count_active_admin_users().await?
@@ -211,6 +229,23 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
}
}
if let Some(adjustment) = mutation.wallet_balance_adjustment {
if !apply_batch_user_wallet_balance_adjustment(
state,
&item.user_id,
adjustment,
current_admin_user_id,
)
.await?
{
failures.push(json!({
"user_id": item.user_id,
"reason": "用户钱包不可用",
}));
continue;
}
}
if mutation.has_auth_user_fields()
&& state
.update_local_auth_user_admin_fields(
@@ -604,10 +639,37 @@ fn parse_batch_mutation(
}),
"update_access_control" => parse_access_control_mutation(payload),
"update_role" => parse_role_mutation(payload),
"adjust_wallet_balance" => parse_wallet_balance_adjustment_mutation(payload),
_ => Err("不支持的批量操作".to_string()),
}
}
fn parse_wallet_balance_adjustment_mutation(
payload: Option<Value>,
) -> Result<AdminUserBatchMutation, String> {
let Some(Value::Object(payload)) = payload else {
return Err("payload 必须是对象".to_string());
};
let operation = match payload.get("operation").and_then(Value::as_str) {
Some("add") => AdminUserWalletBalanceOperation::Add,
Some("deduct") => AdminUserWalletBalanceOperation::Deduct,
_ => return Err("operation 必须为 add 或 deduct".to_string()),
};
let amount = payload
.get("amount")
.and_then(Value::as_f64)
.ok_or_else(|| "amount 必须为大于 0 的有限数字".to_string())?;
if !amount.is_finite() || amount <= 0.0 {
return Err("amount 必须为大于 0 的有限数字".to_string());
}
Ok(AdminUserBatchMutation {
wallet_balance_adjustment: Some(AdminUserWalletBalanceAdjustment { operation, amount }),
modified_fields: vec!["wallet_balance"],
..AdminUserBatchMutation::default()
})
}
fn parse_role_mutation(payload: Option<Value>) -> Result<AdminUserBatchMutation, String> {
let Some(Value::Object(payload)) = payload else {
return Err("payload 必须是对象".to_string());
@@ -724,6 +786,39 @@ async fn apply_batch_user_wallet_limit_mode(
}
}
async fn apply_batch_user_wallet_balance_adjustment(
state: &AdminAppState<'_>,
user_id: &str,
adjustment: AdminUserWalletBalanceAdjustment,
operator_id: Option<&str>,
) -> Result<bool, GatewayError> {
// 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?
else {
return Ok(false);
};
let amount = match adjustment.operation {
AdminUserWalletBalanceOperation::Add => adjustment.amount,
AdminUserWalletBalanceOperation::Deduct => -adjustment.amount,
};
Ok(state
.admin_adjust_wallet_balance(
&wallet.id,
amount,
"recharge",
operator_id,
Some("管理员批量调整用户余额"),
true,
)
.await?
.is_some())
}
fn build_admin_user_batch_bad_request_response(detail: String) -> Response<Body> {
if detail.as_str() == "缺少 user_id" {
return build_admin_users_bad_request_response("缺少 user_id");
@@ -10,10 +10,11 @@ impl AppState {
balance_type: &str,
operator_id: Option<&str>,
description: Option<&str>,
clamp_deduction_to_available_balance: bool,
) -> Result<
Option<(
aether_data::repository::wallet::StoredWalletSnapshot,
AdminWalletTransactionRecord,
Option<AdminWalletTransactionRecord>,
)>,
GatewayError,
> {
@@ -27,6 +28,14 @@ impl AppState {
let before_recharge = wallet.balance;
let before_gift = wallet.gift_balance;
let before_total = before_recharge + before_gift;
let amount_usd = if clamp_deduction_to_available_balance && amount_usd < 0.0 {
-(-amount_usd).min(before_total.max(0.0))
} else {
amount_usd
};
if amount_usd == 0.0 {
return Ok(Some((wallet.clone(), None)));
}
let mut after_recharge = before_recharge;
let mut after_gift = before_gift;
@@ -90,7 +99,7 @@ impl AppState {
let updated_wallet = wallet.clone();
drop(guard);
self.invalidate_auth_context_cache();
return Ok(Some((updated_wallet, transaction)));
return Ok(Some((updated_wallet, Some(transaction))));
}
Ok(self
@@ -100,10 +109,14 @@ impl AppState {
balance_type: balance_type.to_string(),
operator_id: operator_id.map(ToOwned::to_owned),
description: description.map(ToOwned::to_owned),
clamp_deduction_to_available_balance,
})
.await?
.map(|(wallet, transaction)| {
(wallet, stored_wallet_transaction_to_gateway(transaction))
(
wallet,
transaction.map(stored_wallet_transaction_to_gateway),
)
}))
}
@@ -112,7 +112,7 @@ impl AppState {
) -> Result<
Option<(
aether_data::repository::wallet::StoredWalletSnapshot,
aether_data::repository::wallet::StoredAdminWalletTransaction,
Option<aether_data::repository::wallet::StoredAdminWalletTransaction>,
)>,
GatewayError,
> {
@@ -21,5 +21,6 @@ mod system;
mod system_import;
mod usage;
mod users;
mod users_batch;
mod video_tasks;
mod wallets;
@@ -0,0 +1,232 @@
use aether_data::repository::users::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, start_server, AppState};
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 {
StoredUserAuthRecord::new(
user_id.to_string(),
Some(format!("{user_id}@example.com")),
true,
user_id.to_string(),
Some("hash".to_string()),
"user".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 {
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_reports_missing_wallet_and_skips_zero_delta_for_non_positive_balance() {
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"], -1.0);
assert_eq!(wallet["total_adjusted"], 0.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();
}
@@ -807,6 +807,14 @@ 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 {
-(-input.amount_usd).min(before_total.max(0.0))
} else {
input.amount_usd
}
}
#[async_trait]
impl WalletReadRepository for SqlxWalletRepository {
async fn find(
@@ -4262,7 +4270,8 @@ RETURNING
async fn adjust_wallet_balance(
&self,
input: AdjustWalletBalanceInput,
) -> Result<Option<(StoredWalletSnapshot, StoredAdminWalletTransaction)>, DataLayerError> {
) -> Result<Option<(StoredWalletSnapshot, Option<StoredAdminWalletTransaction>)>, DataLayerError>
{
if !input.amount_usd.is_finite() || input.amount_usd == 0.0 {
return Err(DataLayerError::InvalidInput(
"adjustment amount must be finite and non-zero".to_string(),
@@ -4285,7 +4294,8 @@ SELECT
CAST(total_recharged AS DOUBLE PRECISION) AS total_recharged,
CAST(total_consumed AS DOUBLE PRECISION) AS total_consumed,
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
WHERE id = $1
FOR UPDATE
@@ -4312,17 +4322,21 @@ FOR UPDATE
"wallet balance is invalid".to_string(),
));
}
let amount_usd = effective_wallet_adjustment_amount(&input, before_total);
if amount_usd == 0.0 {
return Ok(Some((map_wallet_row(&row)?, None)));
}
let mut after_recharge = before_recharge;
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") {
after_gift += input.amount_usd;
after_gift += amount_usd;
} else {
after_recharge += input.amount_usd;
after_recharge += amount_usd;
}
} else {
let mut remaining = -input.amount_usd;
let mut remaining = -amount_usd;
let consume_positive_bucket = |balance: &mut f64, to_consume: &mut f64| {
if *to_consume <= 0.0 {
return;
@@ -4344,7 +4358,7 @@ FOR UPDATE
}
}
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()
|| !after_gift.is_finite()
|| !after_total.is_finite()
@@ -4383,7 +4397,7 @@ RETURNING
.bind(&input.wallet_id)
.bind(after_recharge)
.bind(after_gift)
.bind(input.amount_usd)
.bind(amount_usd)
.fetch_one(&mut **tx)
.await
.map_postgres_err()?;
@@ -4439,7 +4453,7 @@ VALUES (
)
.bind(&transaction_id)
.bind(&input.wallet_id)
.bind(input.amount_usd)
.bind(amount_usd)
.bind(before_total)
.bind(after_total)
.bind(before_recharge)
@@ -4455,12 +4469,12 @@ VALUES (
Ok(Some((
wallet,
StoredAdminWalletTransaction {
Some(StoredAdminWalletTransaction {
id: transaction_id,
wallet_id: input.wallet_id,
category: "adjust".to_string(),
reason_code: "adjust_admin".to_string(),
amount: input.amount_usd,
amount: amount_usd,
balance_before: before_total,
balance_after: after_total,
recharge_balance_before: before_recharge,
@@ -4474,7 +4488,7 @@ VALUES (
operator_email: None,
description: Some(description),
created_at_unix_ms: Some(created_at),
},
}),
)))
})
})
@@ -8489,13 +8503,14 @@ VALUES ($1, $2, 'gift', 'gift_initial', $3, 0, $3, 0, 0, 0, $3, 'system_task', $
#[cfg(test)]
mod tests {
use aether_data_contracts::repository::wallet::{
CreateManualWalletRechargeInput, CreditAdminPaymentOrderInput, ProcessPaymentCallbackInput,
ProcessPaymentCallbackOutcome, RedeemWalletCodeInput, RedeemWalletCodeOutcome,
WalletLookupKey, WalletMutationOutcome, WalletReadRepository, WalletWriteRepository,
AdjustWalletBalanceInput, CreateManualWalletRechargeInput, CreditAdminPaymentOrderInput,
ProcessPaymentCallbackInput, ProcessPaymentCallbackOutcome, RedeemWalletCodeInput,
RedeemWalletCodeOutcome, WalletLookupKey, WalletMutationOutcome, WalletReadRepository,
WalletWriteRepository,
};
use sqlx::Row;
use super::SqlxWalletRepository;
use super::{effective_wallet_adjustment_amount, SqlxWalletRepository};
use crate::{PostgresPoolConfig, PostgresPoolFactory};
#[test]
@@ -9221,6 +9236,117 @@ mod tests {
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,
};
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), -0.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,
})
.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 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,
})
.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 from negative balance".to_string()),
clamp_deduction_to_available_balance: true,
})
.await
.expect("legacy negative wallet should remain usable")
.expect("wallet should still exist");
assert_eq!(wallet.balance + wallet.gift_balance, -1.0);
assert!(transaction.is_none());
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);
pool.close().await;
}
#[tokio::test]
async fn repository_constructs_from_lazy_pool() {
let factory = PostgresPoolFactory::new(PostgresPoolConfig {
@@ -2462,6 +2462,8 @@ pub struct AdjustWalletBalanceInput {
pub balance_type: String,
pub operator_id: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub clamp_deduction_to_available_balance: bool,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
@@ -2893,7 +2895,10 @@ pub trait WalletWriteRepository: Send + Sync {
async fn adjust_wallet_balance(
&self,
input: AdjustWalletBalanceInput,
) -> Result<Option<(StoredWalletSnapshot, StoredAdminWalletTransaction)>, crate::DataLayerError>;
) -> Result<
Option<(StoredWalletSnapshot, Option<StoredAdminWalletTransaction>)>,
crate::DataLayerError,
>;
async fn create_manual_wallet_recharge(
&self,
@@ -2322,8 +2322,13 @@ impl WalletWriteRepository for InMemoryWalletRepository {
async fn adjust_wallet_balance(
&self,
_input: AdjustWalletBalanceInput,
) -> Result<Option<(StoredWalletSnapshot, super::StoredAdminWalletTransaction)>, DataLayerError>
{
) -> Result<
Option<(
StoredWalletSnapshot,
Option<super::StoredAdminWalletTransaction>,
)>,
DataLayerError,
> {
Ok(None)
}
+23 -1
View File
@@ -17,7 +17,7 @@ vi.mock('@/utils/cache', () => ({
cachedRequest: cachedRequestMock,
}))
import { usersApi } from '@/api/users'
import { buildUserBatchBalanceAdjustmentPayload, usersApi } from '@/api/users'
describe('usersApi admin list query', () => {
beforeEach(() => {
@@ -104,3 +104,25 @@ describe('usersApi admin list query', () => {
expect(getMock).toHaveBeenCalledWith('/api/admin/users/target-user/api-keys')
})
})
describe('user batch wallet balance payload', () => {
it('builds an addition payload from a valid positive amount', () => {
expect(buildUserBatchBalanceAdjustmentPayload('add', '12.5')).toEqual({
operation: 'add',
amount: 12.5,
})
})
it('builds a deduction payload from a valid positive amount', () => {
expect(buildUserBatchBalanceAdjustmentPayload('deduct', 4)).toEqual({
operation: 'deduct',
amount: 4,
})
})
it('rejects zero, blank, and non-finite amounts', () => {
expect(buildUserBatchBalanceAdjustmentPayload('add', '0')).toBeNull()
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '')).toBeNull()
expect(buildUserBatchBalanceAdjustmentPayload('deduct', '1e999')).toBeNull()
})
})
+28 -2
View File
@@ -120,9 +120,28 @@ export interface UserBatchRolePayload {
role: UserRole
}
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role'
export type UserBatchBalanceOperation = 'add' | 'deduct'
export type UserBatchActionPayload = UserBatchAccessControlPayload | UserBatchRolePayload
export interface UserBatchBalanceAdjustmentPayload {
operation: UserBatchBalanceOperation
amount: number
}
export function buildUserBatchBalanceAdjustmentPayload(
operation: UserBatchBalanceOperation,
amountInput: string | number,
): UserBatchBalanceAdjustmentPayload | null {
const amount = typeof amountInput === 'number' ? amountInput : Number(amountInput.trim())
if (!Number.isFinite(amount) || amount <= 0) return null
return { operation, amount }
}
export type UserBatchAction = 'enable' | 'disable' | 'update_access_control' | 'update_role' | 'adjust_wallet_balance'
export type UserBatchActionPayload =
| UserBatchAccessControlPayload
| UserBatchRolePayload
| UserBatchBalanceAdjustmentPayload
export interface UserBatchToggleActionRequest {
selection: UserBatchSelection
@@ -142,10 +161,17 @@ export interface UserBatchRoleActionRequest {
payload: UserBatchRolePayload
}
export interface UserBatchBalanceActionRequest {
selection: UserBatchSelection
action: 'adjust_wallet_balance'
payload: UserBatchBalanceAdjustmentPayload
}
export type UserBatchActionRequest =
| UserBatchToggleActionRequest
| UserBatchAccessControlActionRequest
| UserBatchRoleActionRequest
| UserBatchBalanceActionRequest
export interface UserBatchActionFailure {
user_id: string
@@ -2,7 +2,7 @@
<Dialog
:model-value="open"
:title="legacyT('用户批量操作')"
:description="legacyT('按当前选择批量调整用户状态、角色和额度')"
:description="legacyT('按当前选择批量调整用户状态、角色、额度和钱包余额')"
size="2xl"
persistent
@update:model-value="handleDialogUpdate"
@@ -39,6 +39,55 @@
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>
<UserBatchResultSummary
:result="lastResult"
:label="lastResultLabel"
@@ -69,7 +118,10 @@ import { computed, ref, watch } from 'vue'
import {
Dialog,
Button,
Input,
Label,
} from '@/components/ui'
import { Minus, Plus } from 'lucide-vue-next'
import { useUsersStore } from '@/stores/users'
import { useToast } from '@/composables/useToast'
import { parseApiError } from '@/utils/errorParser'
@@ -82,11 +134,13 @@ import UserBatchRolePanel from './UserBatchRolePanel.vue'
import UserBatchTargetSummary from './UserBatchTargetSummary.vue'
import { USER_BATCH_ACTION_OPTIONS } from './user-management-config'
import type { UserBatchQuotaMode } from './user-management-types'
import { buildUserBatchBalanceAdjustmentPayload } from '@/api/users'
import type {
UserBatchAccessControlPayload,
UserBatchAction,
UserBatchActionRequest,
UserBatchActionResponse,
UserBatchBalanceOperation,
UserBatchRolePayload,
UserBatchSelection,
UserBatchSelectionFilters,
@@ -116,6 +170,8 @@ const { legacyT, locale } = useI18n()
const selectedAction = ref<UserBatchAction>('enable')
const targetRole = ref<UserRole>('user')
const quotaMode = ref<UserBatchQuotaMode>('skip')
const balanceOperation = ref<UserBatchBalanceOperation>('add')
const balanceAmount = ref('')
const selectedGroupIds = ref<string[]>([])
const previewLoading = ref(false)
const previewItems = ref<UserBatchSelectionItem[]>([])
@@ -125,7 +181,16 @@ const lastResult = ref<UserBatchActionResponse | null>(null)
const hasAnyTarget = computed(() => props.selectedCount > 0 || selectedGroupIds.value.length > 0)
const impactCount = computed(() => resolvedTotal.value ?? props.selectedCount)
const canExecute = computed(() => hasAnyTarget.value && !previewLoading.value && !executing.value)
const balancePayload = computed(() => buildUserBatchBalanceAdjustmentPayload(
balanceOperation.value,
balanceAmount.value,
))
const canExecute = computed(() => (
hasAnyTarget.value
&& !previewLoading.value
&& !executing.value
&& (selectedAction.value !== 'adjust_wallet_balance' || balancePayload.value !== null)
))
const selectedActionLabel = computed(() => (
USER_BATCH_ACTION_OPTIONS.find((action) => action.value === selectedAction.value)?.label ?? '批量操作'
))
@@ -149,7 +214,9 @@ const lastResultLabel = computed(() => {
})
const lastResultFailuresLabel = computed(() => {
if (!lastResult.value || lastResult.value.failures.length === 0) return ''
const failures = lastResult.value.failures.slice(0, 3).map((item) => `${item.user_id} ${item.reason}`).join(locale.value === 'en-US' ? '; ' : ';')
const failures = lastResult.value.failures.slice(0, 3)
.map((item) => `${item.user_id} ${legacyT(item.reason)}`)
.join(locale.value === 'en-US' ? '; ' : ';')
return locale.value === 'en-US' ? `: ${failures}` : `:${failures}`
})
@@ -177,6 +244,8 @@ function resetLocalState(): void {
selectedAction.value = 'enable'
targetRole.value = 'user'
quotaMode.value = 'skip'
balanceOperation.value = 'add'
balanceAmount.value = ''
selectedGroupIds.value = []
lastResult.value = null
}
@@ -235,6 +304,12 @@ async function executeBatchAction(): Promise<void> {
return
}
request = { selection, action: 'update_access_control', payload }
} else if (selectedAction.value === 'adjust_wallet_balance') {
if (balancePayload.value === null) {
warning(legacyT('请输入大于 0 的有限金额'))
return
}
request = { selection, action: 'adjust_wallet_balance', payload: balancePayload.value }
} else if (selectedAction.value === 'update_role') {
request = { selection, action: 'update_role', payload: buildRolePayload() }
} else {
@@ -253,7 +328,7 @@ async function executeBatchAction(): Promise<void> {
}
emit('completed', result)
} catch (err) {
error(parseApiError(err, '批量操作失败'), legacyT('批量操作失败'))
error(legacyT(parseApiError(err, '批量操作失败')), legacyT('批量操作失败'))
} finally {
executing.value = false
}
@@ -3,6 +3,7 @@ import {
CheckCircle2,
ShieldCheck,
UserCog,
Wallet,
} from 'lucide-vue-next'
import type { Component } from 'vue'
import type { UserBatchAction, UserRole } from '@/api/users'
@@ -64,6 +65,12 @@ export const USER_BATCH_ACTION_OPTIONS: UserBatchActionOption[] = [
description: '批量设为普通用户或管理员',
icon: UserCog,
},
{
value: 'adjust_wallet_balance',
label: '调整余额',
description: '批量增加或扣减钱包余额',
icon: Wallet,
},
]
export function formatUserRoleLabel(role: UserRole | string): string {
+11
View File
@@ -1453,6 +1453,17 @@ const legacyExactEnglishMessages: Record<string, string> = {
'暂无选项': 'No options',
'用户批量操作': 'User batch actions',
'按当前选择批量调整用户状态、角色和额度': 'Batch update user status, role, and quota for the current selection',
'按当前选择批量调整用户状态、角色、额度和钱包余额': 'Batch update user status, role, quota, and wallet balances for the current selection',
'调整余额': 'Adjust balance',
'批量增加或扣减钱包余额': 'Add or deduct wallet balances in bulk',
'调整金额 (USD)': 'Adjustment amount (USD)',
'余额调整方式': 'Balance adjustment mode',
'增加': 'Add',
'扣减': 'Deduct',
'请输入大于 0 的有限金额': 'Enter a finite amount greater than 0',
'扣减超过单个用户可用余额时,该用户余额将归零。': 'Deductions above an individual user’s available balance will be clamped to zero.',
'用户钱包不可用': 'User wallet is unavailable',
'当前为只读模式,无法批量调整用户钱包余额': 'Cannot adjust user wallet balances in read-only mode',
'影响用户:': 'Affected users:',
'目标为当前筛选条件匹配的全部用户,执行前后端会重新解析。': 'Targets all users matching the current filters; the backend will resolve the selection again before execution.',
'目标为当前已勾选的用户,重复 ID 会自动去重。': 'Targets the currently selected users; duplicate IDs are deduplicated automatically.',