refactor: 拆分 gateway 单体为独立 crate,新增 systemd 部署方案

将 gateway 内部的 model-fetch、provider-transport、scheduler-core、
usage-runtime、video-tasks-core 模块提取为独立 crate;重构 gateway
内部模块结构(state/router/cache/data/query 等);移除大量遗留模块
文件;新增 systemd 二进制部署骨架及相关文档;更新前端 usage 相关
API 和组件。
This commit is contained in:
fawney19
2026-04-05 20:23:16 +08:00
parent cbc811f6ce
commit 763ff03a7b
777 changed files with 42659 additions and 21469 deletions

View File

@@ -1,5 +1,7 @@
use crate::gateway::async_task::CancelVideoTaskError;
use crate::gateway::{AppState, GatewayControlDecision, GatewayError, GatewayPublicRequestContext};
use crate::async_task::CancelVideoTaskError;
use crate::control::GatewayControlDecision;
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use aether_data::repository::video_tasks::{
StoredVideoTask, VideoTaskQueryFilter, VideoTaskStatus,
};
@@ -243,7 +245,7 @@ async fn build_local_gemini_video_operation_cancel_response(
}
};
match crate::gateway::async_task::cancel_video_task_record(state, &task.id).await {
match crate::async_task::cancel_video_task_record(state, &task.id).await {
Ok(_) => Json(json!({})).into_response(),
Err(CancelVideoTaskError::NotFound) => build_ai_public_error_response(
http::StatusCode::NOT_FOUND,

View File

@@ -1,8 +1,8 @@
use crate::gateway::api::ai::public_api_format_local_path;
use crate::gateway::handlers::{
use crate::api::ai::public_api_format_local_path;
use crate::handlers::{
query_param_optional_bool, query_param_value, unix_secs_to_rfc3339,
};
use crate::gateway::AppState;
use crate::AppState;
use aether_data::repository::candidates::{
PublicHealthTimelineBucket, RequestCandidateStatus, StoredRequestCandidate,
};

View File

@@ -7,11 +7,12 @@ use super::{
system_config_bool, system_config_string, ApiFormatHealthMonitorOptions,
PUBLIC_CAPABILITY_DEFINITIONS,
};
use crate::gateway::handlers::{
use crate::control::GatewayPublicRequestContext;
use crate::handlers::{
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, query_param_bool,
query_param_optional_bool, query_param_value, unix_secs_to_rfc3339,
};
use crate::gateway::{AppState, GatewayError, GatewayPublicRequestContext};
use crate::{AppState, GatewayError};
use axum::body::{Body, Bytes};
use axum::http::{self, Response};
use axum::response::IntoResponse;

View File

@@ -6,7 +6,8 @@ use axum::{
};
use serde_json::json;
use crate::gateway::{AppState, GatewayError, GatewayPublicRequestContext};
use crate::control::GatewayPublicRequestContext;
use crate::{AppState, GatewayError};
use super::announcements_shared::{
announcements_bad_request_response, announcements_not_found_response,

View File

@@ -5,7 +5,8 @@ use axum::{
Json,
};
use crate::gateway::{AppState, GatewayPublicRequestContext};
use crate::control::GatewayPublicRequestContext;
use crate::AppState;
use super::super::build_unhandled_public_support_response;
use super::announcements_shared::{

View File

@@ -10,8 +10,8 @@ use aether_data::repository::announcements::{
AnnouncementListQuery, StoredAnnouncement, StoredAnnouncementPage,
};
use crate::gateway::handlers::{query_param_optional_bool, query_param_value};
use crate::gateway::GatewayError;
use crate::handlers::{query_param_optional_bool, query_param_value};
use crate::GatewayError;
pub(super) fn parse_public_announcements_query(
query: Option<&str>,

View File

@@ -6,7 +6,8 @@ use axum::{
};
use serde_json::json;
use crate::gateway::{AppState, GatewayPublicRequestContext};
use crate::control::GatewayPublicRequestContext;
use crate::AppState;
use super::super::{build_unhandled_public_support_response, resolve_authenticated_local_user};
use super::announcements_shared::{

View File

@@ -301,7 +301,7 @@ pub(super) async fn maybe_build_local_auth_response(
#[cfg(test)]
mod tests {
use super::{maybe_build_local_auth_response, AppState, GatewayPublicRequestContext};
use crate::gateway::GatewayControlDecision;
use crate::control::GatewayControlDecision;
use axum::body::to_bytes;
use axum::http::{HeaderMap, Method, StatusCode, Uri};

View File

@@ -218,7 +218,7 @@ fn auth_non_empty_string(value: Option<String>) -> Option<String> {
pub(super) fn extract_bearer_token(headers: &http::HeaderMap) -> Option<String> {
let value =
crate::gateway::headers::header_value_str(headers, http::header::AUTHORIZATION.as_str())?;
crate::headers::header_value_str(headers, http::header::AUTHORIZATION.as_str())?;
let (scheme, token) = value.split_once(' ')?;
if !scheme.eq_ignore_ascii_case("bearer") {
return None;
@@ -227,7 +227,7 @@ pub(super) fn extract_bearer_token(headers: &http::HeaderMap) -> Option<String>
}
pub(super) fn extract_cookie_value(headers: &http::HeaderMap, cookie_name: &str) -> Option<String> {
let header = crate::gateway::headers::header_value_str(headers, http::header::COOKIE.as_str())?;
let header = crate::headers::header_value_str(headers, http::header::COOKIE.as_str())?;
for pair in header.split(';') {
let (name, value) = pair.trim().split_once('=')?;
if name.trim() == cookie_name {
@@ -241,7 +241,7 @@ pub(super) fn extract_client_device_id(
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Result<String, Response<Body>> {
let header_value = crate::gateway::headers::header_value_str(headers, "x-client-device-id");
let header_value = crate::headers::header_value_str(headers, "x-client-device-id");
let query_value = request_context
.request_query_string
.as_deref()
@@ -268,12 +268,12 @@ pub(super) fn extract_client_device_id(
}
pub(super) fn auth_user_agent(headers: &http::HeaderMap) -> Option<String> {
crate::gateway::headers::header_value_str(headers, http::header::USER_AGENT.as_str())
crate::headers::header_value_str(headers, http::header::USER_AGENT.as_str())
.map(|value| value.chars().take(1000).collect())
}
pub(super) fn auth_client_ip(headers: &http::HeaderMap) -> Option<String> {
crate::gateway::headers::header_value_str(headers, "x-forwarded-for")
crate::headers::header_value_str(headers, "x-forwarded-for")
.and_then(|value| {
value
.split(',')
@@ -283,7 +283,7 @@ pub(super) fn auth_client_ip(headers: &http::HeaderMap) -> Option<String> {
.filter(|value| !value.is_empty())
.map(|value| value.chars().take(45).collect())
.or_else(|| {
crate::gateway::headers::header_value_str(headers, "x-real-ip")
crate::headers::header_value_str(headers, "x-real-ip")
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())

View File

@@ -342,7 +342,7 @@ pub(super) async fn handle_auth_refresh(
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
if crate::gateway::headers::header_value_str(headers, http::header::CONTENT_LENGTH.as_str())
if crate::headers::header_value_str(headers, http::header::CONTENT_LENGTH.as_str())
.as_deref()
.is_some_and(|value| value.trim() != "0")
{
@@ -495,7 +495,7 @@ pub(super) async fn handle_auth_refresh(
user_id,
session_id,
&session.refresh_token_hash,
&crate::gateway::gateway_data::StoredUserSessionRecord::hash_refresh_token(
&crate::data::state::StoredUserSessionRecord::hash_refresh_token(
&new_refresh_token,
),
now,
@@ -583,12 +583,14 @@ pub(super) async fn build_auth_login_success_response(
)
}
};
let session = match crate::gateway::gateway_data::StoredUserSessionRecord::new(
let session = match crate::data::state::StoredUserSessionRecord::new(
session_id,
user.id.clone(),
client_device_id,
None,
crate::gateway::gateway_data::StoredUserSessionRecord::hash_refresh_token(&refresh_token),
crate::data::state::StoredUserSessionRecord::hash_refresh_token(
&refresh_token,
),
None,
None,
Some(now),

View File

@@ -48,10 +48,8 @@ pub(super) async fn maybe_build_local_dashboard_response(
#[cfg(test)]
mod tests {
use super::{
maybe_build_local_dashboard_response, AppState, GatewayPublicRequestContext,
};
use crate::gateway::GatewayControlDecision;
use super::{maybe_build_local_dashboard_response, AppState, GatewayPublicRequestContext};
use crate::control::GatewayControlDecision;
use axum::body::to_bytes;
use axum::http::{HeaderMap, Method, StatusCode, Uri};

View File

@@ -28,12 +28,12 @@ pub(super) fn models_detail_id(request_path: &str) -> Option<String> {
}
fn auth_snapshot_allows_provider_for_models(
auth_snapshot: Option<&crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot>,
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
provider_id: &str,
provider_name: &str,
) -> bool {
let Some(allowed) = auth_snapshot.and_then(
crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot::effective_allowed_providers,
crate::data::auth::GatewayAuthApiKeySnapshot::effective_allowed_providers,
) else {
return true;
};
@@ -45,11 +45,11 @@ fn auth_snapshot_allows_provider_for_models(
}
fn auth_snapshot_allows_model_for_models(
auth_snapshot: Option<&crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot>,
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
global_model_name: &str,
) -> bool {
let Some(allowed) = auth_snapshot.and_then(
crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot::effective_allowed_models,
crate::data::auth::GatewayAuthApiKeySnapshot::effective_allowed_models,
) else {
return true;
};
@@ -130,7 +130,7 @@ fn row_exposes_global_model_for_models(
pub(super) fn filter_rows_for_models(
rows: Vec<StoredMinimalCandidateSelectionRow>,
auth_snapshot: Option<&crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot>,
auth_snapshot: Option<&crate::data::auth::GatewayAuthApiKeySnapshot>,
api_format: &str,
) -> Vec<StoredMinimalCandidateSelectionRow> {
let mut filtered = rows

View File

@@ -45,7 +45,7 @@ mod tests {
use super::{
maybe_build_local_user_monitoring_response, AppState, GatewayPublicRequestContext,
};
use crate::gateway::GatewayControlDecision;
use crate::control::GatewayControlDecision;
use axum::body::to_bytes;
use axum::http::{HeaderMap, Method, StatusCode, Uri};

View File

@@ -6,9 +6,9 @@ use axum::{
};
use chrono::Utc;
use serde_json::{json, Value};
use sqlx::Row;
use crate::gateway::handlers::shared::query_param_value;
use crate::handlers::shared::query_param_value;
use crate::query::monitoring as monitoring_query;
use super::{
build_auth_error_response, resolve_authenticated_local_user, AppState,
@@ -124,131 +124,29 @@ pub(super) async fn handle_user_audit_logs(
};
let cutoff_time = Utc::now() - chrono::Duration::days(days);
let total = if let Some(ref event_type) = event_type {
match sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM audit_logs
WHERE user_id = $1
AND created_at >= $2
AND event_type = $3
"#,
)
.bind(&auth.user.id)
.bind(cutoff_time)
.bind(event_type)
.fetch_one(&pool)
.await
{
Ok(value) => usize::try_from(value.max(0)).unwrap_or(usize::MAX),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user audit logs count failed: {err}"),
false,
)
}
}
} else {
match sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
FROM audit_logs
WHERE user_id = $1
AND created_at >= $2
"#,
)
.bind(&auth.user.id)
.bind(cutoff_time)
.fetch_one(&pool)
.await
{
Ok(value) => usize::try_from(value.max(0)).unwrap_or(usize::MAX),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user audit logs count failed: {err}"),
false,
)
}
let (items, total) = match monitoring_query::list_user_audit_logs(
&pool,
&auth.user.id,
cutoff_time,
event_type.as_deref(),
limit,
offset,
)
.await
{
Ok(value) => value,
Err(err) => {
let detail = match err {
crate::GatewayError::Internal(message) => message,
other => format!("{other:?}"),
};
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
detail,
false,
);
}
};
let rows = if let Some(ref event_type) = event_type {
match sqlx::query(
r#"
SELECT id, event_type, description, ip_address, status_code, created_at
FROM audit_logs
WHERE user_id = $1
AND created_at >= $2
AND event_type = $3
ORDER BY created_at DESC
LIMIT $4 OFFSET $5
"#,
)
.bind(&auth.user.id)
.bind(cutoff_time)
.bind(event_type)
.bind(i64::try_from(limit).unwrap_or(i64::MAX))
.bind(i64::try_from(offset).unwrap_or(i64::MAX))
.fetch_all(&pool)
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user audit logs read failed: {err}"),
false,
)
}
}
} else {
match sqlx::query(
r#"
SELECT id, event_type, description, ip_address, status_code, created_at
FROM audit_logs
WHERE user_id = $1
AND created_at >= $2
ORDER BY created_at DESC
LIMIT $3 OFFSET $4
"#,
)
.bind(&auth.user.id)
.bind(cutoff_time)
.bind(i64::try_from(limit).unwrap_or(i64::MAX))
.bind(i64::try_from(offset).unwrap_or(i64::MAX))
.fetch_all(&pool)
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("user audit logs read failed: {err}"),
false,
)
}
}
};
let items = rows
.into_iter()
.map(|row| {
let created_at = row
.try_get::<chrono::DateTime<chrono::Utc>, _>("created_at")
.ok()
.map(|value| value.to_rfc3339());
json!({
"id": row.try_get::<String, _>("id").ok(),
"event_type": row.try_get::<String, _>("event_type").ok(),
"description": row.try_get::<String, _>("description").ok(),
"ip_address": row.try_get::<Option<String>, _>("ip_address").ok().flatten(),
"status_code": row.try_get::<Option<i32>, _>("status_code").ok().flatten(),
"created_at": created_at,
})
})
.collect::<Vec<_>>();
build_user_monitoring_audit_logs_payload(items, total, limit, offset, event_type, days)
}

View File

@@ -3,50 +3,14 @@ use super::payment_shared::{
NormalizedPaymentCallbackRequest,
};
use axum::{body::Body, http, response::Response};
use chrono::Utc;
use serde_json::json;
use sqlx::Row;
use uuid::Uuid;
use super::super::{build_auth_json_response, wallet_payment_order_payload_from_row};
use super::super::build_auth_json_response;
use super::{
build_auth_error_response, build_payment_callback_storage_unavailable_response, AppState,
GatewayPublicRequestContext,
};
async fn update_payment_callback_failure(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
callback_id: &str,
payload: &NormalizedPaymentCallbackRequest,
callback_payload_hash: &str,
signature_valid: bool,
error: &str,
) {
let _ = sqlx::query(
r#"
UPDATE payment_callbacks
SET signature_valid = $2,
status = 'failed',
error_message = $3,
payload_hash = $4,
payload = $5,
processed_at = NOW(),
order_no = COALESCE($6, order_no),
gateway_order_id = COALESCE($7, gateway_order_id)
WHERE id = $1
"#,
)
.bind(callback_id)
.bind(signature_valid)
.bind(error)
.bind(callback_payload_hash)
.bind(&payload.payload)
.bind(payload.order_no.as_deref())
.bind(payload.gateway_order_id.as_deref())
.execute(&mut **tx)
.await;
}
pub(super) async fn handle_payment_callback_with_postgres(
state: &AppState,
payment_method: &str,
@@ -54,360 +18,75 @@ pub(super) async fn handle_payment_callback_with_postgres(
payload: &NormalizedPaymentCallbackRequest,
signature_valid: bool,
) -> Response<Body> {
let Some(pool) = state.postgres_pool() else {
if state.postgres_pool().is_none() {
return build_payment_callback_storage_unavailable_response();
};
let mut tx = match pool.begin().await {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback transaction failed: {err}"),
false,
);
}
};
let existing_callback = match sqlx::query(
r#"
SELECT id, payment_order_id, status, order_no, gateway_order_id
FROM payment_callbacks
WHERE callback_key = $1
LIMIT 1
"#,
)
.bind(&payload.callback_key)
.fetch_optional(&mut *tx)
.await
{
Ok(value) => value,
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback lookup failed: {err}"),
false,
);
}
};
}
let callback_payload_hash = match payment_callback_payload_hash(&payload.payload) {
Ok(value) => value,
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(http::StatusCode::INTERNAL_SERVER_ERROR, err, false);
return build_auth_error_response(http::StatusCode::INTERNAL_SERVER_ERROR, err, false)
}
};
let duplicate = existing_callback.is_some();
let callback_id = if let Some(row) = existing_callback.as_ref() {
let status = row.try_get::<String, _>("status").ok().unwrap_or_default();
if status == "processed" {
let order_id = row
.try_get::<Option<String>, _>("payment_order_id")
.ok()
.flatten();
let _ = tx.rollback().await;
return build_auth_json_response(
http::StatusCode::OK,
json!({
"ok": true,
"duplicate": true,
"credited": false,
"order_id": order_id,
"payment_method": payment_method,
"request_path": request_context.request_path,
}),
None,
);
}
row.try_get::<String, _>("id").ok().unwrap_or_default()
} else {
let callback_id = Uuid::new_v4().to_string();
let insert_result = sqlx::query(
r#"
INSERT INTO payment_callbacks (
id,
payment_order_id,
payment_method,
callback_key,
order_no,
gateway_order_id,
payload_hash,
signature_valid,
status,
payload,
error_message,
created_at,
processed_at
)
VALUES (
$1,
NULL,
$2,
$3,
$4,
$5,
$6,
$7,
'received',
$8,
NULL,
NOW(),
NULL
)
"#,
let outcome = match state
.process_payment_callback(
aether_data::repository::wallet::ProcessPaymentCallbackInput {
payment_method: payment_method.to_string(),
callback_key: payload.callback_key.clone(),
order_no: payload.order_no.clone(),
gateway_order_id: payload.gateway_order_id.clone(),
amount_usd: payload.amount_usd,
pay_amount: payload.pay_amount,
pay_currency: payload.pay_currency.clone(),
exchange_rate: payload.exchange_rate,
payload_hash: callback_payload_hash,
payload: payload.payload.clone(),
signature_valid,
},
)
.bind(&callback_id)
.bind(payment_method)
.bind(&payload.callback_key)
.bind(payload.order_no.as_deref())
.bind(payload.gateway_order_id.as_deref())
.bind(&callback_payload_hash)
.bind(signature_valid)
.bind(&payload.payload)
.execute(&mut *tx)
.await;
if let Err(err) = insert_result {
let _ = tx.rollback().await;
.await
{
Ok(Some(value)) => value,
Ok(None) => return build_payment_callback_storage_unavailable_response(),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback create failed: {err}"),
format!("payment callback failed: {err:?}"),
false,
);
)
}
callback_id
};
if !signature_valid {
update_payment_callback_failure(
&mut tx,
&callback_id,
payload,
&callback_payload_hash,
signature_valid,
"invalid callback signature",
)
.await;
let _ = tx.commit().await;
return payment_callback_mark_failed_response(
match outcome {
aether_data::repository::wallet::ProcessPaymentCallbackOutcome::DuplicateProcessed {
order_id,
} => build_auth_json_response(
http::StatusCode::OK,
json!({
"ok": true,
"duplicate": true,
"credited": false,
"order_id": order_id,
"payment_method": payment_method,
"request_path": request_context.request_path,
}),
None,
),
aether_data::repository::wallet::ProcessPaymentCallbackOutcome::Failed {
duplicate,
"invalid callback signature",
error,
} => payment_callback_mark_failed_response(
duplicate,
&error,
payment_method,
&request_context.request_path,
);
}
let lookup_order_no = payload.order_no.clone().or_else(|| {
existing_callback
.as_ref()
.and_then(|row| row.try_get::<Option<String>, _>("order_no").ok().flatten())
});
let lookup_gateway_order_id = payload.gateway_order_id.clone().or_else(|| {
existing_callback.as_ref().and_then(|row| {
row.try_get::<Option<String>, _>("gateway_order_id")
.ok()
.flatten()
})
});
let order_row = if let Some(order_no) = lookup_order_no.as_deref() {
match sqlx::query(
r#"
SELECT
id,
order_no,
wallet_id,
user_id,
CAST(amount_usd AS DOUBLE PRECISION) AS amount_usd,
CAST(pay_amount AS DOUBLE PRECISION) AS pay_amount,
pay_currency,
CAST(exchange_rate AS DOUBLE PRECISION) AS exchange_rate,
CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd,
CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd,
payment_method,
gateway_order_id,
gateway_response,
status AS effective_status,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM paid_at) AS BIGINT) AS paid_at_unix_secs,
CAST(EXTRACT(EPOCH FROM credited_at) AS BIGINT) AS credited_at_unix_secs,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs
FROM payment_orders
WHERE order_no = $1
LIMIT 1
FOR UPDATE
"#,
)
.bind(order_no)
.fetch_optional(&mut *tx)
.await
{
Ok(value) => value,
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback order lookup failed: {err}"),
false,
);
}
}
} else if let Some(gateway_order_id) = lookup_gateway_order_id.as_deref() {
match sqlx::query(
r#"
SELECT
id,
order_no,
wallet_id,
user_id,
CAST(amount_usd AS DOUBLE PRECISION) AS amount_usd,
CAST(pay_amount AS DOUBLE PRECISION) AS pay_amount,
pay_currency,
CAST(exchange_rate AS DOUBLE PRECISION) AS exchange_rate,
CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd,
CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd,
payment_method,
gateway_order_id,
gateway_response,
status AS effective_status,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM paid_at) AS BIGINT) AS paid_at_unix_secs,
CAST(EXTRACT(EPOCH FROM credited_at) AS BIGINT) AS credited_at_unix_secs,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs
FROM payment_orders
WHERE gateway_order_id = $1
LIMIT 1
FOR UPDATE
"#,
)
.bind(gateway_order_id)
.fetch_optional(&mut *tx)
.await
{
Ok(value) => value,
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback order lookup failed: {err}"),
false,
);
}
}
} else {
None
};
let Some(order_row) = order_row else {
update_payment_callback_failure(
&mut tx,
&callback_id,
payload,
&callback_payload_hash,
signature_valid,
"payment order not found",
)
.await;
let _ = tx.commit().await;
return payment_callback_mark_failed_response(
),
aether_data::repository::wallet::ProcessPaymentCallbackOutcome::AlreadyCredited {
duplicate,
"payment order not found",
payment_method,
&request_context.request_path,
);
};
let order_id = order_row
.try_get::<String, _>("id")
.ok()
.unwrap_or_default();
let order_no = order_row
.try_get::<String, _>("order_no")
.ok()
.unwrap_or_default();
let order_wallet_id = order_row
.try_get::<String, _>("wallet_id")
.ok()
.unwrap_or_default();
let order_payment_method = order_row
.try_get::<String, _>("payment_method")
.ok()
.unwrap_or_default();
let order_amount_usd = order_row
.try_get::<f64, _>("amount_usd")
.ok()
.unwrap_or_default();
let order_status = order_row
.try_get::<String, _>("effective_status")
.ok()
.unwrap_or_default();
let expires_at_unix_secs = order_row
.try_get::<Option<i64>, _>("expires_at_unix_secs")
.ok()
.flatten();
if (payload.amount_usd - order_amount_usd).abs() > f64::EPSILON {
update_payment_callback_failure(
&mut tx,
&callback_id,
payload,
&callback_payload_hash,
signature_valid,
"callback amount mismatch",
)
.await;
let _ = tx.commit().await;
return payment_callback_mark_failed_response(
duplicate,
"callback amount mismatch",
payment_method,
&request_context.request_path,
);
}
if !order_payment_method.eq_ignore_ascii_case(payment_method) {
update_payment_callback_failure(
&mut tx,
&callback_id,
payload,
&callback_payload_hash,
signature_valid,
"payment method mismatch",
)
.await;
let _ = tx.commit().await;
return payment_callback_mark_failed_response(
duplicate,
"payment method mismatch",
payment_method,
&request_context.request_path,
);
}
if order_status == "credited" {
let _ = sqlx::query(
r#"
UPDATE payment_callbacks
SET payment_order_id = $2,
signature_valid = true,
status = 'processed',
error_message = NULL,
payload_hash = $3,
payload = $4,
processed_at = NOW(),
order_no = $5,
gateway_order_id = COALESCE($6, gateway_order_id)
WHERE id = $1
"#,
)
.bind(&callback_id)
.bind(&order_id)
.bind(&callback_payload_hash)
.bind(&payload.payload)
.bind(&order_no)
.bind(payload.gateway_order_id.as_deref())
.execute(&mut *tx)
.await;
let _ = tx.commit().await;
return build_auth_json_response(
order_id,
order_no,
wallet_id,
} => build_auth_json_response(
http::StatusCode::OK,
json!({
"ok": true,
@@ -416,351 +95,43 @@ WHERE id = $1
"order_id": order_id,
"order_no": order_no,
"status": "credited",
"wallet_id": order_wallet_id,
"wallet_id": wallet_id,
"payment_method": payment_method,
"request_path": request_context.request_path,
}),
None,
);
}
if matches!(order_status.as_str(), "failed" | "expired" | "refunded") {
let error = format!("payment order is not creditable: {order_status}");
update_payment_callback_failure(
&mut tx,
&callback_id,
payload,
&callback_payload_hash,
signature_valid,
&error,
)
.await;
let _ = tx.commit().await;
return payment_callback_mark_failed_response(
),
aether_data::repository::wallet::ProcessPaymentCallbackOutcome::Applied {
duplicate,
&error,
payment_method,
&request_context.request_path,
);
order_id,
order_no,
wallet_id,
order,
} => build_auth_json_response(
http::StatusCode::OK,
json!({
"ok": true,
"duplicate": duplicate,
"credited": true,
"order_id": order_id,
"order_no": order_no,
"status": order.status,
"wallet_id": wallet_id,
"payment_method": payment_method,
"request_path": request_context.request_path,
}),
None,
),
}
if order_status == "pending" {
let now = Utc::now().timestamp();
if expires_at_unix_secs.is_some_and(|value| value < now) {
let _ = sqlx::query("UPDATE payment_orders SET status = 'expired' WHERE id = $1")
.bind(&order_id)
.execute(&mut *tx)
.await;
update_payment_callback_failure(
&mut tx,
&callback_id,
payload,
&callback_payload_hash,
signature_valid,
"payment order expired",
)
.await;
let _ = tx.commit().await;
return payment_callback_mark_failed_response(
duplicate,
"payment order expired",
payment_method,
&request_context.request_path,
);
}
}
let wallet_row = match sqlx::query(
r#"
SELECT
id,
status,
CAST(balance AS DOUBLE PRECISION) AS balance,
CAST(gift_balance AS DOUBLE PRECISION) AS gift_balance,
CAST(total_recharged AS DOUBLE PRECISION) AS total_recharged
FROM wallets
WHERE id = $1
LIMIT 1
FOR UPDATE
"#,
)
.bind(&order_wallet_id)
.fetch_optional(&mut *tx)
.await
{
Ok(value) => value,
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback wallet lookup failed: {err}"),
false,
);
}
};
let Some(wallet_row) = wallet_row else {
update_payment_callback_failure(
&mut tx,
&callback_id,
payload,
&callback_payload_hash,
signature_valid,
"wallet not found",
)
.await;
let _ = tx.commit().await;
return payment_callback_mark_failed_response(
duplicate,
"wallet not found",
payment_method,
&request_context.request_path,
);
};
let wallet_status = wallet_row
.try_get::<String, _>("status")
.ok()
.unwrap_or_default();
if wallet_status != "active" {
update_payment_callback_failure(
&mut tx,
&callback_id,
payload,
&callback_payload_hash,
signature_valid,
"wallet is not active",
)
.await;
let _ = tx.commit().await;
return payment_callback_mark_failed_response(
duplicate,
"wallet is not active",
payment_method,
&request_context.request_path,
);
}
let before_recharge = wallet_row
.try_get::<f64, _>("balance")
.ok()
.unwrap_or_default();
let before_gift = wallet_row
.try_get::<f64, _>("gift_balance")
.ok()
.unwrap_or_default();
let before_total = before_recharge + before_gift;
let after_recharge = before_recharge + order_amount_usd;
let after_total = after_recharge + before_gift;
let wallet_tx_id = Uuid::new_v4().to_string();
if let Err(err) = sqlx::query(
r#"
UPDATE wallets
SET balance = $2,
total_recharged = total_recharged + $3,
updated_at = NOW()
WHERE id = $1
"#,
)
.bind(&order_wallet_id)
.bind(after_recharge)
.bind(order_amount_usd)
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback wallet update failed: {err}"),
false,
);
}
if let Err(err) = sqlx::query(
r#"
INSERT INTO wallet_transactions (
id,
wallet_id,
category,
reason_code,
amount,
balance_before,
balance_after,
recharge_balance_before,
recharge_balance_after,
gift_balance_before,
gift_balance_after,
link_type,
link_id,
operator_id,
description,
created_at
)
VALUES (
$1,
$2,
'recharge',
'topup_gateway',
$3,
$4,
$5,
$6,
$7,
$8,
$9,
'payment_order',
$10,
NULL,
$11,
NOW()
)
"#,
)
.bind(&wallet_tx_id)
.bind(&order_wallet_id)
.bind(order_amount_usd)
.bind(before_total)
.bind(after_total)
.bind(before_recharge)
.bind(after_recharge)
.bind(before_gift)
.bind(before_gift)
.bind(&order_id)
.bind(format!("充值到账({payment_method})"))
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback wallet transaction failed: {err}"),
false,
);
}
let updated_order_row = match sqlx::query(
r#"
UPDATE payment_orders
SET gateway_order_id = COALESCE($2, gateway_order_id),
gateway_response = $3,
pay_amount = COALESCE($4, pay_amount),
pay_currency = COALESCE($5, pay_currency),
exchange_rate = COALESCE($6, exchange_rate),
status = 'credited',
paid_at = COALESCE(paid_at, NOW()),
credited_at = NOW(),
refundable_amount_usd = amount_usd
WHERE id = $1
RETURNING
id,
order_no,
wallet_id,
user_id,
CAST(amount_usd AS DOUBLE PRECISION) AS amount_usd,
CAST(pay_amount AS DOUBLE PRECISION) AS pay_amount,
pay_currency,
CAST(exchange_rate AS DOUBLE PRECISION) AS exchange_rate,
CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd,
CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd,
payment_method,
gateway_order_id,
gateway_response,
status AS effective_status,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM paid_at) AS BIGINT) AS paid_at_unix_secs,
CAST(EXTRACT(EPOCH FROM credited_at) AS BIGINT) AS credited_at_unix_secs,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs
"#,
)
.bind(&order_id)
.bind(payload.gateway_order_id.as_deref())
.bind(&payload.payload)
.bind(payload.pay_amount)
.bind(payload.pay_currency.as_deref())
.bind(payload.exchange_rate)
.fetch_one(&mut *tx)
.await
{
Ok(value) => value,
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback order update failed: {err}"),
false,
);
}
};
if let Err(err) = sqlx::query(
r#"
UPDATE payment_callbacks
SET payment_order_id = $2,
signature_valid = true,
status = 'processed',
error_message = NULL,
payload_hash = $3,
payload = $4,
processed_at = NOW(),
order_no = $5,
gateway_order_id = COALESCE($6, gateway_order_id)
WHERE id = $1
"#,
)
.bind(&callback_id)
.bind(&order_id)
.bind(&callback_payload_hash)
.bind(&payload.payload)
.bind(&order_no)
.bind(payload.gateway_order_id.as_deref())
.execute(&mut *tx)
.await
{
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback update failed: {err}"),
false,
);
}
if let Err(err) = tx.commit().await {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback commit failed: {err}"),
false,
);
}
let updated_order_payload = match wallet_payment_order_payload_from_row(&updated_order_row) {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("payment callback order payload failed: {err:?}"),
false,
);
}
};
build_auth_json_response(
http::StatusCode::OK,
json!({
"ok": true,
"duplicate": duplicate,
"credited": true,
"order_id": order_id,
"order_no": order_no,
"status": updated_order_payload["status"],
"wallet_id": order_wallet_id,
"payment_method": payment_method,
"request_path": request_context.request_path,
}),
None,
)
}
#[cfg(test)]
mod tests {
use super::{handle_payment_callback_with_postgres, AppState, NormalizedPaymentCallbackRequest};
use crate::gateway::handlers::public::support::support_payment::PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL;
use crate::gateway::GatewayPublicRequestContext;
use super::{
handle_payment_callback_with_postgres, AppState, NormalizedPaymentCallbackRequest,
};
use crate::control::GatewayPublicRequestContext;
use crate::handlers::public::support::support_payment::PAYMENT_CALLBACK_STORAGE_UNAVAILABLE_DETAIL;
use axum::body::to_bytes;
use axum::http::{HeaderMap, Method, Uri};
use serde_json::json;

View File

@@ -31,7 +31,7 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
));
};
let Some(provided_token) =
crate::gateway::headers::header_value_str(headers, PAYMENT_CALLBACK_TOKEN_HEADER)
crate::headers::header_value_str(headers, PAYMENT_CALLBACK_TOKEN_HEADER)
else {
return Some(build_auth_error_response(
http::StatusCode::UNAUTHORIZED,
@@ -47,7 +47,7 @@ pub(super) async fn maybe_build_local_payment_callback_route_response(
));
}
let Some(signature) =
crate::gateway::headers::header_value_str(headers, PAYMENT_CALLBACK_SIGNATURE_HEADER)
crate::headers::header_value_str(headers, PAYMENT_CALLBACK_SIGNATURE_HEADER)
else {
return Some(build_auth_error_response(
http::StatusCode::UNAUTHORIZED,

View File

@@ -1,7 +1,7 @@
use axum::{body::Body, response::Response};
pub(super) use super::{query_param_value, AppState, GatewayPublicRequestContext};
use crate::gateway::handlers::admin::provider_catalog_key_supports_format;
use crate::handlers::admin::misc_helpers::provider_catalog_key_supports_format;
#[path = "test_connection/route.rs"]
mod test_connection_route;

View File

@@ -157,7 +157,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
if transport.provider.proxy.is_some()
|| transport.endpoint.proxy.is_some()
|| transport.key.proxy.is_some()
|| crate::gateway::provider_transport::resolve_transport_tls_profile(&transport).is_some()
|| crate::provider_transport::resolve_transport_tls_profile(&transport).is_some()
{
return None;
}
@@ -179,7 +179,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
}),
_ => return None,
};
if !crate::gateway::provider_transport::apply_local_body_rules(
if !crate::provider_transport::apply_local_body_rules(
&mut provider_request_body,
transport.endpoint.body_rules.as_ref(),
None,
@@ -191,7 +191,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
"openai:chat" | "claude:chat" => {
match state.resolve_local_oauth_request_auth(&transport).await {
Ok(Some(
crate::gateway::provider_transport::LocalResolvedOAuthRequestAuth::Header {
crate::provider_transport::LocalResolvedOAuthRequestAuth::Header {
name,
value,
},
@@ -204,14 +204,16 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
let auth = match format_value.as_str() {
"openai:chat" => {
crate::gateway::provider_transport::resolve_local_openai_chat_auth(&transport)
crate::provider_transport::auth::resolve_local_openai_chat_auth(&transport)
.or(oauth_auth.clone())
}
"claude:chat" => {
crate::gateway::provider_transport::resolve_local_standard_auth(&transport)
crate::provider_transport::auth::resolve_local_standard_auth(&transport)
.or(oauth_auth.clone())
}
"gemini:chat" => crate::gateway::provider_transport::resolve_local_gemini_auth(&transport),
"gemini:chat" => {
crate::provider_transport::auth::resolve_local_gemini_auth(&transport)
}
_ => None,
};
let Some((auth_header, auth_value)) = auth else {
@@ -227,7 +229,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
.filter(|value| !value.is_empty());
match (format_value.as_str(), custom_path) {
("openai:chat", Some(path)) | ("claude:chat", Some(path)) => {
crate::gateway::provider_transport::build_passthrough_path_url(
crate::provider_transport::url::build_passthrough_path_url(
&transport.endpoint.base_url,
path,
None,
@@ -235,31 +237,33 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
)
}
("gemini:chat", Some(path)) => {
crate::gateway::provider_transport::build_passthrough_path_url(
crate::provider_transport::url::build_passthrough_path_url(
&transport.endpoint.base_url,
path,
None,
&["key"],
)
}
("openai:chat", None) => {
Some(crate::gateway::provider_transport::build_openai_chat_url(
&transport.endpoint.base_url,
None,
))
}
("claude:chat", None) => Some(
crate::gateway::provider_transport::build_claude_messages_url(
("openai:chat", None) => Some(
crate::provider_transport::url::build_openai_chat_url(
&transport.endpoint.base_url,
None,
),
),
("gemini:chat", None) => crate::gateway::provider_transport::build_gemini_content_url(
&transport.endpoint.base_url,
&model,
false,
None,
("claude:chat", None) => Some(
crate::provider_transport::url::build_claude_messages_url(
&transport.endpoint.base_url,
None,
),
),
("gemini:chat", None) => {
crate::provider_transport::url::build_gemini_content_url(
&transport.endpoint.base_url,
&model,
false,
None,
)
}
_ => None,
}
};
@@ -271,7 +275,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
("content-type".to_string(), "application/json".to_string()),
(auth_header.clone(), auth_value.clone()),
]);
if !crate::gateway::provider_transport::apply_local_header_rules(
if !crate::provider_transport::apply_local_header_rules(
&mut provider_request_headers,
transport.endpoint.header_rules.as_ref(),
&[auth_header.as_str(), "content-type"],
@@ -280,7 +284,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
) {
return None;
}
crate::gateway::provider_transport::ensure_upstream_auth_header(
crate::provider_transport::ensure_upstream_auth_header(
&mut provider_request_headers,
&auth_header,
&auth_value,
@@ -291,7 +295,7 @@ pub(super) async fn maybe_build_local_test_connection_route_response(
upstream_request = upstream_request.header(name, value);
}
if let Some(total_ms) =
crate::gateway::provider_transport::resolve_transport_execution_timeouts(&transport)
crate::provider_transport::resolve_transport_execution_timeouts(&transport)
.and_then(|timeouts| timeouts.total_ms.or(timeouts.first_byte_ms))
{
upstream_request = upstream_request.timeout(Duration::from_millis(total_ms));

View File

@@ -5,12 +5,12 @@ use super::{
unix_secs_to_rfc3339, validate_auth_register_password, AppState, AuthenticatedLocalUserContext,
GatewayPublicRequestContext, PUBLIC_CAPABILITY_DEFINITIONS,
};
use crate::gateway::handlers::admin::{
admin_stats_bad_request_response, build_admin_endpoint_health_status_payload,
list_usage_for_optional_range, parse_bounded_u32, round_to, AdminStatsTimeRange,
AdminStatsUsageFilter,
use crate::handlers::admin::endpoints_health_helpers::build_admin_endpoint_health_status_payload;
use crate::handlers::internal::build_management_token_payload;
use crate::handlers::{
admin_stats_bad_request_response, list_usage_for_optional_range, parse_bounded_u32, round_to,
AdminStatsTimeRange, AdminStatsUsageFilter,
};
use crate::gateway::handlers::internal::build_management_token_payload;
const USERS_ME_AVAILABLE_MODELS_FETCH_LIMIT: usize = 1000;

View File

@@ -429,7 +429,7 @@ async fn resolve_users_me_api_key_snapshot_by_id(
state: &AppState,
user_id: &str,
api_key_id: &str,
) -> Result<crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot, Response<Body>> {
) -> Result<crate::data::auth::GatewayAuthApiKeySnapshot, Response<Body>> {
let snapshot = match state
.read_auth_api_key_snapshot(
user_id,
@@ -465,7 +465,7 @@ async fn resolve_users_me_api_key_snapshot_by_id(
}
fn ensure_users_me_api_key_mutable(
snapshot: &crate::gateway::gateway_data::StoredGatewayAuthApiKeySnapshot,
snapshot: &crate::data::auth::GatewayAuthApiKeySnapshot,
) -> Result<(), Response<Body>> {
if snapshot.api_key_is_locked {
return Err(build_auth_error_response(

View File

@@ -19,7 +19,7 @@ use super::{
query_param_value, resolve_authenticated_local_user, AppState, AuthenticatedLocalUserContext,
GatewayPublicRequestContext,
};
use crate::gateway::LocalMutationOutcome;
use crate::LocalMutationOutcome;
const USERS_ME_MANAGEMENT_TOKEN_PREFIX: &str = "ae_";
const USERS_ME_MANAGEMENT_TOKEN_RANDOM_LENGTH: usize = 40;

View File

@@ -73,7 +73,7 @@ fn validate_user_model_capability_settings(
}
fn build_users_me_preferences_payload(
preferences: &crate::gateway::gateway_data::StoredUserPreferenceRecord,
preferences: &crate::data::state::StoredUserPreferenceRecord,
) -> serde_json::Value {
json!({
"avatar_url": preferences.avatar_url,
@@ -177,9 +177,11 @@ pub(super) async fn handle_users_me_preferences_get(
let preferences = match state.read_user_preferences(&auth.user.id).await {
Ok(Some(value)) => value,
Ok(None) => crate::gateway::gateway_data::StoredUserPreferenceRecord::default_for_user(
&auth.user.id,
),
Ok(None) => {
crate::data::state::StoredUserPreferenceRecord::default_for_user(
&auth.user.id,
)
}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
@@ -217,9 +219,11 @@ pub(super) async fn handle_users_me_preferences_put(
let mut preferences = match state.read_user_preferences(&auth.user.id).await {
Ok(Some(value)) => value,
Ok(None) => crate::gateway::gateway_data::StoredUserPreferenceRecord::default_for_user(
&auth.user.id,
),
Ok(None) => {
crate::data::state::StoredUserPreferenceRecord::default_for_user(
&auth.user.id,
)
}
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,

View File

@@ -1,4 +1,4 @@
use crate::gateway::handlers::public::support::build_unhandled_public_support_response;
use crate::handlers::public::support::build_unhandled_public_support_response;
use axum::{body::Body, http, response::Response};
use super::{

View File

@@ -34,7 +34,7 @@ pub(super) fn users_me_session_detail_path_matches(request_path: &str) -> bool {
}
fn build_users_me_session_payload(
session: crate::gateway::gateway_data::StoredUserSessionRecord,
session: crate::data::state::StoredUserSessionRecord,
current_session_id: &str,
) -> serde_json::Value {
json!({

View File

@@ -1,6 +1,6 @@
use chrono::{DateTime, Utc};
use crate::gateway::gateway_data::StoredUserSessionRecord;
use crate::data::state::StoredUserSessionRecord;
pub(super) fn format_users_me_optional_datetime_iso8601(
value: Option<DateTime<Utc>>,

View File

@@ -30,7 +30,7 @@ use self::reads::{
build_wallet_daily_usage_payload, build_wallet_payload, build_wallet_zero_today_entry,
handle_wallet_balance, handle_wallet_today_cost, handle_wallet_transactions,
parse_wallet_limit, parse_wallet_offset, wallet_fixed_offset, wallet_today_billing_date_string,
wallet_transaction_payload_from_row,
wallet_transaction_payload_from_record,
};
use self::recharge::{
handle_wallet_create_recharge, handle_wallet_recharge_detail, handle_wallet_recharge_list,

View File

@@ -2,11 +2,10 @@ use super::{
build_auth_error_response, build_auth_json_response, build_wallet_daily_usage_payload,
build_wallet_payload, build_wallet_zero_today_entry, http, parse_wallet_limit,
parse_wallet_offset, resolve_authenticated_local_user, unix_secs_to_rfc3339,
wallet_fixed_offset, wallet_today_billing_date_string, wallet_transaction_payload_from_row,
wallet_fixed_offset, wallet_today_billing_date_string, wallet_transaction_payload_from_record,
AppState, Body, GatewayPublicRequestContext, Response, WALLET_LEGACY_TIMEZONE,
};
use serde_json::json;
use sqlx::Row;
fn wallet_flow_sort_key(item_type: &str, payload: &serde_json::Value) -> (String, u8, String) {
match item_type {
@@ -100,223 +99,85 @@ pub(super) async fn handle_wallet_flow(
};
let mut today_entry = build_wallet_zero_today_entry();
let mut items = Vec::new();
let mut total = 0_u64;
if let Some(pool) = state.postgres_pool() {
let today_row = sqlx::query(
r#"
SELECT
id,
billing_date::text AS billing_date,
billing_timezone,
CAST(total_cost_usd AS DOUBLE PRECISION) AS total_cost_usd,
total_requests,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
CAST(EXTRACT(EPOCH FROM first_finalized_at) AS BIGINT) AS first_finalized_at_unix_secs,
CAST(EXTRACT(EPOCH FROM last_finalized_at) AS BIGINT) AS last_finalized_at_unix_secs,
CAST(EXTRACT(EPOCH FROM aggregated_at) AS BIGINT) AS aggregated_at_unix_secs
FROM wallet_daily_usage_ledgers
WHERE wallet_id = $1
AND billing_timezone = $2
AND billing_date = (timezone($2, now()))::date
LIMIT 1
"#,
)
.bind(&wallet.id)
.bind(WALLET_LEGACY_TIMEZONE)
.fetch_optional(&pool)
.await;
if let Ok(Some(row)) = today_row {
today_entry = build_wallet_daily_usage_payload(
row.try_get::<Option<String>, _>("id").ok().flatten(),
row.try_get::<String, _>("billing_date")
.ok()
.unwrap_or_else(wallet_today_billing_date_string),
row.try_get::<String, _>("billing_timezone")
.ok()
.unwrap_or_else(|| WALLET_LEGACY_TIMEZONE.to_string()),
row.try_get::<f64, _>("total_cost_usd")
.ok()
.unwrap_or_default(),
row.try_get::<i64, _>("total_requests")
.ok()
.unwrap_or_default()
.max(0) as u64,
row.try_get::<i64, _>("input_tokens")
.ok()
.unwrap_or_default()
.max(0) as u64,
row.try_get::<i64, _>("output_tokens")
.ok()
.unwrap_or_default()
.max(0) as u64,
row.try_get::<i64, _>("cache_creation_tokens")
.ok()
.unwrap_or_default()
.max(0) as u64,
row.try_get::<i64, _>("cache_read_tokens")
.ok()
.unwrap_or_default()
.max(0) as u64,
row.try_get::<Option<i64>, _>("first_finalized_at_unix_secs")
.ok()
.flatten()
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339),
row.try_get::<Option<i64>, _>("last_finalized_at_unix_secs")
.ok()
.flatten()
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339),
row.try_get::<Option<i64>, _>("aggregated_at_unix_secs")
.ok()
.flatten()
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339),
true,
);
}
let fetch_size = offset.saturating_add(limit).min(5200);
let tx_count_row = sqlx::query(
r#"
SELECT COUNT(*) AS total
FROM wallet_transactions
WHERE wallet_id = $1
"#,
)
.bind(&wallet.id)
.fetch_one(&pool)
.await;
let tx_total = tx_count_row
.ok()
.and_then(|row| row.try_get::<i64, _>("total").ok())
.unwrap_or_default()
.max(0) as u64;
let tx_rows = sqlx::query(
r#"
SELECT
id,
category,
reason_code,
CAST(amount AS DOUBLE PRECISION) AS amount,
CAST(balance_before AS DOUBLE PRECISION) AS balance_before,
CAST(balance_after AS DOUBLE PRECISION) AS balance_after,
CAST(recharge_balance_before AS DOUBLE PRECISION) AS recharge_balance_before,
CAST(recharge_balance_after AS DOUBLE PRECISION) AS recharge_balance_after,
CAST(gift_balance_before AS DOUBLE PRECISION) AS gift_balance_before,
CAST(gift_balance_after AS DOUBLE PRECISION) AS gift_balance_after,
link_type,
link_id,
operator_id,
description,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs
FROM wallet_transactions
WHERE wallet_id = $1
ORDER BY created_at DESC
LIMIT $2
"#,
)
.bind(&wallet.id)
.bind(i64::try_from(fetch_size).ok().unwrap_or(50))
.fetch_all(&pool)
if let Ok(Some(today_usage)) = state
.find_wallet_today_usage(&wallet.id, WALLET_LEGACY_TIMEZONE)
.await
.unwrap_or_default();
let daily_count_row = sqlx::query(
r#"
SELECT COUNT(*) AS total
FROM wallet_daily_usage_ledgers
WHERE wallet_id = $1
AND billing_timezone = $2
AND billing_date < (timezone($2, now()))::date
"#,
)
.bind(&wallet.id)
.bind(WALLET_LEGACY_TIMEZONE)
.fetch_one(&pool)
.await;
let daily_total = daily_count_row
.ok()
.and_then(|row| row.try_get::<i64, _>("total").ok())
.unwrap_or_default()
.max(0) as u64;
let daily_rows = sqlx::query(
r#"
SELECT
id,
billing_date::text AS billing_date,
billing_timezone,
CAST(total_cost_usd AS DOUBLE PRECISION) AS total_cost_usd,
total_requests,
input_tokens,
output_tokens,
cache_creation_tokens,
cache_read_tokens,
CAST(EXTRACT(EPOCH FROM first_finalized_at) AS BIGINT) AS first_finalized_at_unix_secs,
CAST(EXTRACT(EPOCH FROM last_finalized_at) AS BIGINT) AS last_finalized_at_unix_secs,
CAST(EXTRACT(EPOCH FROM aggregated_at) AS BIGINT) AS aggregated_at_unix_secs
FROM wallet_daily_usage_ledgers
WHERE wallet_id = $1
AND billing_timezone = $2
AND billing_date < (timezone($2, now()))::date
ORDER BY billing_date DESC
LIMIT $3
"#,
)
.bind(&wallet.id)
.bind(WALLET_LEGACY_TIMEZONE)
.bind(i64::try_from(fetch_size).ok().unwrap_or(50))
.fetch_all(&pool)
.await
.unwrap_or_default();
let mut merged = tx_rows
.iter()
.filter_map(|row| wallet_transaction_payload_from_row(row).ok())
.map(|data| json!({ "type": "transaction", "data": data }))
.collect::<Vec<_>>();
merged.extend(daily_rows.iter().map(|row| {
json!({
"type": "daily_usage",
"data": build_wallet_daily_usage_payload(
row.try_get::<Option<String>, _>("id").ok().flatten(),
row.try_get::<String, _>("billing_date").ok().unwrap_or_default(),
row.try_get::<String, _>("billing_timezone").ok().unwrap_or_else(|| WALLET_LEGACY_TIMEZONE.to_string()),
row.try_get::<f64, _>("total_cost_usd").ok().unwrap_or_default(),
row.try_get::<i64, _>("total_requests").ok().unwrap_or_default().max(0) as u64,
row.try_get::<i64, _>("input_tokens").ok().unwrap_or_default().max(0) as u64,
row.try_get::<i64, _>("output_tokens").ok().unwrap_or_default().max(0) as u64,
row.try_get::<i64, _>("cache_creation_tokens").ok().unwrap_or_default().max(0) as u64,
row.try_get::<i64, _>("cache_read_tokens").ok().unwrap_or_default().max(0) as u64,
row.try_get::<Option<i64>, _>("first_finalized_at_unix_secs").ok().flatten().and_then(|value| u64::try_from(value).ok()).and_then(unix_secs_to_rfc3339),
row.try_get::<Option<i64>, _>("last_finalized_at_unix_secs").ok().flatten().and_then(|value| u64::try_from(value).ok()).and_then(unix_secs_to_rfc3339),
row.try_get::<Option<i64>, _>("aggregated_at_unix_secs").ok().flatten().and_then(|value| u64::try_from(value).ok()).and_then(unix_secs_to_rfc3339),
false,
)
})
}));
merged.sort_by(|left, right| {
let left_type = left
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let right_type = right
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
wallet_flow_sort_key(right_type, right).cmp(&wallet_flow_sort_key(left_type, left))
});
items = merged
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
total = tx_total.saturating_add(daily_total);
{
today_entry = build_wallet_daily_usage_payload(
today_usage.id,
today_usage.billing_date,
today_usage.billing_timezone,
today_usage.total_cost_usd,
today_usage.total_requests,
today_usage.input_tokens,
today_usage.output_tokens,
today_usage.cache_creation_tokens,
today_usage.cache_read_tokens,
today_usage
.first_finalized_at_unix_secs
.and_then(unix_secs_to_rfc3339),
today_usage
.last_finalized_at_unix_secs
.and_then(unix_secs_to_rfc3339),
today_usage
.aggregated_at_unix_secs
.and_then(unix_secs_to_rfc3339),
true,
);
}
let fetch_size = offset.saturating_add(limit).min(5200);
let (transactions, tx_total) = state
.list_admin_wallet_transactions(&wallet.id, fetch_size, 0)
.await
.unwrap_or((Vec::new(), 0));
let daily_page = state
.list_wallet_daily_usage_history(&wallet.id, WALLET_LEGACY_TIMEZONE, fetch_size)
.await
.unwrap_or_default();
let mut merged = transactions
.iter()
.map(|record| json!({ "type": "transaction", "data": wallet_transaction_payload_from_record(record) }))
.collect::<Vec<_>>();
merged.extend(daily_page.items.iter().map(|entry| {
json!({
"type": "daily_usage",
"data": build_wallet_daily_usage_payload(
entry.id.clone(),
entry.billing_date.clone(),
entry.billing_timezone.clone(),
entry.total_cost_usd,
entry.total_requests,
entry.input_tokens,
entry.output_tokens,
entry.cache_creation_tokens,
entry.cache_read_tokens,
entry.first_finalized_at_unix_secs.and_then(unix_secs_to_rfc3339),
entry.last_finalized_at_unix_secs.and_then(unix_secs_to_rfc3339),
entry.aggregated_at_unix_secs.and_then(unix_secs_to_rfc3339),
false,
)
})
}));
merged.sort_by(|left, right| {
let left_type = left
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let right_type = right
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
wallet_flow_sort_key(right_type, right).cmp(&wallet_flow_sort_key(left_type, left))
});
let items = merged
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
let total = tx_total.saturating_add(daily_page.total);
let mut payload = json!({
"today_entry": today_entry,
"items": items,

View File

@@ -3,10 +3,9 @@ use super::{
query_param_value, resolve_authenticated_local_user, unix_secs_to_rfc3339, AppState, Body,
GatewayError, GatewayPublicRequestContext, Response, WALLET_LEGACY_TIMEZONE,
};
use crate::gateway::handlers::admin::round_to;
use crate::handlers::round_to;
use chrono::Utc;
use serde_json::json;
use sqlx::Row;
const WALLET_TODAY_COST_UNAVAILABLE_DETAIL: &str = "钱包今日费用数据暂不可用";
@@ -135,31 +134,26 @@ pub(super) fn build_wallet_zero_today_entry() -> serde_json::Value {
)
}
pub(super) fn wallet_transaction_payload_from_row(
row: &sqlx::postgres::PgRow,
) -> Result<serde_json::Value, GatewayError> {
let created_at = row
.try_get::<Option<i64>, _>("created_at_unix_secs")
.map_err(|err| GatewayError::Internal(err.to_string()))?
.and_then(|value| u64::try_from(value).ok())
.and_then(unix_secs_to_rfc3339);
Ok(json!({
"id": row.try_get::<String, _>("id").map_err(|err| GatewayError::Internal(err.to_string()))?,
"category": row.try_get::<String, _>("category").map_err(|err| GatewayError::Internal(err.to_string()))?,
"reason_code": row.try_get::<String, _>("reason_code").map_err(|err| GatewayError::Internal(err.to_string()))?,
"amount": row.try_get::<f64, _>("amount").map_err(|err| GatewayError::Internal(err.to_string()))?,
"balance_before": row.try_get::<f64, _>("balance_before").map_err(|err| GatewayError::Internal(err.to_string()))?,
"balance_after": row.try_get::<f64, _>("balance_after").map_err(|err| GatewayError::Internal(err.to_string()))?,
"recharge_balance_before": row.try_get::<f64, _>("recharge_balance_before").map_err(|err| GatewayError::Internal(err.to_string()))?,
"recharge_balance_after": row.try_get::<f64, _>("recharge_balance_after").map_err(|err| GatewayError::Internal(err.to_string()))?,
"gift_balance_before": row.try_get::<f64, _>("gift_balance_before").map_err(|err| GatewayError::Internal(err.to_string()))?,
"gift_balance_after": row.try_get::<f64, _>("gift_balance_after").map_err(|err| GatewayError::Internal(err.to_string()))?,
"link_type": row.try_get::<Option<String>, _>("link_type").map_err(|err| GatewayError::Internal(err.to_string()))?,
"link_id": row.try_get::<Option<String>, _>("link_id").map_err(|err| GatewayError::Internal(err.to_string()))?,
"operator_id": row.try_get::<Option<String>, _>("operator_id").map_err(|err| GatewayError::Internal(err.to_string()))?,
"description": row.try_get::<Option<String>, _>("description").map_err(|err| GatewayError::Internal(err.to_string()))?,
"created_at": created_at,
}))
pub(super) fn wallet_transaction_payload_from_record(
record: &aether_data::repository::wallet::StoredAdminWalletTransaction,
) -> serde_json::Value {
json!({
"id": record.id.clone(),
"category": record.category.clone(),
"reason_code": record.reason_code.clone(),
"amount": record.amount,
"balance_before": record.balance_before,
"balance_after": record.balance_after,
"recharge_balance_before": record.recharge_balance_before,
"recharge_balance_after": record.recharge_balance_after,
"gift_balance_before": record.gift_balance_before,
"gift_balance_after": record.gift_balance_after,
"link_type": record.link_type.clone(),
"link_id": record.link_id.clone(),
"operator_id": record.operator_id.clone(),
"description": record.description.clone(),
"created_at": record.created_at_unix_secs.and_then(unix_secs_to_rfc3339),
})
}
pub(super) async fn handle_wallet_balance(
@@ -342,89 +336,23 @@ pub(super) async fn handle_wallet_transactions(
);
};
let mut total = 0_u64;
let mut items = Vec::new();
if let Some(pool) = state.postgres_pool() {
let count_row = match sqlx::query(
r#"
SELECT COUNT(*) AS total
FROM wallet_transactions
WHERE wallet_id = $1
"#,
)
.bind(&wallet.id)
.fetch_one(&pool)
let (transactions, total) = match state
.list_admin_wallet_transactions(&wallet.id, limit, offset)
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet transaction count failed: {err}"),
false,
)
}
};
total = count_row
.try_get::<i64, _>("total")
.ok()
.unwrap_or_default()
.max(0) as u64;
let rows = match sqlx::query(
r#"
SELECT
id,
category,
reason_code,
CAST(amount AS DOUBLE PRECISION) AS amount,
CAST(balance_before AS DOUBLE PRECISION) AS balance_before,
CAST(balance_after AS DOUBLE PRECISION) AS balance_after,
CAST(recharge_balance_before AS DOUBLE PRECISION) AS recharge_balance_before,
CAST(recharge_balance_after AS DOUBLE PRECISION) AS recharge_balance_after,
CAST(gift_balance_before AS DOUBLE PRECISION) AS gift_balance_before,
CAST(gift_balance_after AS DOUBLE PRECISION) AS gift_balance_after,
link_type,
link_id,
operator_id,
description,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs
FROM wallet_transactions
WHERE wallet_id = $1
ORDER BY created_at DESC
OFFSET $2
LIMIT $3
"#,
)
.bind(&wallet.id)
.bind(i64::try_from(offset).ok().unwrap_or_default())
.bind(i64::try_from(limit).ok().unwrap_or_default())
.fetch_all(&pool)
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet transaction query failed: {err}"),
false,
)
}
};
items = match rows
.iter()
.map(wallet_transaction_payload_from_row)
.collect::<Result<Vec<_>, GatewayError>>()
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet transaction payload failed: {err:?}"),
false,
)
}
};
}
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet transaction lookup failed: {err:?}"),
false,
)
}
};
let items = transactions
.iter()
.map(wallet_transaction_payload_from_record)
.collect::<Vec<_>>();
let mut payload = json!({
"items": items,
"total": total,

View File

@@ -262,6 +262,31 @@ pub(crate) fn wallet_payment_order_payload_from_row(
))
}
fn wallet_payment_order_payload_from_record(
record: &aether_data::repository::wallet::StoredAdminPaymentOrder,
) -> serde_json::Value {
build_wallet_payment_order_payload(
record.id.clone(),
record.order_no.clone(),
record.wallet_id.clone(),
record.user_id.clone(),
record.amount_usd,
record.pay_amount,
record.pay_currency.clone(),
record.exchange_rate,
record.refunded_amount_usd,
record.refundable_amount_usd,
record.payment_method.clone(),
record.gateway_order_id.clone(),
record.gateway_response.clone(),
record.status.clone(),
Some(unix_secs_to_rfc3339(record.created_at_unix_secs)).flatten(),
record.paid_at_unix_secs.and_then(unix_secs_to_rfc3339),
record.credited_at_unix_secs.and_then(unix_secs_to_rfc3339),
record.expires_at_unix_secs.and_then(unix_secs_to_rfc3339),
)
}
pub(super) async fn handle_wallet_create_recharge(
state: &AppState,
request_context: &GatewayPublicRequestContext,
@@ -311,7 +336,7 @@ pub(super) async fn handle_wallet_create_recharge(
}
};
let Some(pool) = state.postgres_pool() else {
if state.postgres_pool().is_none() {
#[cfg(test)]
{
let Some(wallet) = wallet else {
@@ -375,239 +400,56 @@ pub(super) async fn handle_wallet_create_recharge(
}
#[cfg(not(test))]
return build_wallet_recharge_storage_unavailable_response();
};
let mut tx = match pool.begin().await {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge transaction failed: {err}"),
false,
)
}
};
let wallet_row = match sqlx::query(
r#"
SELECT id, status
FROM wallets
WHERE user_id = $1
LIMIT 1
FOR UPDATE
"#,
)
.bind(&auth.user.id)
.fetch_optional(&mut *tx)
.await
{
Ok(Some(value)) => Some(value),
Ok(None) => {
let wallet_id = wallet
.as_ref()
.map(|value| value.id.clone())
.unwrap_or_else(|| Uuid::new_v4().to_string());
match sqlx::query(
r#"
INSERT INTO wallets (
id,
user_id,
balance,
gift_balance,
limit_mode,
currency,
status,
total_recharged,
total_consumed,
total_refunded,
total_adjusted,
created_at,
updated_at
)
VALUES (
$1,
$2,
0,
0,
'finite',
'USD',
'active',
0,
0,
0,
0,
NOW(),
NOW()
)
ON CONFLICT (user_id) DO UPDATE
SET updated_at = wallets.updated_at
RETURNING id, status
"#,
)
.bind(&wallet_id)
.bind(&auth.user.id)
.fetch_one(&mut *tx)
.await
{
Ok(value) => Some(value),
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge wallet bootstrap failed: {err}"),
false,
);
}
}
}
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge wallet lookup failed: {err}"),
false,
);
}
};
let Some(wallet_row) = wallet_row else {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"wallet not available",
false,
);
};
let wallet_id = wallet_row
.try_get::<String, _>("id")
.ok()
.unwrap_or_default();
let wallet_status = wallet_row
.try_get::<String, _>("status")
.ok()
.unwrap_or_default();
if wallet_status != "active" {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"wallet is not active",
false,
);
}
let now = Utc::now();
let order_id = Uuid::new_v4().to_string();
let order_no = wallet_build_order_no(now);
let expires_at = now + chrono::Duration::minutes(30);
let (gateway_order_id, gateway_response) =
match wallet_checkout_payload(&payload.payment_method, &order_no, expires_at) {
Ok(value) => value,
Err(detail) => {
let _ = tx.rollback().await;
return build_auth_error_response(http::StatusCode::BAD_REQUEST, detail, false);
}
};
let row = match sqlx::query(
r#"
INSERT INTO payment_orders (
id,
order_no,
wallet_id,
user_id,
amount_usd,
pay_amount,
pay_currency,
exchange_rate,
refunded_amount_usd,
refundable_amount_usd,
payment_method,
gateway_order_id,
gateway_response,
status,
created_at,
expires_at
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
0,
0,
$9,
$10,
$11,
'pending',
NOW(),
to_timestamp($12)
)
RETURNING
id,
order_no,
wallet_id,
user_id,
CAST(amount_usd AS DOUBLE PRECISION) AS amount_usd,
CAST(pay_amount AS DOUBLE PRECISION) AS pay_amount,
pay_currency,
CAST(exchange_rate AS DOUBLE PRECISION) AS exchange_rate,
CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd,
CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd,
payment_method,
gateway_order_id,
gateway_response,
status AS effective_status,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM paid_at) AS BIGINT) AS paid_at_unix_secs,
CAST(EXTRACT(EPOCH FROM credited_at) AS BIGINT) AS credited_at_unix_secs,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs
"#,
)
.bind(&order_id)
.bind(&order_no)
.bind(&wallet_id)
.bind(&auth.user.id)
.bind(payload.amount_usd)
.bind(payload.pay_amount)
.bind(payload.pay_currency.as_deref())
.bind(payload.exchange_rate)
.bind(&payload.payment_method)
.bind(&gateway_order_id)
.bind(&gateway_response)
.bind(expires_at.timestamp())
.fetch_one(&mut *tx)
.await
let outcome = match state
.create_wallet_recharge_order(
aether_data::repository::wallet::CreateWalletRechargeOrderInput {
preferred_wallet_id: wallet.as_ref().map(|value| value.id.clone()),
user_id: auth.user.id.clone(),
amount_usd: payload.amount_usd,
pay_amount: payload.pay_amount,
pay_currency: payload.pay_currency.clone(),
exchange_rate: payload.exchange_rate,
payment_method: payload.payment_method.clone(),
gateway_order_id,
gateway_response: gateway_response.clone(),
order_no,
expires_at_unix_secs: expires_at.timestamp().max(0) as u64,
},
)
.await
{
Ok(value) => value,
Ok(Some(value)) => value,
Ok(None) => return build_wallet_recharge_storage_unavailable_response(),
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge create failed: {err}"),
format!("wallet recharge create failed: {err:?}"),
false,
);
)
}
};
if let Err(err) = tx.commit().await {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge commit failed: {err}"),
false,
);
}
let order_payload = match wallet_payment_order_payload_from_row(&row) {
Ok(value) => value,
Err(err) => {
let order_payload = match outcome {
aether_data::repository::wallet::CreateWalletRechargeOrderOutcome::Created(order) => {
wallet_payment_order_payload_from_record(&order)
}
aether_data::repository::wallet::CreateWalletRechargeOrderOutcome::WalletInactive => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge payload failed: {err:?}"),
http::StatusCode::BAD_REQUEST,
"wallet is not active",
false,
);
)
}
};
build_auth_json_response(
@@ -658,103 +500,31 @@ pub(super) async fn handle_wallet_recharge_list(
}
};
let mut total = 0_u64;
let mut items = Vec::new();
if let Some(pool) = state.postgres_pool() {
let count_row = match sqlx::query(
r#"
SELECT COUNT(*) AS total
FROM payment_orders
WHERE user_id = $1
"#,
)
.bind(&auth.user.id)
.fetch_one(&pool)
let (items, total) = match state
.list_wallet_payment_orders_by_user_id(&auth.user.id, limit, offset)
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge count failed: {err}"),
false,
)
}
};
total = count_row
.try_get::<i64, _>("total")
.ok()
.unwrap_or_default()
.max(0) as u64;
let rows = match sqlx::query(
r#"
SELECT
id,
order_no,
wallet_id,
user_id,
CAST(amount_usd AS DOUBLE PRECISION) AS amount_usd,
CAST(pay_amount AS DOUBLE PRECISION) AS pay_amount,
pay_currency,
CAST(exchange_rate AS DOUBLE PRECISION) AS exchange_rate,
CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd,
CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd,
payment_method,
gateway_order_id,
gateway_response,
CASE
WHEN status = 'pending' AND expires_at IS NOT NULL AND expires_at < now() THEN 'expired'
ELSE status
END AS effective_status,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM paid_at) AS BIGINT) AS paid_at_unix_secs,
CAST(EXTRACT(EPOCH FROM credited_at) AS BIGINT) AS credited_at_unix_secs,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs
FROM payment_orders
WHERE user_id = $1
ORDER BY created_at DESC
OFFSET $2
LIMIT $3
"#,
)
.bind(&auth.user.id)
.bind(i64::try_from(offset).ok().unwrap_or_default())
.bind(i64::try_from(limit).ok().unwrap_or_default())
.fetch_all(&pool)
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge query failed: {err}"),
false,
)
}
};
items = match rows
.iter()
.map(wallet_payment_order_payload_from_row)
.collect::<Result<Vec<_>, GatewayError>>()
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge payload failed: {err:?}"),
false,
)
}
};
} else {
#[cfg(test)]
{
let (test_items, test_total) =
wallet_test_recharge_orders_for_user(&auth.user.id, limit, offset);
items = test_items;
total = test_total;
{
Ok(page) => (
page.items
.iter()
.map(wallet_payment_order_payload_from_record)
.collect::<Vec<_>>(),
page.total,
),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge lookup failed: {err:?}"),
false,
)
}
}
};
#[cfg(test)]
let (items, total) = if state.postgres_pool().is_none() && items.is_empty() && total == 0 {
wallet_test_recharge_orders_for_user(&auth.user.id, limit, offset)
} else {
(items, total)
};
let mut payload = json!({
"items": items,
@@ -786,9 +556,17 @@ pub(super) async fn handle_wallet_recharge_detail(
false,
);
};
let Some(pool) = state.postgres_pool() else {
#[cfg(test)]
{
match state
.find_wallet_payment_order_by_user_id(&auth.user.id, &order_id)
.await
{
Ok(Some(order)) => build_auth_json_response(
http::StatusCode::OK,
json!({ "order": wallet_payment_order_payload_from_record(&order) }),
None,
),
Ok(None) => {
#[cfg(test)]
if let Some(order) = wallet_test_recharge_order_by_id(&auth.user.id, &order_id) {
return build_auth_json_response(
http::StatusCode::OK,
@@ -796,72 +574,16 @@ pub(super) async fn handle_wallet_recharge_detail(
None,
);
}
}
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"Payment order not found",
false,
);
};
let row = match sqlx::query(
r#"
SELECT
id,
order_no,
wallet_id,
user_id,
CAST(amount_usd AS DOUBLE PRECISION) AS amount_usd,
CAST(pay_amount AS DOUBLE PRECISION) AS pay_amount,
pay_currency,
CAST(exchange_rate AS DOUBLE PRECISION) AS exchange_rate,
CAST(refunded_amount_usd AS DOUBLE PRECISION) AS refunded_amount_usd,
CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd,
payment_method,
gateway_order_id,
gateway_response,
CASE
WHEN status = 'pending' AND expires_at IS NOT NULL AND expires_at < now() THEN 'expired'
ELSE status
END AS effective_status,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM paid_at) AS BIGINT) AS paid_at_unix_secs,
CAST(EXTRACT(EPOCH FROM credited_at) AS BIGINT) AS credited_at_unix_secs,
CAST(EXTRACT(EPOCH FROM expires_at) AS BIGINT) AS expires_at_unix_secs
FROM payment_orders
WHERE id = $1 AND user_id = $2
LIMIT 1
"#,
)
.bind(&order_id)
.bind(&auth.user.id)
.fetch_optional(&pool)
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge detail query failed: {err}"),
build_auth_error_response(
http::StatusCode::NOT_FOUND,
"Payment order not found",
false,
)
}
};
let Some(row) = row else {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"Payment order not found",
Err(err) => build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge detail lookup failed: {err:?}"),
false,
);
};
let payload = match wallet_payment_order_payload_from_row(&row) {
Ok(value) => json!({ "order": value }),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet recharge detail payload failed: {err:?}"),
false,
)
}
};
build_auth_json_response(http::StatusCode::OK, payload, None)
),
}
}

