mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-04 00:17:45 +08:00
feat(admin): batch adjust user wallet balances
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user