feat: add admin account export

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-06-12 14:36:11 +08:00
co-authored by Codex
parent f01f4e5f67
commit a817e15087
4 changed files with 321 additions and 5 deletions
+13
View File
@@ -215,6 +215,19 @@ impl AccountRepository {
Ok(Page { items, next_cursor })
}
pub async fn list_all(&self) -> AccountRepositoryResult<Vec<StoredAccount>> {
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<String>,
+5 -3
View File
@@ -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)
+197 -2
View File
@@ -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<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountExportQuery {
pub ids: Option<String>,
pub format: Option<String>,
}
#[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<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
Query(query): Query<AccountExportQuery>,
) -> 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<AppState>,
Extension(request_id): Extension<RequestId>,
@@ -2226,6 +2317,110 @@ fn normalized_account_id(id: Option<String>) -> String {
.unwrap_or_else(|| format!("acct_{}", Uuid::new_v4().simple()))
}
fn account_export_ids(value: Option<&str>) -> Vec<String> {
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<AccountExportFormat, &'static str> {
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<StoredAccount>) -> Value {
json!({
"sourceFormat": "native",
"accounts": accounts.into_iter().map(native_export_account).collect::<Vec<_>>(),
})
}
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<StoredAccount>) -> Value {
json!({
"exported_at": Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
"proxies": [],
"accounts": accounts.into_iter().map(sub2api_export_account).collect::<Vec<_>>(),
"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<String, Value>, key: &str, value: Option<String>) {
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<Account>) -> Vec<(String, Account)> {
let mut by_plan = std::collections::BTreeMap::new();
for account in accounts {
+106
View File
@@ -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,