View File

@@ -139,6 +139,31 @@ fn wallet_refund_payload_from_row(
}))
}
fn wallet_refund_payload_from_record(
record: &aether_data::repository::wallet::StoredAdminWalletRefund,
) -> serde_json::Value {
json!({
"id": record.id,
"refund_no": record.refund_no,
"payment_order_id": record.payment_order_id,
"source_type": record.source_type,
"source_id": record.source_id,
"refund_mode": record.refund_mode,
"amount_usd": record.amount_usd,
"status": record.status,
"reason": record.reason,
"failure_reason": record.failure_reason,
"gateway_refund_id": record.gateway_refund_id,
"payout_method": record.payout_method,
"payout_reference": record.payout_reference,
"payout_proof": record.payout_proof,
"created_at": unix_secs_to_rfc3339(record.created_at_unix_secs),
"updated_at": unix_secs_to_rfc3339(record.updated_at_unix_secs),
"processed_at": record.processed_at_unix_secs.and_then(unix_secs_to_rfc3339),
"completed_at": record.completed_at_unix_secs.and_then(unix_secs_to_rfc3339),
})
}
pub(super) async fn handle_wallet_refunds_list(
state: &AppState,
request_context: &GatewayPublicRequestContext,
@@ -191,102 +216,57 @@ pub(super) async fn handle_wallet_refunds_list(
return build_auth_json_response(http::StatusCode::OK, payload, None);
};
let mut total = 0_u64;
let mut items = Vec::new();
if let Some(pool) = state.postgres_pool() {
let count_row = match sqlx::query(
r#"
SELECT COUNT(*) AS total
FROM refund_requests
WHERE wallet_id = $1
"#,
)
.bind(&wallet.id)
.fetch_one(&pool)
let (refunds, total) = match state
.list_admin_wallet_refunds(&wallet.id, limit, offset)
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund count failed: {err}"),
false,
)
}
};
total = count_row
.try_get::<i64, _>("total")
.ok()
.unwrap_or_default()
.max(0) as u64;
let rows = match sqlx::query(
r#"
SELECT
id,
refund_no,
payment_order_id,
source_type,
source_id,
refund_mode,
CAST(amount_usd AS DOUBLE PRECISION) AS amount_usd,
status,
reason,
failure_reason,
gateway_refund_id,
payout_method,
payout_reference,
payout_proof,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs,
CAST(EXTRACT(EPOCH FROM processed_at) AS BIGINT) AS processed_at_unix_secs,
CAST(EXTRACT(EPOCH FROM completed_at) AS BIGINT) AS completed_at_unix_secs
FROM refund_requests
WHERE wallet_id = $1
ORDER BY created_at DESC
OFFSET $2
LIMIT $3
"#,
)
.bind(&wallet.id)
.bind(i64::try_from(offset).ok().unwrap_or_default())
.bind(i64::try_from(limit).ok().unwrap_or_default())
.fetch_all(&pool)
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund query failed: {err}"),
false,
)
}
};
items = match rows
.iter()
.map(wallet_refund_payload_from_row)
.collect::<Result<Vec<_>, GatewayError>>()
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund payload failed: {err:?}"),
false,
)
}
};
}
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund lookup failed: {err:?}"),
false,
)
}
};
let items = refunds
.iter()
.map(|record| {
json!({
"id": record.id,
"refund_no": record.refund_no,
"payment_order_id": record.payment_order_id,
"source_type": record.source_type,
"source_id": record.source_id,
"refund_mode": record.refund_mode,
"amount_usd": record.amount_usd,
"status": record.status,
"reason": record.reason,
"failure_reason": record.failure_reason,
"gateway_refund_id": record.gateway_refund_id,
"payout_method": record.payout_method,
"payout_reference": record.payout_reference,
"payout_proof": record.payout_proof,
"created_at": unix_secs_to_rfc3339(record.created_at_unix_secs),
"updated_at": unix_secs_to_rfc3339(record.updated_at_unix_secs),
"processed_at": record.processed_at_unix_secs.and_then(unix_secs_to_rfc3339),
"completed_at": record.completed_at_unix_secs.and_then(unix_secs_to_rfc3339),
})
})
.collect::<Vec<_>>();
#[cfg(test)]
if state.postgres_pool().is_none() {
let (items, total) = if state.postgres_pool().is_none() && items.is_empty() && total == 0 {
let all_items = wallet_test_refunds_for_wallet(&wallet.id);
total = all_items.len() as u64;
items = all_items
let total = all_items.len() as u64;
let items = all_items
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
}
(items, total)
} else {
(items, total)
};
let mut payload = json!({
"items": items,
@@ -340,75 +320,29 @@ pub(super) async fn handle_wallet_refund_detail(
false,
);
};
let Some(pool) = state.postgres_pool() else {
#[cfg(test)]
if let Some(payload) = wallet_test_refund_by_id(&wallet.id, &refund_id) {
return build_auth_json_response(http::StatusCode::OK, payload, None);
}
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"Refund request not found",
false,
);
};
let row = match sqlx::query(
r#"
SELECT
id,
refund_no,
payment_order_id,
source_type,
source_id,
refund_mode,
CAST(amount_usd AS DOUBLE PRECISION) AS amount_usd,
status,
reason,
failure_reason,
gateway_refund_id,
payout_method,
payout_reference,
payout_proof,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs,
CAST(EXTRACT(EPOCH FROM processed_at) AS BIGINT) AS processed_at_unix_secs,
CAST(EXTRACT(EPOCH FROM completed_at) AS BIGINT) AS completed_at_unix_secs
FROM refund_requests
WHERE wallet_id = $1 AND id = $2
LIMIT 1
"#,
)
.bind(&wallet.id)
.bind(&refund_id)
.fetch_optional(&pool)
.await
{
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund detail query failed: {err}"),
match state.find_wallet_refund(&wallet.id, &refund_id).await {
Ok(Some(refund)) => build_auth_json_response(
http::StatusCode::OK,
wallet_refund_payload_from_record(&refund),
None,
),
Ok(None) => {
#[cfg(test)]
if let Some(payload) = wallet_test_refund_by_id(&wallet.id, &refund_id) {
return build_auth_json_response(http::StatusCode::OK, payload, None);
}
build_auth_error_response(
http::StatusCode::NOT_FOUND,
"Refund request not found",
false,
)
}
};
let Some(row) = row else {
return build_auth_error_response(
http::StatusCode::NOT_FOUND,
"Refund request not found",
Err(err) => build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund detail lookup failed: {err:?}"),
false,
);
};
let payload = match wallet_refund_payload_from_row(&row) {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund detail payload failed: {err:?}"),
false,
)
}
};
build_auth_json_response(http::StatusCode::OK, payload, None)
),
}
}
pub(super) async fn handle_wallet_create_refund(
@@ -460,7 +394,7 @@ pub(super) async fn handle_wallet_create_refund(
);
};
let Some(pool) = state.postgres_pool() else {
if state.postgres_pool().is_none() {
#[cfg(test)]
{
if let Some(idempotency_key) = payload.idempotency_key.as_deref() {
@@ -509,377 +443,86 @@ pub(super) async fn handle_wallet_create_refund(
}
#[cfg(not(test))]
return build_wallet_refund_storage_unavailable_response();
};
}
let mut tx = match pool.begin().await {
Ok(value) => value,
let outcome = match state
.create_wallet_refund_request(
aether_data::repository::wallet::CreateWalletRefundRequestInput {
wallet_id: wallet.id.clone(),
user_id: auth.user.id.clone(),
amount_usd: payload.amount_usd,
payment_order_id: payload.payment_order_id.clone(),
source_type: payload.source_type.clone(),
source_id: payload.source_id.clone(),
refund_mode: payload.refund_mode.clone(),
reason: payload.reason.clone(),
idempotency_key: payload.idempotency_key.clone(),
refund_no: wallet_build_refund_no(Utc::now()),
},
)
.await
{
Ok(Some(value)) => value,
Ok(None) => return build_wallet_refund_storage_unavailable_response(),
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund transaction failed: {err}"),
format!("wallet refund create failed: {err:?}"),
false,
)
}
};
let locked_wallet_row = match sqlx::query(
r#"
SELECT
id,
CAST(balance AS DOUBLE PRECISION) AS balance
FROM wallets
WHERE id = $1
LIMIT 1
FOR UPDATE
"#,
)
.bind(&wallet.id)
.fetch_optional(&mut *tx)
.await
{
Ok(value) => value,
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund wallet lock failed: {err}"),
false,
);
match outcome {
aether_data::repository::wallet::CreateWalletRefundRequestOutcome::Created(refund)
| aether_data::repository::wallet::CreateWalletRefundRequestOutcome::Duplicate(refund) => {
build_auth_json_response(
http::StatusCode::OK,
wallet_refund_payload_from_record(&refund),
None,
)
}
};
let Some(locked_wallet_row) = locked_wallet_row else {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"当前账户尚未开通钱包,无法申请退款",
false,
);
};
let wallet_recharge_balance = locked_wallet_row
.try_get::<f64, _>("balance")
.ok()
.unwrap_or_default();
let wallet_reserved_row = match sqlx::query(
r#"
SELECT COALESCE(CAST(SUM(amount_usd) AS DOUBLE PRECISION), 0) AS total
FROM refund_requests
WHERE wallet_id = $1
AND status IN ('pending_approval', 'approved')
"#,
)
.bind(&wallet.id)
.fetch_one(&mut *tx)
.await
{
Ok(value) => value,
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund reserved amount lookup failed: {err}"),
aether_data::repository::wallet::CreateWalletRefundRequestOutcome::WalletMissing => {
build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"当前账户尚未开通钱包,无法申请退款",
false,
);
)
}
};
let wallet_reserved_amount = wallet_reserved_row
.try_get::<f64, _>("total")
.ok()
.unwrap_or_default();
if payload.amount_usd > (wallet_recharge_balance - wallet_reserved_amount) {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"refund amount exceeds available refundable recharge balance",
false,
);
}
let mut payment_order_id = None;
let mut source_type = payload
.source_type
.clone()
.unwrap_or_else(|| "wallet_balance".to_string());
let mut source_id = payload.source_id.clone();
let mut refund_mode = payload
.refund_mode
.clone()
.unwrap_or_else(|| "offline_payout".to_string());
if let Some(order_id) = payload.payment_order_id.as_deref() {
let order_row = match sqlx::query(
r#"
SELECT
id,
wallet_id,
status,
payment_method,
CAST(refundable_amount_usd AS DOUBLE PRECISION) AS refundable_amount_usd
FROM payment_orders
WHERE id = $1
AND wallet_id = $2
LIMIT 1
FOR UPDATE
"#,
)
.bind(order_id)
.bind(&wallet.id)
.fetch_optional(&mut *tx)
.await
{
Ok(value) => value,
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund payment order lookup failed: {err}"),
false,
);
}
};
let Some(order_row) = order_row else {
let _ = tx.rollback().await;
return build_auth_error_response(
aether_data::repository::wallet::CreateWalletRefundRequestOutcome::RefundAmountExceedsAvailableBalance => {
build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"refund amount exceeds available refundable recharge balance",
false,
)
}
aether_data::repository::wallet::CreateWalletRefundRequestOutcome::PaymentOrderNotFound => {
build_auth_error_response(
http::StatusCode::NOT_FOUND,
"Payment order not found",
false,
);
};
let status = order_row
.try_get::<String, _>("status")
.ok()
.unwrap_or_default();
if status != "credited" {
let _ = tx.rollback().await;
return build_auth_error_response(
)
}
aether_data::repository::wallet::CreateWalletRefundRequestOutcome::PaymentOrderNotRefundable => {
build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"payment order is not refundable",
false,
);
)
}
let order_reserved_row = match sqlx::query(
r#"
SELECT COALESCE(CAST(SUM(amount_usd) AS DOUBLE PRECISION), 0) AS total
FROM refund_requests
WHERE payment_order_id = $1
AND status IN ('pending_approval', 'approved')
"#,
)
.bind(order_id)
.fetch_one(&mut *tx)
.await
{
Ok(value) => value,
Err(err) => {
let _ = tx.rollback().await;
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund payment order reserve lookup failed: {err}"),
false,
);
}
};
let refundable_amount = order_row
.try_get::<f64, _>("refundable_amount_usd")
.ok()
.unwrap_or_default();
let reserved_amount = order_reserved_row
.try_get::<f64, _>("total")
.ok()
.unwrap_or_default();
if payload.amount_usd > (refundable_amount - reserved_amount) {
let _ = tx.rollback().await;
return build_auth_error_response(
aether_data::repository::wallet::CreateWalletRefundRequestOutcome::RefundAmountExceedsAvailableOrderAmount => {
build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"refund amount exceeds available refundable amount",
false,
);
)
}
payment_order_id = Some(order_id.to_string());
source_type = "payment_order".to_string();
source_id = Some(order_id.to_string());
if payload.refund_mode.is_none() {
let payment_method = order_row
.try_get::<String, _>("payment_method")
.ok()
.unwrap_or_default();
refund_mode =
wallet_default_refund_mode_for_payment_method(&payment_method).to_string();
aether_data::repository::wallet::CreateWalletRefundRequestOutcome::DuplicateRejected => {
build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"退款申请重复,请勿重复提交",
false,
)
}
}
let now = Utc::now();
let refund_id = Uuid::new_v4().to_string();
let refund_no = wallet_build_refund_no(now);
let insert_result = sqlx::query(
r#"
INSERT INTO refund_requests (
id,
refund_no,
wallet_id,
user_id,
payment_order_id,
source_type,
source_id,
refund_mode,
amount_usd,
status,
reason,
requested_by,
idempotency_key,
created_at,
updated_at
)
VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
'pending_approval',
$10,
$11,
$12,
NOW(),
NOW()
)
RETURNING
id,
refund_no,
payment_order_id,
source_type,
source_id,
refund_mode,
CAST(amount_usd AS DOUBLE PRECISION) AS amount_usd,
status,
reason,
failure_reason,
gateway_refund_id,
payout_method,
payout_reference,
payout_proof,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs,
CAST(EXTRACT(EPOCH FROM processed_at) AS BIGINT) AS processed_at_unix_secs,
CAST(EXTRACT(EPOCH FROM completed_at) AS BIGINT) AS completed_at_unix_secs
"#,
)
.bind(&refund_id)
.bind(&refund_no)
.bind(&wallet.id)
.bind(&auth.user.id)
.bind(payment_order_id.as_deref())
.bind(&source_type)
.bind(source_id.as_deref())
.bind(&refund_mode)
.bind(payload.amount_usd)
.bind(payload.reason.as_deref())
.bind(&auth.user.id)
.bind(payload.idempotency_key.as_deref())
.fetch_one(&mut *tx)
.await;
let row = match insert_result {
Ok(value) => value,
Err(err) => {
let _ = tx.rollback().await;
if let Some(idempotency_key) = payload.idempotency_key.as_deref() {
let existing = match sqlx::query(
r#"
SELECT
id,
refund_no,
payment_order_id,
source_type,
source_id,
refund_mode,
CAST(amount_usd AS DOUBLE PRECISION) AS amount_usd,
status,
reason,
failure_reason,
gateway_refund_id,
payout_method,
payout_reference,
payout_proof,
CAST(EXTRACT(EPOCH FROM created_at) AS BIGINT) AS created_at_unix_secs,
CAST(EXTRACT(EPOCH FROM updated_at) AS BIGINT) AS updated_at_unix_secs,
CAST(EXTRACT(EPOCH FROM processed_at) AS BIGINT) AS processed_at_unix_secs,
CAST(EXTRACT(EPOCH FROM completed_at) AS BIGINT) AS completed_at_unix_secs
FROM refund_requests
WHERE user_id = $1
AND idempotency_key = $2
LIMIT 1
"#,
)
.bind(&auth.user.id)
.bind(idempotency_key)
.fetch_optional(&pool)
.await
{
Ok(value) => value,
Err(read_err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund idempotency lookup failed: {read_err}"),
false,
);
}
};
if let Some(existing) = existing {
let payload = match wallet_refund_payload_from_row(&existing) {
Ok(value) => value,
Err(payload_err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund payload failed: {payload_err:?}"),
false,
);
}
};
return build_auth_json_response(http::StatusCode::OK, payload, None);
}
}
if err
.as_database_error()
.and_then(|value| value.code())
.as_deref()
== Some("23505")
{
return build_auth_error_response(
http::StatusCode::BAD_REQUEST,
"退款申请重复,请勿重复提交",
false,
);
}
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund create failed: {err}"),
false,
);
}
};
if let Err(err) = tx.commit().await {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund commit failed: {err}"),
false,
);
}
let payload = match wallet_refund_payload_from_row(&row) {
Ok(value) => value,
Err(err) => {
return build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("wallet refund payload failed: {err:?}"),
false,
);
}
};
build_auth_json_response(http::StatusCode::OK, payload, None)
}

