feat(ccswitch): 添加一键导入和用量查询

This commit is contained in:
Entropy.Xu
2026-05-29 21:39:45 +08:00
parent 0d76a8e478
commit 7ed4b84654
23 changed files with 1720 additions and 48 deletions
@@ -29,6 +29,8 @@ mod support_announcements;
mod support_auth;
#[path = "support/billing.rs"]
mod support_billing;
#[path = "support/ccswitch.rs"]
mod support_ccswitch;
#[path = "support/dashboard.rs"]
mod support_dashboard;
#[path = "support/install.rs"]
@@ -66,10 +68,11 @@ use self::support_auth::{
build_auth_settings_payload, extract_client_device_id, maybe_build_local_auth_response,
};
use self::support_billing::maybe_build_local_billing_response;
use self::support_ccswitch::maybe_build_local_ccswitch_response;
use self::support_dashboard::maybe_build_local_dashboard_response;
pub(crate) use self::support_install::{
build_api_key_install_session_response, build_proxy_node_install_session_response,
CreateApiKeyInstallSessionRequest,
base_url_from_request, build_api_key_install_session_response,
build_proxy_node_install_session_response, CreateApiKeyInstallSessionRequest,
};
use self::support_install::{
handle_users_me_api_key_install_session_create, maybe_build_local_install_response,
@@ -84,7 +87,10 @@ use self::support_payment::maybe_build_local_payment_callback_response;
use self::support_test_connection::maybe_build_local_test_connection_response;
use self::support_user_me::maybe_build_local_users_me_response;
use self::support_wallet::{
direct_gateway_channels, maybe_build_local_wallet_response, sanitize_wallet_gateway_response,
build_wallet_balance_payload_for_auth_scope, build_wallet_balance_payload_for_user,
build_wallet_live_today_usage_payload_for_api_key,
build_wallet_live_today_usage_payload_for_user, direct_gateway_channels,
maybe_build_local_wallet_response, sanitize_wallet_gateway_response,
wallet_normalize_optional_string_field,
};
@@ -171,6 +177,13 @@ pub(crate) async fn maybe_build_local_public_support_response(
return Some(build_unhandled_public_support_response(request_context));
}
if decision.route_family.as_deref() == Some("ccswitch") {
if let Some(response) = maybe_build_local_ccswitch_response(state, request_context).await {
return Some(response);
}
return Some(build_unhandled_public_support_response(request_context));
}
if decision.route_family.as_deref() == Some("users_me") {
return maybe_build_local_users_me_response(state, request_context, headers, request_body)
.await;
@@ -0,0 +1,176 @@
use axum::{
body::Body,
http,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use crate::control::GatewayLocalAuthRejection;
use crate::handlers::shared::round_to;
use super::{
build_auth_error_response, build_wallet_balance_payload_for_auth_scope,
build_wallet_live_today_usage_payload_for_api_key,
build_wallet_live_today_usage_payload_for_user, AppState, GatewayPublicRequestContext,
};
fn ccswitch_usage_auth_error_response(
rejection: Option<&GatewayLocalAuthRejection>,
) -> Response<Body> {
match rejection {
Some(GatewayLocalAuthRejection::InvalidApiKey) | None => {
build_auth_error_response(http::StatusCode::UNAUTHORIZED, "无效的 API Key", false)
}
Some(GatewayLocalAuthRejection::LockedApiKey) => {
build_auth_error_response(http::StatusCode::FORBIDDEN, "API Key 已被锁定", false)
}
Some(GatewayLocalAuthRejection::ProviderNotAllowed { .. })
| Some(GatewayLocalAuthRejection::ApiFormatNotAllowed { .. })
| Some(GatewayLocalAuthRejection::ModelNotAllowed { .. })
| Some(GatewayLocalAuthRejection::IpNotAllowed { .. }) => {
build_auth_error_response(http::StatusCode::FORBIDDEN, "API Key 无权查询用量", false)
}
Some(GatewayLocalAuthRejection::WalletUnavailable) => build_auth_error_response(
http::StatusCode::SERVICE_UNAVAILABLE,
"钱包数据暂不可用",
false,
),
Some(GatewayLocalAuthRejection::BalanceDenied { .. }) => {
build_auth_error_response(http::StatusCode::FORBIDDEN, "API Key 余额不足", false)
}
}
}
fn json_f64(value: &serde_json::Value, key: &str) -> Option<f64> {
value.get(key).and_then(serde_json::Value::as_f64)
}
fn json_bool(value: &serde_json::Value, key: &str) -> bool {
value
.get(key)
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
fn format_usd(value: f64) -> String {
format!("${:.4}", round_to(value, 4))
}
fn build_ccswitch_usage_extra(
wallet_payload: &serde_json::Value,
today_payload: Option<&serde_json::Value>,
) -> String {
let mut parts = Vec::new();
if let Some(today_cost) = today_payload.and_then(|payload| json_f64(payload, "total_cost")) {
parts.push(format!("今日消耗 {}", format_usd(today_cost)));
}
if let Some(wallet_balance) = json_f64(wallet_payload, "wallet_balance") {
parts.push(format!("钱包 {}", format_usd(wallet_balance)));
}
if let Some(package_balance) = json_f64(wallet_payload, "package_balance") {
if package_balance > 0.0 {
parts.push(format!("套餐 {}", format_usd(package_balance)));
}
}
parts.join(" · ")
}
pub(super) async fn maybe_build_local_ccswitch_response(
state: &AppState,
request_context: &GatewayPublicRequestContext,
) -> Option<Response<Body>> {
let decision = request_context.control_decision.as_ref()?;
if decision.route_family.as_deref() != Some("ccswitch") {
return None;
}
if decision.route_kind.as_deref() != Some("usage")
|| request_context.request_path.trim_end_matches('/') != "/api/ccswitch/usage"
{
return None;
}
let auth_context = match decision.auth_context.as_ref() {
Some(auth_context) if !auth_context.user_id.trim().is_empty() => auth_context,
_ => {
return Some(ccswitch_usage_auth_error_response(
decision.local_auth_rejection.as_ref(),
))
}
};
match auth_context.local_rejection.as_ref() {
None | Some(GatewayLocalAuthRejection::BalanceDenied { .. }) => {}
rejection => return Some(ccswitch_usage_auth_error_response(rejection)),
}
let wallet = match state
.read_wallet_snapshot_for_auth(
&auth_context.user_id,
&auth_context.api_key_id,
auth_context.api_key_is_standalone,
)
.await
{
Ok(value) => value,
Err(err) => {
return Some(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
format!("ccswitch usage wallet lookup failed: {err:?}"),
false,
))
}
};
let wallet_payload = build_wallet_balance_payload_for_auth_scope(
state,
&auth_context.user_id,
auth_context.api_key_is_standalone,
wallet.as_ref(),
)
.await;
let today_payload = match if auth_context.api_key_is_standalone {
build_wallet_live_today_usage_payload_for_api_key(state, &auth_context.api_key_id).await
} else {
build_wallet_live_today_usage_payload_for_user(state, &auth_context.user_id).await
} {
Ok(value) => value,
Err(err) => {
return Some(build_auth_error_response(
http::StatusCode::INTERNAL_SERVER_ERROR,
err,
false,
))
}
};
let unlimited = json_bool(&wallet_payload, "unlimited");
let remaining = json_f64(&wallet_payload, "total_available_balance")
.or_else(|| json_f64(&wallet_payload, "wallet_balance"));
let used_today = today_payload
.as_ref()
.and_then(|payload| json_f64(payload, "total_cost"))
.unwrap_or(0.0);
let mut extra = build_ccswitch_usage_extra(&wallet_payload, today_payload.as_ref());
if extra.is_empty() && unlimited {
extra = "无限额度".to_string();
}
Some(
Json(json!({
"is_valid": true,
"plan_name": if unlimited { "Aether Unlimited" } else { "Aether" },
"remaining": remaining.map(|value| round_to(value.max(0.0), 6)),
"used": round_to(used_today.max(0.0), 6),
"unit": wallet_payload
.get("currency")
.and_then(serde_json::Value::as_str)
.unwrap_or("USD"),
"extra": extra,
"unlimited": unlimited,
"wallet": wallet_payload,
"today": today_payload,
}))
.into_response(),
)
}
@@ -1,6 +1,7 @@
use super::{
auth_password_policy_level, build_auth_error_response, build_auth_wallet_summary_payload,
decrypt_catalog_secret_with_fallbacks, encrypt_catalog_secret_with_fallbacks, handle_auth_me,
auth_password_policy_level, base_url_from_request, build_auth_error_response,
build_auth_wallet_summary_payload, decrypt_catalog_secret_with_fallbacks,
encrypt_catalog_secret_with_fallbacks, handle_auth_me,
handle_users_me_api_key_install_session_create, query_param_optional_bool, query_param_value,
resolve_authenticated_local_user, sanitize_public_model_config_for_user, unix_secs_to_rfc3339,
users_me_api_key_install_sessions_path_matches, validate_auth_register_password, AppState,
@@ -12,8 +12,9 @@ use crate::handlers::shared::{
};
use super::{
auth_password_policy_level, build_auth_error_response, resolve_authenticated_local_user,
validate_auth_register_password, AppState, GatewayPublicRequestContext,
auth_password_policy_level, base_url_from_request, build_auth_error_response,
resolve_authenticated_local_user, validate_auth_register_password, AppState,
GatewayPublicRequestContext,
};
const USERS_ME_PROFILE_STORAGE_UNAVAILABLE_DETAIL: &str = "用户资料存储暂不可用";
@@ -40,6 +41,30 @@ fn normalize_users_me_optional_non_empty_string(value: Option<String>) -> Option
value.filter(|value| !value.is_empty())
}
pub(super) async fn handle_users_me_client_config_get(
state: &AppState,
request_context: &GatewayPublicRequestContext,
headers: &http::HeaderMap,
) -> Response<Body> {
if let Err(response) = resolve_authenticated_local_user(state, request_context, headers).await {
return response;
}
let site_name = state
.read_system_config_json_value("site_name")
.await
.ok()
.flatten()
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| "Aether".to_string());
Json(json!({
"base_url": base_url_from_request(headers, request_context),
"site_name": site_name,
}))
.into_response()
}
pub(super) async fn handle_users_me_detail_put(
state: &AppState,
request_context: &GatewayPublicRequestContext,
@@ -7,20 +7,20 @@ use super::{
handle_users_me_api_key_install_session_create, handle_users_me_api_key_patch,
handle_users_me_api_key_providers_put, handle_users_me_api_key_update,
handle_users_me_api_keys_get, handle_users_me_available_models,
handle_users_me_delete_other_sessions, handle_users_me_delete_session,
handle_users_me_detail_put, handle_users_me_endpoint_status_get,
handle_users_me_management_token_create, handle_users_me_management_token_delete,
handle_users_me_management_token_detail_get, handle_users_me_management_token_regenerate,
handle_users_me_management_token_toggle, handle_users_me_management_token_update,
handle_users_me_management_tokens_list, handle_users_me_model_capabilities_get,
handle_users_me_model_capabilities_put, handle_users_me_password_patch,
handle_users_me_preferences_get, handle_users_me_preferences_put,
handle_users_me_providers_get, handle_users_me_referral_get, handle_users_me_sessions_get,
handle_users_me_update_session, handle_users_me_usage_active_get, handle_users_me_usage_get,
handle_users_me_usage_heatmap_get, handle_users_me_usage_interval_timeline_get,
users_me_api_key_capabilities_path_matches, users_me_api_key_detail_path_matches,
users_me_api_key_install_sessions_path_matches, users_me_api_key_providers_path_matches,
users_me_management_token_detail_path_matches,
handle_users_me_client_config_get, handle_users_me_delete_other_sessions,
handle_users_me_delete_session, handle_users_me_detail_put,
handle_users_me_endpoint_status_get, handle_users_me_management_token_create,
handle_users_me_management_token_delete, handle_users_me_management_token_detail_get,
handle_users_me_management_token_regenerate, handle_users_me_management_token_toggle,
handle_users_me_management_token_update, handle_users_me_management_tokens_list,
handle_users_me_model_capabilities_get, handle_users_me_model_capabilities_put,
handle_users_me_password_patch, handle_users_me_preferences_get,
handle_users_me_preferences_put, handle_users_me_providers_get, handle_users_me_referral_get,
handle_users_me_sessions_get, handle_users_me_update_session, handle_users_me_usage_active_get,
handle_users_me_usage_get, handle_users_me_usage_heatmap_get,
handle_users_me_usage_interval_timeline_get, users_me_api_key_capabilities_path_matches,
users_me_api_key_detail_path_matches, users_me_api_key_install_sessions_path_matches,
users_me_api_key_providers_path_matches, users_me_management_token_detail_path_matches,
users_me_management_token_regenerate_path_matches,
users_me_management_token_toggle_path_matches, users_me_management_tokens_root,
users_me_session_detail_path_matches, AppState, GatewayPublicRequestContext,
@@ -220,6 +220,9 @@ pub(crate) async fn maybe_build_local_users_me_response(
{
Some(handle_users_me_available_models(state, request_context, headers).await)
}
Some("client_config") if request_context.request_path == "/api/users/me/client-config" => {
Some(handle_users_me_client_config_get(state, request_context, headers).await)
}
Some("model_capabilities")
if request_context.request_path == "/api/users/me/model-capabilities" =>
{
@@ -28,12 +28,16 @@ mod redeem;
#[path = "wallet/refunds.rs"]
mod refunds;
use self::flow::handle_wallet_flow;
pub(in crate::handlers::public::support) use self::reads::build_wallet_balance_payload_for_user;
pub(in crate::handlers::public::support) use self::reads::{
build_wallet_balance_payload_for_auth_scope, build_wallet_balance_payload_for_user,
build_wallet_live_today_usage_payload_for_api_key,
build_wallet_live_today_usage_payload_for_user,
};
use self::reads::{
build_wallet_daily_usage_payload, build_wallet_live_today_usage_payload_for_user,
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_transaction_payload_from_record,
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_transaction_payload_from_record,
};
pub(crate) use self::recharge::{direct_gateway_channels, sanitize_wallet_gateway_response};
use self::recharge::{
@@ -53,16 +53,41 @@ pub(in crate::handlers::public::support) async fn build_wallet_balance_payload_f
state: &AppState,
user_id: &str,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
build_wallet_balance_payload_for_quota_user(state, Some(user_id), wallet).await
}
pub(in crate::handlers::public::support) async fn build_wallet_balance_payload_for_auth_scope(
state: &AppState,
user_id: &str,
api_key_is_standalone: bool,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
let quota_user_id = if api_key_is_standalone {
None
} else {
Some(user_id)
};
build_wallet_balance_payload_for_quota_user(state, quota_user_id, wallet).await
}
async fn build_wallet_balance_payload_for_quota_user(
state: &AppState,
quota_user_id: Option<&str>,
wallet: Option<&aether_data::repository::wallet::StoredWalletSnapshot>,
) -> serde_json::Value {
let mut payload = build_wallet_balance_payload(wallet);
let wallet_balance = wallet
.map(|value| value.balance + value.gift_balance)
.unwrap_or(0.0);
let daily_quota = state
.find_user_daily_quota_availability(user_id)
.await
.ok()
.flatten();
let daily_quota = match quota_user_id {
Some(user_id) => state
.find_user_daily_quota_availability(user_id)
.await
.ok()
.flatten(),
None => None,
};
let (has_active_daily_quota, total_quota_usd, used_usd, remaining_usd, allow_wallet_overage) =
daily_quota
.map(|quota| {
@@ -213,9 +238,10 @@ pub(super) fn build_wallet_zero_today_entry() -> serde_json::Value {
)
}
pub(super) async fn build_wallet_live_today_usage_payload_for_user(
async fn build_wallet_live_today_usage_payload_for_auth_scope(
state: &AppState,
user_id: &str,
user_id: Option<&str>,
api_key_id: Option<&str>,
) -> Result<Option<serde_json::Value>, String> {
if !state.has_usage_data_reader() {
return Ok(None);
@@ -225,7 +251,8 @@ pub(super) async fn build_wallet_live_today_usage_payload_for_user(
.summarize_usage_settled_cost(&UsageSettledCostSummaryQuery {
created_from_unix_secs: start_unix_secs,
created_until_unix_secs: end_unix_secs,
user_id: Some(user_id.to_string()),
user_id: user_id.map(ToOwned::to_owned),
api_key_id: api_key_id.map(ToOwned::to_owned),
})
.await
.map_err(|err| format!("wallet today cost lookup failed: {err:?}"))?;
@@ -250,6 +277,20 @@ pub(super) async fn build_wallet_live_today_usage_payload_for_user(
)))
}
pub(in crate::handlers::public::support) async fn build_wallet_live_today_usage_payload_for_user(
state: &AppState,
user_id: &str,
) -> Result<Option<serde_json::Value>, String> {
build_wallet_live_today_usage_payload_for_auth_scope(state, Some(user_id), None).await
}
pub(in crate::handlers::public::support) async fn build_wallet_live_today_usage_payload_for_api_key(
state: &AppState,
api_key_id: &str,
) -> Result<Option<serde_json::Value>, String> {
build_wallet_live_today_usage_payload_for_auth_scope(state, None, Some(api_key_id)).await
}
pub(super) fn wallet_transaction_payload_from_record(
record: &aether_data::repository::wallet::StoredAdminWalletTransaction,
) -> serde_json::Value {