//! HTTP API Module //! Provides local HTTP interfaces for external programs (e.g., VS Code extension) to call. use axum::{ extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; // 预留 HTTP API 模块,当前未在主流程中启用 use std::sync::Arc; use tokio::sync::RwLock; use tower_http::cors::{Any, CorsLayer}; use crate::modules::{account, logger, proxy_db}; /// Default port for HTTP API server pub const DEFAULT_PORT: u16 = 19527; // ============================================================================ // Settings // ============================================================================ /// HTTP API Settings #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HttpApiSettings { /// Whether to enable HTTP API service #[serde(default = "default_enabled")] pub enabled: bool, /// Listening port #[serde(default = "default_port")] pub port: u16, } fn default_enabled() -> bool { true } fn default_port() -> u16 { DEFAULT_PORT } impl Default for HttpApiSettings { fn default() -> Self { Self { enabled: true, port: DEFAULT_PORT, } } } /// Load HTTP API settings pub fn load_settings() -> Result { let data_dir = crate::modules::account::get_data_dir() .map_err(|e| format!("Failed to get data dir: {}", e))?; let settings_path = data_dir.join("http_api_settings.json"); if !settings_path.exists() { return Ok(HttpApiSettings::default()); } let content = std::fs::read_to_string(&settings_path) .map_err(|e| format!("Failed to read settings file: {}", e))?; serde_json::from_str(&content) .map_err(|e| format!("Failed to parse settings: {}", e)) } /// Save HTTP API settings pub fn save_settings(settings: &HttpApiSettings) -> Result<(), String> { let data_dir = crate::modules::account::get_data_dir() .map_err(|e| format!("Failed to get data dir: {}", e))?; let settings_path = data_dir.join("http_api_settings.json"); let content = serde_json::to_string_pretty(settings) .map_err(|e| format!("Failed to serialize settings: {}", e))?; std::fs::write(&settings_path, content) .map_err(|e| format!("Failed to write settings file: {}", e)) } /// Server State #[derive(Clone)] pub struct ApiState { /// Whether there is a switch operation currently in progress pub switching: Arc>, pub integration: crate::modules::integration::SystemManager, } impl ApiState { pub fn new(integration: crate::modules::integration::SystemManager) -> Self { Self { switching: Arc::new(RwLock::new(false)), integration, } } } // ============================================================================ // Response Types // ============================================================================ #[derive(Serialize)] struct HealthResponse { status: String, version: String, } #[derive(Serialize)] struct AccountResponse { id: String, email: String, name: Option, is_current: bool, disabled: bool, quota: Option, device_bound: bool, last_used: i64, } #[derive(Serialize)] struct QuotaResponse { models: Vec, updated_at: Option, subscription_tier: Option, } #[derive(Serialize)] struct ModelQuota { name: String, percentage: i32, reset_time: String, } #[derive(Serialize)] struct AccountListResponse { accounts: Vec, current_account_id: Option, } #[derive(Serialize)] struct CurrentAccountResponse { account: Option, } #[derive(Serialize)] struct SwitchResponse { success: bool, message: String, } #[derive(Serialize)] struct RefreshResponse { success: bool, message: String, refreshed_count: usize, } #[derive(Serialize)] struct BindDeviceResponse { success: bool, message: String, device_profile: Option, } #[derive(Serialize)] struct DeviceProfileResponse { machine_id: String, mac_machine_id: String, dev_device_id: String, sqm_id: String, } #[derive(Serialize)] struct ErrorResponse { error: String, } #[derive(Serialize)] struct LogsResponse { total: u64, logs: Vec, } // ============================================================================ // Request Types // ============================================================================ #[derive(Deserialize)] struct SwitchRequest { account_id: String, } #[derive(Deserialize)] struct BindDeviceRequest { #[serde(default = "default_bind_mode")] mode: String, } fn default_bind_mode() -> String { "generate".to_string() } #[derive(Deserialize)] struct LogsRequest { #[serde(default)] limit: usize, #[serde(default)] offset: usize, #[serde(default)] filter: String, #[serde(default)] errors_only: bool, } // ============================================================================ // Handlers // ============================================================================ /// GET /health - Health check async fn health() -> impl IntoResponse { Json(HealthResponse { status: "ok".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), }) } /// GET /accounts - Get all accounts async fn list_accounts() -> Result)> { let accounts = account::list_accounts().map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e }), ) })?; let current_id = account::get_current_account_id() .ok() .flatten(); let account_responses: Vec = accounts .into_iter() .map(|acc| { let is_current = current_id.as_ref().map(|id| id == &acc.id).unwrap_or(false); let quota = acc.quota.map(|q| QuotaResponse { models: q.models.into_iter().map(|m| ModelQuota { name: m.name, percentage: m.percentage, reset_time: m.reset_time, }).collect(), updated_at: Some(q.last_updated), subscription_tier: q.subscription_tier, }); AccountResponse { id: acc.id, email: acc.email, name: acc.name, is_current, disabled: acc.disabled, quota, device_bound: acc.device_profile.is_some(), last_used: acc.last_used, } }) .collect(); Ok(Json(AccountListResponse { current_account_id: current_id, accounts: account_responses, })) } /// GET /accounts/current - Get current account async fn get_current_account() -> Result)> { let current = account::get_current_account().map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e }), ) })?; let response = current.map(|acc| { let quota = acc.quota.map(|q| QuotaResponse { models: q.models.into_iter().map(|m| ModelQuota { name: m.name, percentage: m.percentage, reset_time: m.reset_time, }).collect(), updated_at: Some(q.last_updated), subscription_tier: q.subscription_tier, }); AccountResponse { id: acc.id, email: acc.email, name: acc.name, is_current: true, disabled: acc.disabled, quota, device_bound: acc.device_profile.is_some(), last_used: acc.last_used, } }); Ok(Json(CurrentAccountResponse { account: response })) } /// POST /accounts/switch - Switch account async fn switch_account( State(state): State, Json(payload): Json, ) -> Result)> { // Check if another switch operation is already in progress { let switching = state.switching.read().await; if *switching { return Err(( StatusCode::CONFLICT, Json(ErrorResponse { error: "Another switch operation is already in progress".to_string(), }), )); } } // Mark switch started { let mut switching = state.switching.write().await; *switching = true; } let account_id = payload.account_id.clone(); let state_clone = state.clone(); // Execute switch asynchronously (non-blocking response) tokio::spawn(async move { logger::log_info(&format!("[HTTP API] Starting account switch: {}", account_id)); match account::switch_account(&account_id, &state_clone.integration).await { Ok(()) => { logger::log_info(&format!("[HTTP API] Account switch successful: {}", account_id)); } Err(e) => { logger::log_error(&format!("[HTTP API] Account switch failed: {}", e)); } } // Mark switch ended let mut switching = state_clone.switching.write().await; *switching = false; }); // Immediately return 202 Accepted Ok(( StatusCode::ACCEPTED, Json(SwitchResponse { success: true, message: format!("Account switch task started: {}", payload.account_id), }), )) } /// POST /accounts/refresh - Refresh all quotas async fn refresh_all_quotas() -> Result)> { logger::log_info("[HTTP API] Starting refresh of all account quotas"); // Execute refresh asynchronously tokio::spawn(async { match account::refresh_all_quotas_logic().await { Ok(stats) => { logger::log_info(&format!( "[HTTP API] Quota refresh completed, successful {}/{} accounts", stats.success, stats.total )); } Err(e) => { logger::log_error(&format!("[HTTP API] Quota refresh failed: {}", e)); } } }); Ok(( StatusCode::ACCEPTED, Json(RefreshResponse { success: true, message: "Quota refresh task started".to_string(), refreshed_count: 0, }), )) } /// POST /accounts/:id/bind-device - Bind device fingerprint async fn bind_device( Path(account_id): Path, Json(payload): Json, ) -> Result)> { logger::log_info(&format!( "[HTTP API] Binding device fingerprint: account={}, mode={}", account_id, payload.mode )); let result = account::bind_device_profile(&account_id, &payload.mode).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e }), ) })?; Ok(Json(BindDeviceResponse { success: true, message: "Device fingerprint bound successfully".to_string(), device_profile: Some(DeviceProfileResponse { machine_id: result.machine_id, mac_machine_id: result.mac_machine_id, dev_device_id: result.dev_device_id, sqm_id: result.sqm_id, }), })) } /// GET /logs - Get proxy logs async fn get_logs( Query(params): Query, ) -> Result)> { let limit = if params.limit == 0 { 50 } else { params.limit }; let total = proxy_db::get_logs_count_filtered(¶ms.filter, params.errors_only) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?; let logs = proxy_db::get_logs_filtered(¶ms.filter, params.errors_only, limit, params.offset) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(ErrorResponse { error: e })))?; Ok(Json(LogsResponse { total, logs, })) } // ============================================================================ // Server // ============================================================================ /// Start HTTP API server pub async fn start_server(port: u16, integration: crate::modules::integration::SystemManager) -> Result<(), String> { let state = ApiState::new(integration); // CORS config - allow local calls let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); let app = Router::new() .route("/health", get(health)) .route("/accounts", get(list_accounts)) .route("/accounts/current", get(get_current_account)) .route("/accounts/switch", post(switch_account)) .route("/accounts/refresh", post(refresh_all_quotas)) .route("/accounts/{id}/bind-device", post(bind_device)) .route("/logs", get(get_logs)) .layer(cors) .with_state(state); let addr = format!("127.0.0.1:{}", port); logger::log_info(&format!("[HTTP API] Starting server: http://{}", addr)); let listener = tokio::net::TcpListener::bind(&addr) .await .map_err(|e| format!("failed_to_bind_port: {}", e))?; axum::serve(listener, app) .await .map_err(|e| format!("failed_to_run_server: {}", e))?; Ok(()) } /// Start HTTP API server in background (non-blocking) pub fn spawn_server(port: u16, integration: crate::modules::integration::SystemManager) { // Use tauri::async_runtime::spawn to ensure running within Tauri's runtime tauri::async_runtime::spawn(async move { if let Err(e) = start_server(port, integration).await { logger::log_error(&format!("[HTTP API] Failed to start server: {}", e)); } }); }