diff --git a/src/accounts/repository.rs b/src/accounts/repository.rs index 5d2ed5045..0269a62d7 100644 --- a/src/accounts/repository.rs +++ b/src/accounts/repository.rs @@ -215,6 +215,19 @@ impl AccountRepository { Ok(Page { items, next_cursor }) } + pub async fn list_all(&self) -> AccountRepositoryResult> { + let rows = sqlx::query( + "select id, email, account_id, user_id, label, plan_type, access_token_cipher, refresh_token_cipher, access_token_expires_at, status, added_at, updated_at from accounts order by added_at desc, id desc", + ) + .fetch_all(&self.pool) + .await?; + let mut accounts = Vec::with_capacity(rows.len()); + for row in rows { + accounts.push(self.account_from_row(&row)?); + } + Ok(accounts) + } + pub async fn list_metadata( &self, cursor: Option, diff --git a/src/app.rs b/src/app.rs index 07f71a41d..cbad18f63 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,9 +10,10 @@ use crate::{ admin::{ accounts, api_keys, batch_delete_accounts, batch_delete_api_keys, batch_update_account_status, create_api_key, delete_account, delete_account_cookies, - delete_api_key, get_account_cookies, import_accounts, login, logs, refresh_models, - set_account_cookies, settings, update_account_label, update_account_status, - update_api_key_label, update_api_key_status, usage_stats, usage_stats_summary, + delete_api_key, export_accounts, get_account_cookies, import_accounts, login, logs, + refresh_models, set_account_cookies, settings, update_account_label, + update_account_status, update_api_key_label, update_api_key_status, usage_stats, + usage_stats_summary, }, health::health, middleware::attach_request_id, @@ -46,6 +47,7 @@ pub fn build_router(state: AppState) -> Router { "/admin/accounts/batch-status", post(batch_update_account_status), ) + .route("/admin/accounts/export", get(export_accounts)) .route( "/admin/accounts/{account_id}/cookies", get(get_account_cookies) diff --git a/src/http/admin.rs b/src/http/admin.rs index 2ae15d459..51fbc951b 100644 --- a/src/http/admin.rs +++ b/src/http/admin.rs @@ -7,10 +7,10 @@ use axum::{ response::{IntoResponse, Response}, Extension, Json, }; -use chrono::{Duration, Utc}; +use chrono::{Duration, SecondsFormat, Utc}; use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Map, Value}; use sqlx::Row; use uuid::Uuid; @@ -138,6 +138,13 @@ pub struct AccountsQuery { pub limit: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountExportQuery { + pub ids: Option, + pub format: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApiKeysQuery { @@ -229,6 +236,12 @@ pub struct AccountImportData { pub source_format: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AccountExportFormat { + Native, + Sub2Api, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UpdateAccountLabelRequest { @@ -992,6 +1005,84 @@ pub async fn accounts( } } +pub async fn export_accounts( + State(state): State, + Extension(request_id): Extension, + headers: HeaderMap, + Query(query): Query, +) -> Response { + let request_id = request_id.as_str().to_string(); + let format = match parse_account_export_format(query.format.as_deref()) { + Ok(format) => format, + Err(message) => { + return AdminResponse::new( + StatusCode::BAD_REQUEST, + AdminEnvelope::new(40001, message, (), request_id), + ) + .into_response(); + } + }; + let Some(pool) = state.db() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Database is not initialized", (), request_id), + ) + .into_response(); + }; + if let Err(response) = require_admin_session(pool, &headers, &request_id).await { + return response; + } + let Some(repo) = state.account_repository() else { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new( + 50001, + "Account repository is not initialized", + (), + request_id, + ), + ) + .into_response(); + }; + + let ids = account_export_ids(query.ids.as_deref()); + let accounts = if ids.is_empty() { + match repo.list_all().await { + Ok(accounts) => accounts, + Err(_) => { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to export accounts", (), request_id), + ) + .into_response(); + } + } + } else { + let mut accounts = Vec::with_capacity(ids.len()); + for id in ids { + match repo.get(&id).await { + Ok(Some(account)) => accounts.push(account), + Ok(None) => {} + Err(_) => { + return AdminResponse::new( + StatusCode::INTERNAL_SERVER_ERROR, + AdminEnvelope::new(50001, "Failed to export accounts", (), request_id), + ) + .into_response(); + } + } + } + accounts + }; + + // 账号导出会返回可重新导入的 OAuth token;只允许 admin session 访问,不写入日志。 + let data = match format { + AccountExportFormat::Native => native_account_export(accounts), + AccountExportFormat::Sub2Api => sub2api_account_export(accounts), + }; + AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data, request_id)).into_response() +} + pub async fn update_account_label( State(state): State, Extension(request_id): Extension, @@ -2226,6 +2317,110 @@ fn normalized_account_id(id: Option) -> String { .unwrap_or_else(|| format!("acct_{}", Uuid::new_v4().simple())) } +fn account_export_ids(value: Option<&str>) -> Vec { + value + .into_iter() + .flat_map(|ids| ids.split(',')) + .filter_map(|id| { + let id = id.trim(); + (!id.is_empty()).then(|| id.to_string()) + }) + .collect() +} + +fn parse_account_export_format(value: Option<&str>) -> Result { + match value.unwrap_or("native") { + "" | "native" | "full" => Ok(AccountExportFormat::Native), + "sub2api" => Ok(AccountExportFormat::Sub2Api), + _ => Err("Unsupported account export format"), + } +} + +fn native_account_export(accounts: Vec) -> Value { + json!({ + "sourceFormat": "native", + "accounts": accounts.into_iter().map(native_export_account).collect::>(), + }) +} + +fn native_export_account(account: StoredAccount) -> Value { + json!({ + "id": account.id, + "email": account.email, + "accountId": account.account_id, + "userId": account.user_id, + "label": account.label, + "planType": account.plan_type, + "token": account.access_token.expose_secret(), + "refreshToken": account + .refresh_token + .as_ref() + .map(|token| token.expose_secret().to_string()), + "status": account_status_value(account.status), + "accessTokenExpiresAt": account.access_token_expires_at.map(|value| value.to_rfc3339()), + "addedAt": account.added_at.to_rfc3339(), + "updatedAt": account.updated_at.to_rfc3339(), + }) +} + +fn sub2api_account_export(accounts: Vec) -> Value { + json!({ + "exported_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + "proxies": [], + "accounts": accounts.into_iter().map(sub2api_export_account).collect::>(), + "type": "sub2api-data", + "version": 1, + }) +} + +fn sub2api_export_account(account: StoredAccount) -> Value { + let mut credentials = Map::new(); + credentials.insert( + "access_token".to_string(), + Value::String(account.access_token.expose_secret().to_string()), + ); + insert_optional_string( + &mut credentials, + "refresh_token", + account + .refresh_token + .as_ref() + .map(|token| token.expose_secret().to_string()), + ); + insert_optional_string(&mut credentials, "email", account.email.clone()); + insert_optional_string(&mut credentials, "chatgpt_account_id", account.account_id); + insert_optional_string(&mut credentials, "chatgpt_user_id", account.user_id); + insert_optional_string(&mut credentials, "plan_type", account.plan_type); + insert_optional_string( + &mut credentials, + "expires_at", + account + .access_token_expires_at + .map(|value| value.to_rfc3339()), + ); + + json!({ + "name": account + .label + .as_deref() + .filter(|label| !label.trim().is_empty()) + .map(str::to_string) + .or_else(|| account.email.clone()) + .unwrap_or_else(|| account.id.clone()), + "platform": "openai", + "type": "oauth", + "credentials": credentials, + "concurrency": 0, + "priority": 0, + }) +} + +fn insert_optional_string(map: &mut Map, key: &str, value: Option) { + if let Some(value) = value.filter(|value| !value.is_empty()) { + map.insert(key.to_string(), Value::String(value)); + } +} + fn distinct_active_plan_accounts(accounts: Vec) -> Vec<(String, Account)> { let mut by_plan = std::collections::BTreeMap::new(); for account in accounts { diff --git a/tests/admin_accounts_route_test.rs b/tests/admin_accounts_route_test.rs index c2673a6e0..def53976c 100644 --- a/tests/admin_accounts_route_test.rs +++ b/tests/admin_accounts_route_test.rs @@ -824,6 +824,112 @@ async fn admin_account_cookies_should_require_existing_account_and_non_empty_coo assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); } +#[tokio::test] +async fn admin_accounts_export_should_return_native_accounts_with_tokens_and_filter_ids() { + let (app, _state, _pool, _dir) = + admin_accounts_test_app("admin-account-export-native.sqlite", 24).await; + import_test_account(&app, "session_1", "acct_export_a").await; + import_test_account(&app, "session_1", "acct_export_b").await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/admin/accounts/export?ids=acct_export_a") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["sourceFormat"], "native"); + assert_eq!(body["data"]["accounts"].as_array().unwrap().len(), 1); + assert_eq!(body["data"]["accounts"][0]["id"], "acct_export_a"); + assert_eq!(body["data"]["accounts"][0]["token"], "access-acct_export_a"); + assert_eq!( + body["data"]["accounts"][0]["refreshToken"], + "refresh-acct_export_a" + ); + assert!(body["data"]["accounts"][0].get("proxyApiKey").is_none()); + + let invalid = app + .oneshot( + Request::builder() + .method("GET") + .uri("/admin/accounts/export?format=proxy") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn admin_accounts_export_should_return_sub2api_openai_oauth_payload_without_proxy_fields() { + let (app, _state, _pool, _dir) = + admin_accounts_test_app("admin-account-export-sub2api.sqlite", 25).await; + import_test_account(&app, "session_1", "acct_export_sub2api").await; + + let label_response = app + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/admin/accounts/acct_export_sub2api/label") + .header("content-type", "application/json") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::from(r#"{"label":"Sub2API Export"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(label_response.status(), StatusCode::OK); + + let response = app + .oneshot( + Request::builder() + .method("GET") + .uri("/admin/accounts/export?format=sub2api") + .header("cookie", "cpr_admin_session=session_1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_json(response).await; + assert_eq!(body["data"]["type"], "sub2api-data"); + assert_eq!(body["data"]["version"], 1); + assert_eq!(body["data"]["proxies"], json!([])); + assert_eq!(body["data"]["accounts"].as_array().unwrap().len(), 1); + assert_eq!(body["data"]["accounts"][0]["name"], "Sub2API Export"); + assert_eq!(body["data"]["accounts"][0]["platform"], "openai"); + assert_eq!(body["data"]["accounts"][0]["type"], "oauth"); + assert_eq!( + body["data"]["accounts"][0]["credentials"]["access_token"], + "access-acct_export_sub2api" + ); + assert_eq!( + body["data"]["accounts"][0]["credentials"]["refresh_token"], + "refresh-acct_export_sub2api" + ); + assert_eq!( + body["data"]["accounts"][0]["credentials"]["email"], + "acct_export_sub2api@example.com" + ); + assert_eq!( + body["data"]["accounts"][0]["credentials"]["plan_type"], + "plus" + ); + assert!(body["data"]["accounts"][0].get("proxy").is_none()); + assert!(body["data"]["accounts"][0].get("proxyUrl").is_none()); +} + async fn admin_accounts_test_app( db_name: &str, key_byte: u8,