mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 18:37:46 +08:00
fix: harden bulk wallet balance adjustment
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
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, 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};
|
||||||
@@ -173,6 +174,13 @@ pub(in super::super) async fn build_admin_user_batch_action_response(
|
|||||||
"当前为只读模式,无法批量更新用户钱包",
|
"当前为只读模式,无法批量更新用户钱包",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if mutation.wallet_balance_adjustment.is_some()
|
||||||
|
&& !management_token_may_adjust_admin_wallet_balance(request_context)
|
||||||
|
{
|
||||||
|
return Ok(build_admin_users_permission_denied_response(
|
||||||
|
request_context,
|
||||||
|
));
|
||||||
|
}
|
||||||
if mutation.wallet_balance_adjustment.is_some() && !state.has_auth_wallet_write_capability() {
|
if mutation.wallet_balance_adjustment.is_some() && !state.has_auth_wallet_write_capability() {
|
||||||
return Ok(build_admin_users_read_only_response(
|
return Ok(build_admin_users_read_only_response(
|
||||||
"当前为只读模式,无法批量调整用户钱包余额",
|
"当前为只读模式,无法批量调整用户钱包余额",
|
||||||
@@ -195,9 +203,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": "用户不存在或已删除",
|
||||||
@@ -220,34 +248,66 @@ 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(_) => {
|
||||||
|
record_batch_action_interruption(
|
||||||
|
&resolved.items,
|
||||||
|
item_index,
|
||||||
|
true,
|
||||||
|
"用户钱包更新结果未确认,批次已中止,请核对钱包后再重试",
|
||||||
|
&mut failures,
|
||||||
|
&mut uncertain_user_ids,
|
||||||
|
&mut unprocessed_user_ids,
|
||||||
|
);
|
||||||
|
interrupted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(adjustment) = mutation.wallet_balance_adjustment {
|
if let Some(adjustment) = mutation.wallet_balance_adjustment {
|
||||||
if !apply_batch_user_wallet_balance_adjustment(
|
match apply_batch_user_wallet_balance_adjustment(
|
||||||
state,
|
state,
|
||||||
&item.user_id,
|
&item.user_id,
|
||||||
adjustment,
|
adjustment,
|
||||||
current_admin_user_id,
|
current_admin_user_id,
|
||||||
)
|
)
|
||||||
.await?
|
.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(_) => {
|
||||||
|
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 mutation.has_auth_user_fields() {
|
||||||
&& state
|
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(),
|
||||||
@@ -261,22 +321,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,
|
||||||
@@ -284,8 +361,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,
|
||||||
@@ -296,6 +379,35 @@ 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_resolve_selection_request(
|
fn parse_resolve_selection_request(
|
||||||
request_body: Option<&Bytes>,
|
request_body: Option<&Bytes>,
|
||||||
) -> Result<AdminUserSelectionRequest, String> {
|
) -> Result<AdminUserSelectionRequest, String> {
|
||||||
|
|||||||
@@ -51,10 +51,11 @@ use self::shared::{
|
|||||||
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, disabled_user_policy_detail, disabled_user_policy_field,
|
||||||
format_optional_datetime_iso8601, legacy_admin_list_policy_mode,
|
format_optional_datetime_iso8601, legacy_admin_list_policy_mode,
|
||||||
legacy_admin_rate_limit_policy_mode, management_token_may_administer_user_accounts,
|
legacy_admin_rate_limit_policy_mode, management_token_may_adjust_admin_wallet_balance,
|
||||||
normalize_admin_optional_user_email, normalize_admin_user_group_ids, normalize_admin_user_role,
|
management_token_may_administer_user_accounts, normalize_admin_optional_user_email,
|
||||||
normalize_admin_username, validate_admin_user_password, AdminCreateUserApiKeyRequest,
|
normalize_admin_user_group_ids, normalize_admin_user_role, normalize_admin_username,
|
||||||
AdminCreateUserRequest, AdminToggleUserApiKeyLockRequest, AdminUpdateUserApiKeyRequest,
|
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> {
|
||||||
|
|||||||
@@ -492,6 +492,8 @@ 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) 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)]
|
||||||
|
|||||||
@@ -466,6 +466,8 @@ 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)]
|
||||||
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()))),
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ impl AppState {
|
|||||||
)>,
|
)>,
|
||||||
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");
|
||||||
|
|||||||
@@ -472,6 +472,14 @@ 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 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>,
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
use aether_data::repository::users::StoredUserAuthRecord;
|
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 aether_data::repository::wallet::StoredWalletSnapshot;
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use reqwest::{Client, RequestBuilder, Response};
|
use reqwest::{Client, RequestBuilder, Response};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use super::super::{build_router_with_state, start_server, AppState};
|
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 {
|
fn admin_headers(request: RequestBuilder) -> RequestBuilder {
|
||||||
request
|
request
|
||||||
@@ -19,13 +25,17 @@ fn admin_headers(request: RequestBuilder) -> RequestBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sample_user(user_id: &str) -> StoredUserAuthRecord {
|
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(
|
StoredUserAuthRecord::new(
|
||||||
user_id.to_string(),
|
user_id.to_string(),
|
||||||
Some(format!("{user_id}@example.com")),
|
Some(format!("{user_id}@example.com")),
|
||||||
true,
|
true,
|
||||||
user_id.to_string(),
|
user_id.to_string(),
|
||||||
Some("hash".to_string()),
|
Some("hash".to_string()),
|
||||||
"user".to_string(),
|
role.to_string(),
|
||||||
"local".to_string(),
|
"local".to_string(),
|
||||||
Some(json!(["openai"])),
|
Some(json!(["openai"])),
|
||||||
Some(json!(["openai:chat"])),
|
Some(json!(["openai:chat"])),
|
||||||
@@ -162,6 +172,147 @@ async fn gateway_batches_wallet_addition_deduction_and_clamped_deduction_per_use
|
|||||||
gateway_handle.abort();
|
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 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 token_repository = Arc::new(InMemoryManagementTokenRepository::seed_with_hashes(
|
||||||
|
vec![users_only, users_and_wallets],
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
));
|
||||||
|
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 }
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
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]
|
#[tokio::test]
|
||||||
async fn gateway_reports_missing_wallet_and_skips_zero_delta_for_non_positive_balance() {
|
async fn gateway_reports_missing_wallet_and_skips_zero_delta_for_non_positive_balance() {
|
||||||
let state = AppState::new()
|
let state = AppState::new()
|
||||||
|
|||||||
@@ -183,6 +183,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[]
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ const targetRoleWarning = computed(() => {
|
|||||||
const executeButtonLabel = computed(() => legacyT(`确认${selectedActionLabel.value}(${impactCount.value})`))
|
const executeButtonLabel = computed(() => legacyT(`确认${selectedActionLabel.value}(${impactCount.value})`))
|
||||||
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(() => {
|
||||||
@@ -320,6 +325,11 @@ async function executeBatchAction(): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1464,6 +1464,16 @@ const legacyExactEnglishMessages: Record<string, string> = {
|
|||||||
'扣减超过单个用户可用余额时,该用户余额将归零。': 'Deductions above an individual user’s available balance will be clamped to zero.',
|
'扣减超过单个用户可用余额时,该用户余额将归零。': 'Deductions above an individual user’s available balance will be clamped to zero.',
|
||||||
'用户钱包不可用': 'User wallet is unavailable',
|
'用户钱包不可用': 'User wallet is unavailable',
|
||||||
'当前为只读模式,无法批量调整用户钱包余额': 'Cannot adjust user wallet balances in read-only mode',
|
'当前为只读模式,无法批量调整用户钱包余额': '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.',
|
||||||
@@ -3061,6 +3071,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