refactor: remove legacy compatibility

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-06-14 20:04:08 +08:00
co-authored by Codex
parent f557cb17ba
commit bc8650c9cf
16 changed files with 331 additions and 464 deletions
+6 -84
View File
@@ -4,10 +4,9 @@ use axum::{
response::IntoResponse,
Extension,
};
use chrono::{SecondsFormat, Utc};
use secrecy::ExposeSecret;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use serde_json::{json, Value};
use crate::{
codex::accounts::repository::StoredAccount, platform::http::request_id::RequestId,
@@ -24,12 +23,6 @@ pub struct AccountExportQuery {
pub format: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AccountExportFormat {
Native,
Sub2Api,
}
pub async fn export_accounts(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
@@ -37,17 +30,8 @@ pub async fn export_accounts(
Query(query): Query<AccountExportQuery>,
) -> Result<impl IntoResponse, AdminError> {
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 Err(AdminError::new(
StatusCode::BAD_REQUEST,
40001,
message,
request_id,
));
}
};
validate_account_export_format(query.format.as_deref())
.map_err(|message| AdminError::new(StatusCode::BAD_REQUEST, 40001, message, &request_id))?;
require_admin_session(&state, &headers, &request_id).await?;
let ids = account_export_ids(query.ids.as_deref());
@@ -59,20 +43,16 @@ pub async fn export_accounts(
.map_err(|error| account_service_error(error, &request_id))?;
// 账号导出会返回可重新导入的 OAuth token;只允许 admin session 访问,不写入日志。
let data = match format {
AccountExportFormat::Native => native_account_export(accounts),
AccountExportFormat::Sub2Api => sub2api_account_export(accounts),
};
let data = native_account_export(accounts);
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(data, request_id),
))
}
fn parse_account_export_format(value: Option<&str>) -> Result<AccountExportFormat, &'static str> {
fn validate_account_export_format(value: Option<&str>) -> Result<(), &'static str> {
match value.unwrap_or("native") {
"" | "native" | "full" => Ok(AccountExportFormat::Native),
"sub2api" => Ok(AccountExportFormat::Sub2Api),
"" | "native" => Ok(()),
_ => Err("Unsupported account export format"),
}
}
@@ -103,61 +83,3 @@ fn native_export_account(account: StoredAccount) -> Value {
"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));
}
}
+22 -5
View File
@@ -1,8 +1,9 @@
use axum::{
body::Bytes,
extract::State,
http::{HeaderMap, StatusCode},
response::IntoResponse,
Extension, Json,
Extension,
};
use serde::{Deserialize, Serialize};
@@ -16,10 +17,9 @@ use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminRespon
use super::account_status_value;
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct HealthCheckRequest {
pub ids: Option<Vec<String>>,
#[serde(alias = "stagger_ms")]
pub stagger_ms: Option<u64>,
pub concurrency: Option<u8>,
}
@@ -56,10 +56,10 @@ pub async fn health_check_accounts(
State(state): State<AppState>,
Extension(request_id): Extension<RequestId>,
headers: HeaderMap,
payload: Option<Json<HealthCheckRequest>>,
body: Bytes,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.as_str().to_string();
let payload = payload.map(|Json(payload)| payload).unwrap_or_default();
let payload = parse_health_check_request(&body, &request_id)?;
if payload.ids.as_ref().is_some_and(Vec::is_empty) {
return Err(AdminError::new(
StatusCode::BAD_REQUEST,
@@ -125,6 +125,23 @@ pub async fn health_check_accounts(
))
}
fn parse_health_check_request(
body: &Bytes,
request_id: &str,
) -> Result<HealthCheckRequest, AdminError> {
if body.is_empty() {
return Ok(HealthCheckRequest::default());
}
serde_json::from_slice(body).map_err(|_| {
AdminError::new(
StatusCode::BAD_REQUEST,
40001,
"Invalid health check request",
request_id,
)
})
}
pub(super) fn health_check_error(error: HealthCheckError, request_id: &str) -> AdminError {
match error {
HealthCheckError::RepositoryUnavailable => AdminError::new(
+66 -207
View File
@@ -15,7 +15,7 @@ use crate::{
},
platform::http::request_id::RequestId,
runtime::state::AppState,
utils::json::{first_string, string_at},
utils::json::first_string,
};
use super::super::{require_admin_session, AdminEnvelope, AdminError, AdminResponse};
@@ -24,7 +24,6 @@ use super::validated_account_import_error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AccountImportFormat {
Native,
Sub2Api,
CodexCli,
}
@@ -32,7 +31,6 @@ impl AccountImportFormat {
fn as_str(self) -> &'static str {
match self {
Self::Native => "native",
Self::Sub2Api => "sub2api",
Self::CodexCli => "codex_cli",
}
}
@@ -208,227 +206,88 @@ fn store_import_account_error(error: StoreImportAccountError, request_id: &str)
}
fn parse_account_import_payload(payload: &Value) -> ParsedAccountImportPayload {
if let Some(accounts) = parse_sub2api_oauth_payload(payload) {
return ParsedAccountImportPayload {
accounts,
source_format: AccountImportFormat::Sub2Api,
};
}
parse_native_account_payload(payload)
}
fn parse_sub2api_oauth_payload(payload: &Value) -> Option<Vec<AccountImportEntry>> {
let accounts = payload.get("accounts")?.as_array()?;
let looks_like_sub2api = string_at(payload, &["type"]).as_deref() == Some("sub2api-data")
|| payload.get("proxies").is_some()
|| accounts
.iter()
.any(|account| account.get("credentials").is_some());
if !looks_like_sub2api {
return None;
}
Some(
accounts
.iter()
.filter_map(sub2api_oauth_account_entry)
.collect(),
)
}
fn sub2api_oauth_account_entry(account: &Value) -> Option<AccountImportEntry> {
let platform = string_at(account, &["platform"])?.to_ascii_lowercase();
let account_type = string_at(account, &["type"])?.to_ascii_lowercase();
if platform != "openai" || account_type != "oauth" {
return None;
}
let credentials = account.get("credentials")?;
let fallback_label = normalized_label(string_at(account, &["name"]));
let mut entry = account_entry_from_value(credentials, fallback_label);
if entry.token.is_none() && entry.refresh_token.is_none() {
return None;
}
if entry.email.is_none() {
entry.email = string_at(credentials, &["email"]);
}
if entry.account_id.is_none() {
entry.account_id = first_string(
credentials,
&[&["chatgpt_account_id"], &["account_id"], &["accountId"]],
);
}
if entry.user_id.is_none() {
entry.user_id = first_string(
credentials,
&[&["chatgpt_user_id"], &["user_id"], &["userId"]],
);
}
if entry.plan_type.is_none() {
entry.plan_type = first_string(credentials, &[&["plan_type"], &["planType"]]);
}
Some(entry)
}
fn parse_native_account_payload(payload: &Value) -> ParsedAccountImportPayload {
if let Some(accounts) = payload.as_array() {
return ParsedAccountImportPayload {
accounts: accounts
.iter()
.filter_map(|account| {
let entry = account_entry_from_value(account, None);
(entry.token.is_some() || entry.refresh_token.is_some()).then_some(entry)
})
.collect(),
source_format: AccountImportFormat::Native,
};
}
if let Some(accounts) = payload.get("accounts").and_then(Value::as_array) {
let source_format = if looks_like_sub2api_native_export(accounts) {
AccountImportFormat::Sub2Api
} else {
AccountImportFormat::Native
};
return ParsedAccountImportPayload {
accounts: accounts
.iter()
.filter_map(|account| {
let entry = account_entry_from_value(account, None);
(entry.token.is_some() || entry.refresh_token.is_some()).then_some(entry)
})
.collect(),
source_format,
};
}
let entry = account_entry_from_value(payload, None);
let accounts = if entry.token.is_some() || entry.refresh_token.is_some() {
vec![entry]
} else {
Vec::new()
};
ParsedAccountImportPayload {
accounts,
accounts: parse_native_account_payload(payload),
source_format: AccountImportFormat::Native,
}
}
fn looks_like_sub2api_native_export(accounts: &[Value]) -> bool {
accounts.iter().any(|account| {
// sub2api 兼容导出会携带代理/配额运行态字段;这里只用于格式识别,代理数据不进入 Rust 服务。
account.get("proxyApiKey").is_some()
|| account.get("cachedQuota").is_some()
|| account.get("quotaVerifyRequired").is_some()
})
fn parse_native_account_payload(payload: &Value) -> Vec<AccountImportEntry> {
if has_removed_import_container_field(payload) {
return Vec::new();
}
let Some(accounts) = payload.get("accounts").and_then(Value::as_array) else {
return Vec::new();
};
accounts
.iter()
.filter_map(account_entry_from_value)
.collect()
}
fn account_entry_from_value(value: &Value, fallback_label: Option<String>) -> AccountImportEntry {
let token = first_string(
value,
&[
&["token"],
&["accessToken"],
&["access_token"],
&["tokens", "accessToken"],
&["tokens", "access_token"],
&["credentials", "token"],
&["credentials", "accessToken"],
&["credentials", "access_token"],
],
)
.map(normalize_bearer_token);
let refresh_token = first_string(
value,
&[
&["refreshToken"],
&["refresh_token"],
&["tokens", "refreshToken"],
&["tokens", "refresh_token"],
&["credentials", "refreshToken"],
&["credentials", "refresh_token"],
],
);
AccountImportEntry {
id: first_string(value, &[&["id"]]),
email: first_string(value, &[&["email"], &["credentials", "email"]]),
account_id: first_string(
value,
&[
&["accountId"],
&["account_id"],
&["chatgpt_account_id"],
&["credentials", "accountId"],
&["credentials", "account_id"],
&["credentials", "chatgpt_account_id"],
],
),
user_id: first_string(
value,
&[
&["userId"],
&["user_id"],
&["chatgpt_user_id"],
&["credentials", "userId"],
&["credentials", "user_id"],
&["credentials", "chatgpt_user_id"],
],
),
label: label_from_value(value).or(fallback_label),
plan_type: first_string(
value,
&[
&["planType"],
&["plan_type"],
&["credentials", "planType"],
&["credentials", "plan_type"],
],
),
token,
refresh_token,
access_token_expires_at: first_string(
value,
&[
&["accessTokenExpiresAt"],
&["access_token_expires_at"],
&["credentials", "accessTokenExpiresAt"],
&["credentials", "access_token_expires_at"],
],
),
status: first_string(value, &[&["status"]]),
fn account_entry_from_value(value: &Value) -> Option<AccountImportEntry> {
if has_removed_import_account_field(value) {
return None;
}
let entry = AccountImportEntry {
id: first_string(value, &[&["id"]]),
email: first_string(value, &[&["email"]]),
account_id: first_string(value, &[&["accountId"]]),
user_id: first_string(value, &[&["userId"]]),
label: label_from_value(value),
plan_type: first_string(value, &[&["planType"]]),
token: first_string(value, &[&["token"]]),
refresh_token: first_string(value, &[&["refreshToken"]]),
access_token_expires_at: first_string(value, &[&["accessTokenExpiresAt"]]),
status: first_string(value, &[&["status"]]),
};
(entry.token.is_some() || entry.refresh_token.is_some()).then_some(entry)
}
fn has_removed_import_container_field(value: &Value) -> bool {
["proxies", "type", "exported_at"]
.iter()
.any(|field| value.get(*field).is_some())
}
fn has_removed_import_account_field(value: &Value) -> bool {
[
"accessToken",
"access_token",
"refresh_token",
"tokens",
"credentials",
"account_id",
"user_id",
"chatgpt_account_id",
"chatgpt_user_id",
"plan_type",
"access_token_expires_at",
"expires_at",
"name",
"account_name",
"accountName",
"account_note",
"accountNote",
"note",
"proxyApiKey",
"cachedQuota",
"quotaVerifyRequired",
"usage",
]
.iter()
.any(|field| value.get(*field).is_some())
}
fn label_from_value(value: &Value) -> Option<String> {
normalized_label(first_string(
value,
&[
&["label"],
&["name"],
&["account_name"],
&["accountName"],
&["account_note"],
&["accountNote"],
&["note"],
],
))
normalized_label(first_string(value, &[&["label"]]))
}
fn normalized_label(value: Option<String>) -> Option<String> {
value.map(|label| label.chars().take(64).collect())
}
fn normalize_bearer_token(value: String) -> String {
let trimmed = value.trim();
trimmed
.strip_prefix("Bearer ")
.or_else(|| trimmed.strip_prefix("bearer "))
.unwrap_or(trimmed)
.trim()
.to_string()
}
fn empty_to_none(value: Option<String>) -> Option<String> {
value
.map(|value| value.trim().to_string())
-1
View File
@@ -33,7 +33,6 @@ pub fn router() -> Router<AppState> {
.route("/api/admin/auth/logout", post(auth_logout))
.route("/api/admin/auth/login-start", post(auth_login_start))
.route("/api/admin/auth/code-relay", post(auth_code_relay))
.route("/api/admin/auth/callback", get(auth_callback))
.route("/auth/openai/callback", get(auth_callback))
.route("/api/admin/auth/device-login", post(auth_device_login))
.route(