View File

@@ -1,10 +1,18 @@
use super::enabled_key_capability_short_names;
use crate::gateway::handlers::{json_string_list, masked_catalog_api_key, unix_secs_to_rfc3339};
use crate::gateway::AppState;
use crate::handlers::{json_string_list, unix_secs_to_rfc3339};
use crate::AppState;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashMap};
use std::time::{SystemTime, UNIX_EPOCH};
fn grouped_key_masked_label(auth_type: &str) -> &'static str {
match auth_type.trim() {
"service_account" | "vertex_ai" => "[Service Account]",
"oauth" => "[OAuth Token]",
_ => "[API Key]",
}
}
pub(crate) async fn build_admin_keys_grouped_by_format_payload(
state: &AppState,
) -> Option<serde_json::Value> {
@@ -21,14 +29,22 @@ pub(crate) async fn build_admin_keys_grouped_by_format_payload(
.iter()
.map(|provider| provider.id.clone())
.collect::<Vec<_>>();
let provider_by_id = providers
let provider_metadata_by_id = providers
.iter()
.map(|provider| (provider.id.clone(), provider.clone()))
.collect::<BTreeMap<_, _>>();
.map(|provider| {
(
provider.id.clone(),
(provider.name.clone(), provider.is_active),
)
})
.collect::<HashMap<_, _>>();
let endpoint_base_url_by_provider_and_format = state
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
.await
let (endpoints_result, keys_result) = tokio::join!(
state.list_provider_catalog_endpoints_by_provider_ids(&provider_ids),
state.list_provider_catalog_keys_by_provider_ids(&provider_ids),
);
let endpoint_base_url_by_provider_and_format = endpoints_result
.ok()
.unwrap_or_default()
.into_iter()
@@ -39,13 +55,9 @@ pub(crate) async fn build_admin_keys_grouped_by_format_payload(
endpoint.base_url,
)
})
.collect::<BTreeMap<_, _>>();
.collect::<HashMap<_, _>>();
let mut keys = state
.list_provider_catalog_keys_by_provider_ids(&provider_ids)
.await
.ok()
.unwrap_or_default();
let mut keys = keys_result.ok().unwrap_or_default();
keys.sort_by(|left, right| {
left.internal_priority
.cmp(&right.internal_priority)
@@ -60,7 +72,9 @@ pub(crate) async fn build_admin_keys_grouped_by_format_payload(
let mut grouped = BTreeMap::<String, Vec<serde_json::Value>>::new();
for key in keys {
let Some(provider) = provider_by_id.get(&key.provider_id) else {
let Some((provider_name, provider_is_active)) =
provider_metadata_by_id.get(&key.provider_id)
else {
continue;
};
let request_count = u64::from(key.request_count.unwrap_or(0));
@@ -113,13 +127,13 @@ pub(crate) async fn build_admin_keys_grouped_by_format_payload(
"provider_id": key.provider_id,
"name": key.name,
"auth_type": key.auth_type,
"api_key_masked": masked_catalog_api_key(state, &key),
"api_key_masked": grouped_key_masked_label(&key.auth_type),
"internal_priority": key.internal_priority,
"global_priority_by_format": key.global_priority_by_format,
"rate_multipliers": key.rate_multipliers,
"is_active": key.is_active,
"provider_active": provider.is_active,
"provider_name": provider.name,
"provider_active": provider_is_active,
"provider_name": provider_name,
"api_formats": api_formats,
"capabilities": capability_names,
"success_rate": success_rate,
@@ -127,7 +141,7 @@ pub(crate) async fn build_admin_keys_grouped_by_format_payload(
"request_count": request_count,
"api_format": api_format,
"endpoint_base_url": endpoint_base_url_by_provider_and_format
.get(&(provider.id.clone(), api_format.clone()))
.get(&(key.provider_id.clone(), api_format.clone()))
.cloned(),
"format_priority": priority_by_format
.get(api_format)

View File

@@ -1,5 +1,5 @@
use super::{module_available_from_env, system_config_bool};
use crate::gateway::{AppState, GatewayError};
use crate::{AppState, GatewayError};
use serde_json::json;
#[derive(Clone, Copy)]

View File

@@ -1,6 +1,6 @@
use crate::gateway::api::ai::admin_endpoint_signature_parts;
use crate::gateway::handlers::{decrypt_catalog_secret_with_fallbacks, unix_secs_to_rfc3339};
use crate::gateway::{AppState, GatewayError};
use crate::api::ai::admin_endpoint_signature_parts;
use crate::handlers::{decrypt_catalog_secret_with_fallbacks, unix_secs_to_rfc3339};
use crate::{AppState, GatewayError};
use aether_crypto::encrypt_python_fernet_plaintext;
use aether_data::repository::global_models::{
AdminGlobalModelListQuery, AdminProviderModelListQuery,
@@ -172,20 +172,19 @@ pub(crate) async fn build_admin_system_stats_payload(
.iter()
.filter(|provider| provider.is_active)
.count() as u64;
let (total_users, active_users, total_api_keys, total_requests) =
state.read_admin_system_stats().await?;
let stats = state.read_admin_system_stats().await?;
Ok(json!({
"users": {
"total": total_users,
"active": active_users,
"total": stats.total_users,
"active": stats.active_users,
},
"providers": {
"total": total_providers,
"active": active_providers,
},
"api_keys": total_api_keys,
"requests": total_requests,
"api_keys": stats.total_api_keys,
"requests": stats.total_requests,
}))
}
@@ -1504,7 +1503,7 @@ pub(crate) fn build_admin_system_config_list_item(
}
pub(crate) fn build_admin_system_configs_payload(
entries: &[crate::gateway::gateway_data::StoredSystemConfigEntry],
entries: &[aether_data::repository::system::StoredSystemConfigEntry],
) -> serde_json::Value {
let has_request_record_level = entries
.iter()