fix: refine account import and testing flows

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
zyycn
2026-07-04 01:04:26 +08:00
co-authored by Codex
parent 1318f78030
commit 5a0ee4def6
27 changed files with 1400 additions and 921 deletions
+10 -137
View File
@@ -2,10 +2,10 @@
use axum::{
body::Body,
extract::{Extension, Query, State},
extract::{Query, State},
http::{
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
HeaderMap, StatusCode,
StatusCode,
},
response::{IntoResponse, Response},
Json,
@@ -14,7 +14,7 @@ use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use serde_json::Value;
use sqlx::{QueryBuilder, Row, Sqlite};
use crate::{
@@ -31,13 +31,8 @@ use crate::{
AdminAccountRefreshOutcome, AdminAccountRefreshResult, AdminAccountUpdate,
OAuthExchangeInput,
},
monitoring::{
account_usage_service::AdminUsageRecord,
billing,
usage_record_model::{UsageRecord, UsageRecordLevel},
},
monitoring::{account_usage_service::AdminUsageRecord, billing},
},
http::middleware::request_id::RequestId,
infra::{
format::{
format_cost, format_percent, format_plain_number, format_tokens, nonnegative_i64_to_u64,
@@ -552,55 +547,23 @@ pub(crate) async fn create_account(
/// `GET /api/admin/accounts/export`
pub(crate) async fn export_accounts(
State(state): State<AppState>,
request_id: Option<Extension<RequestId>>,
_auth: AdminAuth,
headers: HeaderMap,
Query(query): Query<AccountExportQuery>,
) -> Result<impl IntoResponse, AdminError> {
let request_id = request_id.map(|Extension(request_id)| request_id.as_str().to_string());
let source = account_action_source(&headers);
let ids = account_export_ids(query.ids.as_deref());
if query.confirm.as_deref() != Some(ACCOUNT_EXPORT_CONFIRMATION) {
record_account_export_audit(
&state,
request_id.as_deref(),
&source,
&ids,
AccountExportAuditOutcome::MissingConfirmation,
)
.await;
return Err(AdminError::bad_request(
"account export requires confirm=export_sensitive_accounts",
));
}
match state.services.admin_accounts.export(ids.clone()).await {
Ok(result) => {
record_account_export_audit(
&state,
request_id.as_deref(),
&source,
&ids,
AccountExportAuditOutcome::Success,
)
.await;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result),
))
}
Err(error) => {
record_account_export_audit(
&state,
request_id.as_deref(),
&source,
&ids,
AccountExportAuditOutcome::from_error(&error),
)
.await;
Err(account_error(&error))
}
match state.services.admin_accounts.export(ids).await {
Ok(result) => Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(result),
)),
Err(error) => Err(account_error(&error)),
}
}
@@ -1228,96 +1191,6 @@ fn account_not_found() -> AdminError {
AdminError::not_found("Account not found")
}
enum AccountExportAuditOutcome {
Success,
MissingConfirmation,
EmptyIds,
NotFound,
Failed,
}
impl AccountExportAuditOutcome {
fn from_error(error: &AdminAccountError) -> Self {
match error {
AdminAccountError::EmptyIds => Self::EmptyIds,
AdminAccountError::NotFound => Self::NotFound,
_ => Self::Failed,
}
}
fn level(&self) -> UsageRecordLevel {
match self {
Self::Success => UsageRecordLevel::Warn,
Self::MissingConfirmation | Self::EmptyIds | Self::NotFound => UsageRecordLevel::Warn,
Self::Failed => UsageRecordLevel::Error,
}
}
fn message(&self) -> &'static str {
match self {
Self::Success => "Admin account export succeeded",
Self::MissingConfirmation => "Admin account export missing confirmation",
Self::EmptyIds => "Admin account export missing account ids",
Self::NotFound => "Admin account export account not found",
Self::Failed => "Admin account export failed",
}
}
fn status_code(&self) -> i64 {
match self {
Self::Success => i64::from(StatusCode::OK.as_u16()),
Self::MissingConfirmation | Self::EmptyIds => {
i64::from(StatusCode::BAD_REQUEST.as_u16())
}
Self::NotFound => i64::from(StatusCode::NOT_FOUND.as_u16()),
Self::Failed => i64::from(StatusCode::INTERNAL_SERVER_ERROR.as_u16()),
}
}
fn failure_class(&self) -> Option<&'static str> {
match self {
Self::Success => None,
Self::MissingConfirmation => Some("missing_confirmation"),
Self::EmptyIds => Some("empty_ids"),
Self::NotFound => Some("account_not_found"),
Self::Failed => Some("export_failed"),
}
}
}
async fn record_account_export_audit(
state: &AppState,
request_id: Option<&str>,
source: &str,
ids: &[String],
outcome: AccountExportAuditOutcome,
) {
let mut record = UsageRecord::new("admin_account_export", outcome.level(), outcome.message());
record.request_id = request_id.map(ToString::to_string);
record.route = Some("/api/admin/accounts/export".to_string());
record.status_code = Some(outcome.status_code());
record.failure_class = outcome.failure_class().map(ToString::to_string);
record.metadata = json!({
"source": source,
"accountIds": ids,
"accountCount": ids.len(),
});
if let Err(error) = state.services.usage_records.record_audit(record).await {
tracing::warn!(%error, "failed to record account export audit event");
}
}
fn account_action_source(headers: &HeaderMap) -> String {
headers
.get("x-real-ip")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("unknown")
.to_string()
}
fn account_export_ids(value: Option<&str>) -> Vec<String> {
value
.into_iter()
@@ -143,8 +143,15 @@ impl AdminAccountService {
chatgpt_user_id.as_deref(),
)
.await?;
let next_refresh_at = access_token_expires_at
.map(|expires_at| self.next_refresh_at_for_expires_at(&account_id, expires_at));
let refresh_token = resolved_tokens
.refresh_token
.map(|token| SecretString::new(token.into()));
let next_refresh_at = if refresh_token.is_some() {
access_token_expires_at
.map(|expires_at| self.next_refresh_at_for_expires_at(&account_id, expires_at))
} else {
None
};
if plan_type.is_none() {
plan_type = supplemental.plan_type;
}
@@ -163,9 +170,7 @@ impl AdminAccountService {
label,
plan_type,
access_token: SecretString::new(resolved_tokens.access_token.into()),
refresh_token: resolved_tokens
.refresh_token
.map(|token| SecretString::new(token.into())),
refresh_token,
access_token_expires_at,
status,
added_at: None,
@@ -3,7 +3,7 @@ use std::{
hash::{Hash, Hasher},
};
use chrono::Utc;
use chrono::{DateTime, Utc};
use futures::{stream, StreamExt};
use secrecy::{ExposeSecret, SecretString};
use tokio::time::sleep;
@@ -147,8 +147,12 @@ impl AdminAccountService {
.refresh_token_for_existing
.map(|token| SecretString::new(token.into())),
access_token_expires_at: Some(claims.expires_at),
next_refresh_at: Some(
self.next_refresh_at_for_expires_at(&existing.id, claims.expires_at),
next_refresh_at: create_next_refresh_at(
self,
&existing.id,
claims.expires_at,
tokens.refresh_token_for_new.is_some()
|| existing.refresh_token.is_some(),
),
status: crate::upstream::accounts::model::AccountStatus::Active,
},
@@ -460,6 +464,15 @@ impl AdminAccountService {
}
}
fn create_next_refresh_at(
service: &AdminAccountService,
account_id: &str,
expires_at: DateTime<Utc>,
has_refresh_token: bool,
) -> Option<DateTime<Utc>> {
has_refresh_token.then(|| service.next_refresh_at_for_expires_at(account_id, expires_at))
}
fn stable_jittered_millis(account_id: &str, base_millis: u64, variance: f64) -> u64 {
if base_millis == 0 {
return 0;
+84 -7
View File
@@ -7,7 +7,9 @@ use serde_json::{json, Value};
use tokio::sync::mpsc;
use crate::upstream::{
accounts::{model::Account, store::StoredAccount},
models::CodexModelInfo,
models::ModelRefreshPlanAccount,
protocol::{
responses::CodexResponsesRequest,
sse::{encode_sse_event, parse_sse_events, SseEvent},
@@ -37,19 +39,43 @@ impl AdminAccountService {
.await
.map_err(|_| AdminAccountError::Inspect)?
.ok_or(AdminAccountError::NotFound)?;
let plan_type = account.plan_type.as_deref().unwrap_or("default");
let catalog = self.models.catalog().await;
let models = catalog
.models_for_plan(plan_type)
.iter()
.map(account_model_option)
.collect::<Vec<_>>();
let plan_type = account_plan_type(&account);
let mut models = self.models.catalog().await.models_for_plan(&plan_type);
if models.is_empty() {
self.refresh_account_plan_models(&account, &plan_type).await;
models = self.models.catalog().await.models_for_plan(&plan_type);
}
let models = models.iter().map(account_model_option).collect::<Vec<_>>();
if models.is_empty() {
return Err(AdminAccountError::NoModels);
}
Ok(models)
}
async fn refresh_account_plan_models(&self, account: &StoredAccount, plan_type: &str) {
let request_id = uuid::Uuid::new_v4().to_string();
let plan_account = ModelRefreshPlanAccount {
plan_type: plan_type.to_string(),
account: stored_account_to_model_refresh_account(account.clone()),
};
if let Err(error) = self
.models
.refresh_backend_models_with_installation_id(
&[plan_account],
&request_id,
self.installation_id.as_deref(),
)
.await
{
tracing::warn!(
account_id = %account.id,
plan_type,
error = %error,
"failed to refresh account plan models"
);
}
}
pub async fn test_connection_stream(
&self,
account_id: &str,
@@ -143,6 +169,57 @@ fn account_model_option(model: &CodexModelInfo) -> AccountModelOption {
}
}
fn account_plan_type(account: &StoredAccount) -> String {
account
.plan_type
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("default")
.to_string()
}
fn stored_account_to_model_refresh_account(stored: StoredAccount) -> Account {
Account {
id: stored.id,
email: stored.email,
account_id: stored.account_id,
user_id: stored.user_id,
label: stored.label,
plan_type: stored.plan_type,
access_token: stored.access_token.expose_secret().to_string(),
refresh_token: stored
.refresh_token
.map(|token| token.expose_secret().to_string()),
access_token_expires_at: stored.access_token_expires_at,
next_refresh_at: stored.next_refresh_at,
status: stored.status,
quota_limit_reached: false,
quota_verify_required: false,
quota_cooldown_until: None,
cloudflare_cooldown_until: None,
request_count: 0,
empty_response_count: 0,
image_input_tokens: 0,
image_output_tokens: 0,
image_request_count: 0,
image_request_failed_count: 0,
window_request_count: 0,
window_input_tokens: 0,
window_output_tokens: 0,
window_cached_tokens: 0,
window_image_input_tokens: 0,
window_image_output_tokens: 0,
window_image_request_count: 0,
window_image_request_failed_count: 0,
window_started_at: None,
window_reset_at: None,
limit_window_seconds: None,
added_at: stored.added_at,
last_used_at: None,
}
}
fn test_responses_request(model: String) -> CodexResponsesRequest {
CodexResponsesRequest {
model,
+5 -120
View File
@@ -1,21 +1,18 @@
//! 管理端会话处理器。
use axum::{
extract::{Extension, FromRequestParts, State},
extract::{FromRequestParts, State},
http::{header::SET_COOKIE, request::Parts, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{
admin::{
auth::service::AdminSessionError,
monitoring::usage_record_model::{UsageRecord, UsageRecordLevel},
response::{AdminEnvelope, AdminError, AdminResponse},
},
http::middleware::request_id::RequestId,
infra::time::china_rfc3339,
runtime::state::AppState,
};
@@ -60,16 +57,10 @@ struct AdminSessionStatusData {
/// `POST /api/admin/login`
pub(crate) async fn login(
State(state): State<AppState>,
request_id: Option<Extension<RequestId>>,
headers: HeaderMap,
Json(payload): Json<AdminLoginRequest>,
) -> Result<Response, AdminError> {
let source = admin_login_source(&headers);
let request_id = request_id.map(|Extension(request_id)| request_id.as_str().to_string());
let username_provided = payload
.username
.as_deref()
.is_some_and(|username| !username.trim().is_empty());
let login_result = state
.services
.admin_sessions
@@ -80,50 +71,10 @@ pub(crate) async fn login(
)
.await;
let session = match login_result {
Ok(Some(session)) => {
record_admin_login_audit(
&state,
request_id.as_deref(),
&source,
username_provided,
AdminLoginAuditOutcome::Success,
)
.await;
session
}
Ok(None) => {
record_admin_login_audit(
&state,
request_id.as_deref(),
&source,
username_provided,
AdminLoginAuditOutcome::InvalidCredentials,
)
.await;
return Err(AdminError::invalid_admin_credentials());
}
Err(AdminSessionError::LoginThrottled) => {
record_admin_login_audit(
&state,
request_id.as_deref(),
&source,
username_provided,
AdminLoginAuditOutcome::Throttled,
)
.await;
return Err(AdminError::too_many_login_attempts());
}
Err(_) => {
record_admin_login_audit(
&state,
request_id.as_deref(),
&source,
username_provided,
AdminLoginAuditOutcome::InternalError,
)
.await;
return Err(AdminError::internal("Failed to create admin session"));
}
Ok(Some(session)) => session,
Ok(None) => return Err(AdminError::invalid_admin_credentials()),
Err(AdminSessionError::LoginThrottled) => return Err(AdminError::too_many_login_attempts()),
Err(_) => return Err(AdminError::internal("Failed to create admin session")),
};
let mut response = AdminResponse::new(
@@ -145,72 +96,6 @@ pub(crate) async fn login(
Ok(response)
}
enum AdminLoginAuditOutcome {
Success,
InvalidCredentials,
Throttled,
InternalError,
}
impl AdminLoginAuditOutcome {
fn level(&self) -> UsageRecordLevel {
match self {
Self::Success => UsageRecordLevel::Info,
Self::InvalidCredentials | Self::Throttled => UsageRecordLevel::Warn,
Self::InternalError => UsageRecordLevel::Error,
}
}
fn message(&self) -> &'static str {
match self {
Self::Success => "Admin login succeeded",
Self::InvalidCredentials => "Admin login failed",
Self::Throttled => "Admin login throttled",
Self::InternalError => "Admin login failed internally",
}
}
fn status_code(&self) -> i64 {
match self {
Self::Success => i64::from(StatusCode::OK.as_u16()),
Self::InvalidCredentials => i64::from(StatusCode::UNAUTHORIZED.as_u16()),
Self::Throttled => i64::from(StatusCode::TOO_MANY_REQUESTS.as_u16()),
Self::InternalError => i64::from(StatusCode::INTERNAL_SERVER_ERROR.as_u16()),
}
}
fn failure_class(&self) -> Option<&'static str> {
match self {
Self::Success => None,
Self::InvalidCredentials => Some("invalid_credentials"),
Self::Throttled => Some("login_throttled"),
Self::InternalError => Some("session_error"),
}
}
}
async fn record_admin_login_audit(
state: &AppState,
request_id: Option<&str>,
source: &str,
username_provided: bool,
outcome: AdminLoginAuditOutcome,
) {
let mut record = UsageRecord::new("admin_auth", outcome.level(), outcome.message());
record.request_id = request_id.map(ToString::to_string);
record.route = Some("/api/admin/login".to_string());
record.status_code = Some(outcome.status_code());
record.failure_class = outcome.failure_class().map(ToString::to_string);
record.metadata = json!({
"source": source,
"usernameProvided": username_provided,
});
if let Err(error) = state.services.usage_records.record_audit(record).await {
tracing::warn!(%error, "failed to record admin login audit event");
}
}
/// `GET /api/admin/auth/status`
pub(crate) async fn session_status(
State(state): State<AppState>,
@@ -277,12 +277,6 @@ impl AdminUsageRecordService {
self.append_with_settings(event, settings).await
}
/// 记录安全审计事件,不受普通使用记录开关影响。
pub async fn record_audit(&self, event: UsageRecord) -> Result<(), AdminUsageRecordError> {
let settings = *self.settings.read().await;
self.append_with_settings(event, settings).await
}
async fn append_with_settings(
&self,
mut event: UsageRecord,
+458 -105
View File
@@ -1,7 +1,7 @@
//! 账号导入逻辑。
use chrono::{DateTime, Utc};
use serde_json::Value;
use serde_json::{Map, Value};
use crate::upstream::accounts::model::AccountStatus;
use crate::upstream::accounts::token_refresh::{jwt_expiry, JwtExpiry};
@@ -44,6 +44,8 @@ pub enum AccountImportSource {
Cpr,
/// Sub2API 格式。
Sub2Api,
/// CLIProxyAPI auth 文件格式。
CliProxyApi,
}
impl AccountImportSource {
@@ -52,6 +54,7 @@ impl AccountImportSource {
match self {
Self::Cpr => "cpr",
Self::Sub2Api => "sub2api",
Self::CliProxyApi => "cliproxyapi",
}
}
}
@@ -70,6 +73,7 @@ const ACCOUNT_IMPORT_ACCOUNT_KEYS: &[&str] = &[
"planType",
"plan_type",
"token",
"at",
"accessToken",
"access_token",
"refreshToken",
@@ -82,34 +86,6 @@ const ACCOUNT_IMPORT_ACCOUNT_KEYS: &[&str] = &[
"updatedAt",
"updated_at",
];
const SUB2API_ACCOUNT_IMPORT_KEYS: &[&str] = &[
"id",
"email",
"accountId",
"account_id",
"userId",
"user_id",
"label",
"planType",
"plan_type",
"token",
"accessToken",
"access_token",
"refreshToken",
"refresh_token",
"status",
"addedAt",
"added_at",
"cachedQuota",
"cached_quota",
"quotaFetchedAt",
"quota_fetched_at",
"quotaVerifyRequired",
"quota_verify_required",
"proxyApiKey",
"proxy_api_key",
"usage",
];
/// 解析后的账号导入载荷。
#[derive(Debug, Clone)]
@@ -122,74 +98,132 @@ pub struct ParsedAccountImport {
/// 解析账号导入载荷。
pub fn parse_account_import_payload(payload: &Value) -> Result<ParsedAccountImport, &'static str> {
let payload = payload
let payload = admin_envelope_data(payload)?.unwrap_or(payload);
match explicit_account_import_source(payload)? {
Some(AccountImportSource::Cpr) => parse_cpr_payload(payload),
Some(AccountImportSource::Sub2Api) => parse_sub2api_payload(payload),
Some(AccountImportSource::CliProxyApi) => parse_cli_proxy_api_payload(payload),
None if account_import_payload_looks_sub2api(payload) => parse_sub2api_payload(payload),
None if account_import_payload_looks_cli_proxy_api(payload) => {
parse_cli_proxy_api_payload(payload)
}
None => parse_cpr_payload(payload),
}
}
fn admin_envelope_data(payload: &Value) -> Result<Option<&Value>, &'static str> {
let Some(data) = payload
.get("data")
.filter(|data| data.get("accounts").is_some())
.map(|data| -> Result<&Value, &'static str> {
ensure_account_import_keys(payload, ACCOUNT_IMPORT_ENVELOPE_KEYS)?;
Ok(data)
})
.transpose()?
.unwrap_or(payload);
else {
return Ok(None);
};
ensure_account_import_keys(payload, ACCOUNT_IMPORT_ENVELOPE_KEYS)?;
Ok(Some(data))
}
if let Some(accounts) = payload.get("accounts") {
ensure_account_import_keys(payload, ACCOUNT_IMPORT_CONTAINER_KEYS)?;
let accounts = accounts.as_array().ok_or("no importable accounts")?;
let source = account_import_source(payload, accounts)?;
return Ok(ParsedAccountImport {
source,
entries: parse_account_import_entries(accounts, source)?,
});
}
if let Some(accounts) = payload.as_array() {
let source = account_import_source(payload, accounts)?;
return Ok(ParsedAccountImport {
source,
entries: parse_account_import_entries(accounts, source)?,
});
fn explicit_account_import_source(
value: &Value,
) -> Result<Option<AccountImportSource>, &'static str> {
let Some(source_format) = first_string(value, &["sourceFormat", "source_format"]) else {
return Ok(None);
};
match source_format.trim().to_ascii_lowercase().as_str() {
"cpr" => Ok(Some(AccountImportSource::Cpr)),
"sub2api" => Ok(Some(AccountImportSource::Sub2Api)),
"cliproxyapi" | "cli_proxy_api" | "cli-proxy-api" | "cpa" => {
Ok(Some(AccountImportSource::CliProxyApi))
}
_ => Err("no importable accounts"),
}
}
let source = account_import_source(payload, std::slice::from_ref(payload))?;
fn parse_cpr_payload(payload: &Value) -> Result<ParsedAccountImport, &'static str> {
Ok(ParsedAccountImport {
source,
entries: account_import_entry_from_value(payload, source)?
.into_iter()
.collect(),
source: AccountImportSource::Cpr,
entries: parse_cpr_entries(payload)?,
})
}
fn parse_account_import_entries(
fn parse_cpr_entries(payload: &Value) -> Result<Vec<AccountImportEntry>, &'static str> {
if let Some(accounts) = payload.get("accounts") {
ensure_account_import_keys(payload, ACCOUNT_IMPORT_CONTAINER_KEYS)?;
let accounts = accounts.as_array().ok_or("no importable accounts")?;
return parse_entries(accounts, cpr_account_import_entry_from_value);
}
if let Some(accounts) = payload.as_array() {
return parse_entries(accounts, cpr_account_import_entry_from_value);
}
cpr_account_import_entry_from_value(payload).map(option_to_vec)
}
fn parse_sub2api_payload(payload: &Value) -> Result<ParsedAccountImport, &'static str> {
Ok(ParsedAccountImport {
source: AccountImportSource::Sub2Api,
entries: parse_sub2api_entries(payload)?,
})
}
fn parse_sub2api_entries(payload: &Value) -> Result<Vec<AccountImportEntry>, &'static str> {
if let Some(accounts) = payload.get("accounts") {
let accounts = accounts.as_array().ok_or("no importable accounts")?;
return parse_entries(accounts, sub2api_account_import_entry_from_value);
}
if let Some(accounts) = payload.as_array() {
return parse_entries(accounts, sub2api_account_import_entry_from_value);
}
sub2api_account_import_entry_from_value(payload).map(option_to_vec)
}
fn parse_cli_proxy_api_payload(payload: &Value) -> Result<ParsedAccountImport, &'static str> {
Ok(ParsedAccountImport {
source: AccountImportSource::CliProxyApi,
entries: parse_cli_proxy_api_entries(payload)?,
})
}
fn parse_cli_proxy_api_entries(payload: &Value) -> Result<Vec<AccountImportEntry>, &'static str> {
if let Some(accounts) = payload.get("accounts") {
let accounts = accounts.as_array().ok_or("no importable accounts")?;
return parse_entries(accounts, cli_proxy_api_account_import_entry_from_value);
}
if let Some(accounts) = payload.as_array() {
return parse_entries(accounts, cli_proxy_api_account_import_entry_from_value);
}
cli_proxy_api_account_import_entry_from_value(payload).map(option_to_vec)
}
fn parse_entries(
accounts: &[Value],
source: AccountImportSource,
parser: fn(&Value) -> Result<Option<AccountImportEntry>, &'static str>,
) -> Result<Vec<AccountImportEntry>, &'static str> {
let mut entries = Vec::new();
for account in accounts {
if let Some(entry) = account_import_entry_from_value(account, source)? {
if let Some(entry) = parser(account)? {
entries.push(entry);
}
}
Ok(entries)
}
fn account_import_entry_from_value(
fn option_to_vec<T>(value: Option<T>) -> Vec<T> {
value.into_iter().collect()
}
fn cpr_account_import_entry_from_value(
value: &Value,
source: AccountImportSource,
) -> Result<Option<AccountImportEntry>, &'static str> {
let Some(account) = value.as_object() else {
return Ok(None);
};
let allowed_keys = match source {
AccountImportSource::Cpr => ACCOUNT_IMPORT_ACCOUNT_KEYS,
AccountImportSource::Sub2Api => SUB2API_ACCOUNT_IMPORT_KEYS,
};
if account
.keys()
.any(|key| !allowed_keys.contains(&key.as_str()))
.any(|key| !ACCOUNT_IMPORT_ACCOUNT_KEYS.contains(&key.as_str()))
{
return Err("no importable accounts");
}
let token = first_string(value, &["token", "accessToken", "access_token"]);
let token = first_string(value, &["token", "at", "accessToken", "access_token"]);
let refresh_token = first_string(value, &["refreshToken", "refresh_token"]);
if token.is_none() && refresh_token.is_none() {
return Ok(None);
@@ -209,15 +243,247 @@ fn account_import_entry_from_value(
&["accessTokenExpiresAt", "access_token_expires_at"],
),
status: first_string(value, &["status"]),
cached_quota: (source == AccountImportSource::Sub2Api)
.then(|| first_value(value, &["cachedQuota", "cached_quota"]))
.flatten(),
quota_fetched_at: (source == AccountImportSource::Sub2Api)
.then(|| first_string(value, &["quotaFetchedAt", "quota_fetched_at"]))
.flatten(),
quota_verify_required: (source == AccountImportSource::Sub2Api)
.then(|| first_bool(value, &["quotaVerifyRequired", "quota_verify_required"]))
.flatten(),
cached_quota: None,
quota_fetched_at: None,
quota_verify_required: None,
}))
}
fn sub2api_account_import_entry_from_value(
value: &Value,
) -> Result<Option<AccountImportEntry>, &'static str> {
if sub2api_account_backup_entry(value) {
return Ok(sub2api_backup_account_entry(value));
}
Ok(sub2api_codex_session_or_flat_entry(value))
}
fn sub2api_backup_account_entry(value: &Value) -> Option<AccountImportEntry> {
let account = value.as_object()?;
if !optional_string_field_matches(account, "platform", "openai")
|| !optional_string_field_matches(account, "type", "oauth")
{
return None;
}
let credentials = value.get("credentials")?;
let token = first_path_string(
credentials,
[
&["access_token"],
&["accessToken"],
&["at"],
&["token", "access_token"],
&["token", "accessToken"],
&["token", "at"],
],
);
let refresh_token = first_path_string(
credentials,
[
&["refresh_token"],
&["refreshToken"],
&["rt"],
&["token", "refresh_token"],
&["token", "refreshToken"],
&["token", "rt"],
],
);
if token.is_none() && refresh_token.is_none() {
return None;
}
Some(AccountImportEntry {
id: first_string(value, &["id"]),
email: first_path_string(credentials, [&["email"], &["user", "email"]]),
account_id: first_path_string(
credentials,
[
&["chatgpt_account_id"],
&["chatgptAccountId"],
&["account_id"],
&["accountId"],
&["account", "id"],
&["account", "account_id"],
&["account", "chatgpt_account_id"],
],
),
user_id: first_path_string(
credentials,
[
&["chatgpt_user_id"],
&["chatgptUserId"],
&["user_id"],
&["userId"],
&["user", "id"],
],
),
label: first_string(value, &["label", "name"]),
plan_type: first_path_string(credentials, [&["plan_type"], &["planType"]]),
token,
refresh_token,
access_token_expires_at: first_datetime_string(
credentials,
[&["expires_at"], &["expiresAt"], &["expired"], &["expire"]],
),
status: first_string(value, &["status"]),
cached_quota: first_value(value, &["cachedQuota", "cached_quota"]),
quota_fetched_at: first_string(value, &["quotaFetchedAt", "quota_fetched_at"]),
quota_verify_required: first_bool(value, &["quotaVerifyRequired", "quota_verify_required"]),
})
}
fn sub2api_codex_session_or_flat_entry(value: &Value) -> Option<AccountImportEntry> {
let _account = value.as_object()?;
let token = first_path_string(
value,
[
&["tokens", "access_token"],
&["tokens", "accessToken"],
&["tokens", "at"],
&["access_token"],
&["accessToken"],
&["token"],
&["at"],
],
);
let refresh_token = first_path_string(
value,
[
&["tokens", "refresh_token"],
&["tokens", "refreshToken"],
&["tokens", "rt"],
&["refresh_token"],
&["refreshToken"],
&["rt"],
],
);
if token.is_none() && refresh_token.is_none() {
return None;
}
Some(AccountImportEntry {
id: first_string(value, &["id"]),
email: first_path_string(value, [&["email"], &["user", "email"]]),
account_id: first_path_string(
value,
[
&["chatgpt_account_id"],
&["chatgptAccountId"],
&["account_id"],
&["accountId"],
&["account", "id"],
&["account", "account_id"],
&["account", "chatgpt_account_id"],
],
),
user_id: first_path_string(
value,
[
&["chatgpt_user_id"],
&["chatgptUserId"],
&["user_id"],
&["userId"],
&["user", "id"],
],
),
label: first_path_string(value, [&["label"], &["name"], &["user", "name"]]),
plan_type: first_path_string(
value,
[
&["plan_type"],
&["planType"],
&["account", "plan_type"],
&["account", "planType"],
],
),
token,
refresh_token,
access_token_expires_at: first_datetime_string(
value,
[
&["tokens", "expires_at"],
&["tokens", "expiresAt"],
&["expires_at"],
&["expiresAt"],
],
),
status: first_string(value, &["status"]),
cached_quota: first_value(value, &["cachedQuota", "cached_quota"]),
quota_fetched_at: first_string(value, &["quotaFetchedAt", "quota_fetched_at"]),
quota_verify_required: first_bool(value, &["quotaVerifyRequired", "quota_verify_required"]),
})
}
fn cli_proxy_api_account_import_entry_from_value(
value: &Value,
) -> Result<Option<AccountImportEntry>, &'static str> {
let Some(account) = value.as_object() else {
return Ok(None);
};
if !cli_proxy_api_provider_is_codex(account) {
return Ok(None);
}
let token = first_path_string(
value,
[
&["access_token"],
&["accessToken"],
&["at"],
&["token", "access_token"],
&["token", "accessToken"],
&["token", "at"],
&["token"],
],
);
let refresh_token = first_path_string(
value,
[
&["refresh_token"],
&["refreshToken"],
&["rt"],
&["token", "refresh_token"],
&["token", "refreshToken"],
&["token", "rt"],
],
);
if token.is_none() && refresh_token.is_none() {
return Ok(None);
}
Ok(Some(AccountImportEntry {
id: None,
email: first_string(value, &["email"]),
account_id: first_string(
value,
&["chatgpt_account_id", "chatgptAccountId", "account_id"],
),
user_id: first_string(
value,
&["chatgpt_user_id", "chatgptUserId", "user_id", "userId"],
),
label: first_string(value, &["label", "name"]),
plan_type: first_path_string(
value,
[&["plan_type"], &["planType"], &["attributes", "plan_type"]],
),
token,
refresh_token,
access_token_expires_at: first_datetime_string(
value,
[
&["expired"],
&["expire"],
&["expires_at"],
&["expiresAt"],
&["expiry"],
&["expires"],
],
),
status: cli_proxy_api_status(account),
cached_quota: None,
quota_fetched_at: None,
quota_verify_required: None,
}))
}
@@ -235,42 +501,87 @@ fn ensure_account_import_keys(value: &Value, allowed_keys: &[&str]) -> Result<()
}
}
fn account_import_source(
value: &Value,
accounts: &[Value],
) -> Result<AccountImportSource, &'static str> {
if let Some(source_format) = first_string(value, &["sourceFormat", "source_format"]) {
return match source_format.trim().to_ascii_lowercase().as_str() {
"cpr" => Ok(AccountImportSource::Cpr),
"sub2api" => Ok(AccountImportSource::Sub2Api),
_ => Err("no importable accounts"),
};
fn account_import_payload_looks_sub2api(value: &Value) -> bool {
if value.get("proxies").is_some() || value.get("exported_at").is_some() {
return true;
}
if accounts.iter().any(account_import_entry_looks_sub2api) {
Ok(AccountImportSource::Sub2Api)
} else {
Ok(AccountImportSource::Cpr)
if let Some(accounts) = value.get("accounts").and_then(Value::as_array) {
return accounts.iter().any(sub2api_account_looks_known);
}
if let Some(accounts) = value.as_array() {
return accounts.iter().any(sub2api_account_looks_known);
}
sub2api_account_looks_known(value)
}
fn account_import_entry_looks_sub2api(value: &Value) -> bool {
fn sub2api_account_looks_known(value: &Value) -> bool {
let Some(account) = value.as_object() else {
return false;
};
[
"proxyApiKey",
"proxy_api_key",
"usage",
"cachedQuota",
"cached_quota",
"quotaFetchedAt",
"quota_fetched_at",
"quotaVerifyRequired",
"quota_verify_required",
]
.iter()
.any(|key| account.contains_key(*key))
sub2api_account_backup_entry(value)
|| value.get("tokens").is_some()
|| [
"proxyApiKey",
"proxy_api_key",
"usage",
"cachedQuota",
"cached_quota",
"quotaFetchedAt",
"quota_fetched_at",
"quotaVerifyRequired",
"quota_verify_required",
]
.iter()
.any(|key| account.contains_key(*key))
}
fn sub2api_account_backup_entry(value: &Value) -> bool {
value.get("credentials").is_some()
&& (value.get("platform").is_some() || value.get("type").is_some())
}
fn account_import_payload_looks_cli_proxy_api(value: &Value) -> bool {
if let Some(accounts) = value.get("accounts").and_then(Value::as_array) {
return accounts.iter().any(cli_proxy_api_account_looks_known);
}
if let Some(accounts) = value.as_array() {
return accounts.iter().any(cli_proxy_api_account_looks_known);
}
cli_proxy_api_account_looks_known(value)
}
fn cli_proxy_api_account_looks_known(value: &Value) -> bool {
value
.as_object()
.is_some_and(cli_proxy_api_provider_is_codex)
}
fn cli_proxy_api_provider_is_codex(account: &Map<String, Value>) -> bool {
let provider = account
.get("type")
.or_else(|| account.get("provider"))
.and_then(Value::as_str)
.map(str::trim)
.unwrap_or_default()
.to_ascii_lowercase();
matches!(provider.as_str(), "codex" | "openai")
}
fn cli_proxy_api_status(account: &Map<String, Value>) -> Option<String> {
account
.get("disabled")
.and_then(Value::as_bool)
.filter(|disabled| *disabled)
.map(|_| "disabled".to_string())
}
fn optional_string_field_matches(account: &Map<String, Value>, key: &str, expected: &str) -> bool {
account
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none_or(|value| value.eq_ignore_ascii_case(expected))
}
/// 解析导入的状态字符串。
@@ -360,6 +671,22 @@ fn first_string(value: &Value, keys: &[&str]) -> Option<String> {
.map(ToString::to_string)
}
fn first_path_string<const N: usize>(value: &Value, paths: [&[&str]; N]) -> Option<String> {
paths
.iter()
.find_map(|path| value_at_path(value, path).and_then(Value::as_str))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
fn first_datetime_string<const N: usize>(value: &Value, paths: [&[&str]; N]) -> Option<String> {
paths
.iter()
.find_map(|path| value_at_path(value, path))
.and_then(datetime_value_to_rfc3339)
}
fn first_bool(value: &Value, keys: &[&str]) -> Option<bool> {
keys.iter()
.find_map(|key| value.get(key).and_then(Value::as_bool))
@@ -370,3 +697,29 @@ fn first_value(value: &Value, keys: &[&str]) -> Option<Value> {
.find_map(|key| value.get(key).filter(|value| !value.is_null()))
.cloned()
}
fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
let mut current = value;
for key in path {
current = current.get(*key)?;
}
Some(current)
}
fn datetime_value_to_rfc3339(value: &Value) -> Option<String> {
if let Some(value) = value.as_str() {
return normalize_nonempty_str(Some(value)).map(ToString::to_string);
}
if let Some(value) = value.as_i64() {
return DateTime::<Utc>::from_timestamp(value, 0).map(|time| time.to_rfc3339());
}
if let Some(value) = value.as_u64().and_then(|value| i64::try_from(value).ok()) {
return DateTime::<Utc>::from_timestamp(value, 0).map(|time| time.to_rfc3339());
}
value
.as_f64()
.filter(|value| value.is_finite())
.map(|value| value.trunc() as i64)
.and_then(|value| DateTime::<Utc>::from_timestamp(value, 0))
.map(|time| time.to_rfc3339())
}
@@ -151,12 +151,15 @@ where
let mut expired_recovery_index = 0;
for account in accounts {
if account.refresh_token.is_none()
|| matches!(
account.status,
AccountStatus::Disabled | AccountStatus::Banned
)
{
if self.skip_account_without_refresh_token(&account).await? {
summary.skipped += 1;
continue;
}
if matches!(
account.status,
AccountStatus::Disabled | AccountStatus::Banned
) {
summary.skipped += 1;
continue;
}
@@ -240,12 +243,18 @@ where
account_id.to_string(),
));
};
if account.refresh_token.is_none()
|| matches!(
account.status,
AccountStatus::Disabled | AccountStatus::Banned
)
{
if account.refresh_token.is_none() {
self.clear_scheduled_timer(account_id).await;
if account.next_refresh_at.is_some() {
persist_next_refresh_at(&self.store, account_id, None).await?;
}
return Ok(());
}
if matches!(
account.status,
AccountStatus::Disabled | AccountStatus::Banned
) {
return Ok(());
}
@@ -272,6 +281,11 @@ where
};
for account in accounts {
if self.skip_account_without_refresh_token(&account).await? {
summary.skipped += 1;
continue;
}
if account.next_refresh_at.is_some_and(|value| value > now) {
summary.skipped += 1;
continue;
@@ -336,6 +350,22 @@ where
(refresh_at - now).to_std().ok()
}
async fn skip_account_without_refresh_token(
&self,
account: &Account,
) -> TokenRefreshServiceResult<bool> {
if account.refresh_token.is_some() {
return Ok(false);
}
self.clear_scheduled_timer(&account.id).await;
if account.next_refresh_at.is_some() {
self.persist_next_refresh_at_if_changed(account, None)
.await?;
}
Ok(true)
}
fn computed_refresh_at(&self, account: &Account) -> Option<DateTime<Utc>> {
let expires_at = account
.access_token_expires_at
@@ -440,6 +470,10 @@ where
));
};
let account = stored_account_to_refresh_account(account);
if self.skip_account_without_refresh_token(&account).await? {
return Ok(TokenRefreshOutcome::Skipped);
}
if account.next_refresh_at.is_some_and(|value| value > now) {
return Ok(TokenRefreshOutcome::Skipped);
}
+14 -52
View File
@@ -1,5 +1,4 @@
use super::*;
use sqlx::Row;
const EXPORT_CONFIRM: &str = "confirm=export_sensitive_accounts";
@@ -295,28 +294,22 @@ async fn admin_accounts_export_should_require_explicit_sensitive_export_confirma
.unwrap();
let status = response.status();
let body = response_json(response).await;
let audit = account_export_audit_row(&pool, "req_export_missing_confirmation").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
body["message"],
"account export requires confirm=export_sensitive_accounts"
);
assert_eq!(audit.level, "warn");
assert_eq!(audit.status_code, Some(400));
assert_eq!(
audit.failure_class,
Some("missing_confirmation".to_string())
usage_record_count_by_request_id(&pool, "req_export_missing_confirmation").await,
0
);
assert_eq!(audit.metadata["accountIds"][0], "acct_export_confirm");
assert!(audit.metadata.get("token").is_none());
assert!(audit.metadata.get("refreshToken").is_none());
}
#[tokio::test]
async fn admin_accounts_export_should_record_success_audit_event() {
async fn admin_accounts_export_should_not_write_usage_record_on_success() {
let (app, _state, pool, _dir) =
admin_accounts_test_app("admin-accounts-export-audit.sqlite", 137).await;
admin_accounts_test_app("admin-accounts-export-usage.sqlite", 137).await;
seed_account(
&pool,
NewAccount {
@@ -358,48 +351,17 @@ async fn admin_accounts_export_should_record_success_audit_event() {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let audit = account_export_audit_row(&pool, "req_export_success_audit").await;
assert_eq!(audit.kind, "admin_account_export");
assert_eq!(audit.level, "warn");
assert_eq!(audit.status_code, Some(200));
assert_eq!(audit.failure_class, None);
assert_eq!(audit.message, "Admin account export succeeded");
assert_eq!(audit.metadata["accountCount"], 1);
assert_eq!(audit.metadata["accountIds"][0], "acct_export_audit");
assert!(audit.metadata.get("token").is_none());
assert!(audit.metadata.get("refreshToken").is_none());
assert_eq!(
usage_record_count_by_request_id(&pool, "req_export_success_audit").await,
0
);
}
#[derive(Debug)]
struct AccountExportAuditRow {
kind: String,
level: String,
status_code: Option<i64>,
failure_class: Option<String>,
message: String,
metadata: Value,
}
async fn account_export_audit_row(pool: &SqlitePool, request_id: &str) -> AccountExportAuditRow {
let row = sqlx::query(
"select kind, level, status_code, failure_class, message, metadata_json \
from usage_records \
where kind = 'admin_account_export' and request_id = ? \
order by created_at desc, id desc \
limit 1",
)
.bind(request_id)
.fetch_one(pool)
.await
.unwrap();
AccountExportAuditRow {
kind: row.get("kind"),
level: row.get("level"),
status_code: row.get("status_code"),
failure_class: row.get("failure_class"),
message: row.get("message"),
metadata: serde_json::from_str(&row.get::<String, _>("metadata_json")).unwrap(),
}
async fn usage_record_count_by_request_id(pool: &SqlitePool, request_id: &str) -> i64 {
sqlx::query_scalar("select count(*) from usage_records where request_id = ?")
.bind(request_id)
.fetch_one(pool)
.await
.unwrap()
}
+251
View File
@@ -81,6 +81,256 @@ async fn admin_accounts_import_should_store_cpr_account_tokens() {
assert_eq!(stored.status, AccountStatus::Disabled);
}
#[tokio::test]
async fn admin_accounts_import_should_store_cpr_access_token_from_at_field() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"account_id": "cpr-at-account",
"user_id": "cpr-at-user",
"email": "cpr-at@example.com",
"plan_type": "k12",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 9,
"reset_at": 1_800_000_000,
"limit_window_seconds": 18_000
}
}
})))
.mount(&server)
.await;
let (app, _state, pool, _dir) = admin_accounts_test_app_with_api_base_url(
"admin-accounts-import-cpr-at.sqlite",
129,
format!("{}/backend-api", server.uri()),
)
.await;
let access_token = unsigned_jwt(&json!({
"exp": 4_102_444_800i64,
"https://api.openai.com/auth": {},
"https://api.openai.com/profile": {},
}));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts_import_cpr_at")
.body(Body::from(
json!({
"accounts": [{
"id": "acct_import_cpr_at",
"at": access_token.clone(),
"status": "active"
}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let stored = SqliteAccountStore::new(pool)
.get("acct_import_cpr_at")
.await
.unwrap()
.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["data"]["imported"], 1);
assert_eq!(body["data"]["sourceFormat"], "cpr");
assert_eq!(stored.account_id.as_deref(), Some("cpr-at-account"));
assert_eq!(stored.user_id.as_deref(), Some("cpr-at-user"));
assert_eq!(stored.email.as_deref(), Some("cpr-at@example.com"));
assert_eq!(stored.plan_type.as_deref(), Some("k12"));
assert_eq!(stored.access_token.expose_secret(), access_token);
assert!(stored.refresh_token.is_none());
assert!(stored.next_refresh_at.is_none());
}
#[tokio::test]
async fn admin_accounts_import_should_read_sub2api_backup_access_token_only_accounts() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"account_id": "sub2api-at-account",
"user_id": "sub2api-at-user",
"email": "sub2api-at@example.com",
"plan_type": "k12",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 7,
"reset_at": 1_800_000_000,
"limit_window_seconds": 18_000
}
}
})))
.mount(&server)
.await;
let (app, _state, pool, _dir) = admin_accounts_test_app_with_api_base_url(
"admin-accounts-import-sub2api-backup-at.sqlite",
127,
format!("{}/backend-api", server.uri()),
)
.await;
let access_token = unsigned_jwt(&json!({
"exp": 4_102_444_800i64,
"https://api.openai.com/auth": {},
"https://api.openai.com/profile": {},
}));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts_import_sub2api_backup_at")
.body(Body::from(
json!({
"sourceFormat": "sub2api",
"exported_at": "2026-07-03T15:46:38.717Z",
"proxies": [],
"accounts": [{
"name": "sub2api-at@example.com",
"platform": "openai",
"type": "oauth",
"credentials": {
"at": access_token.clone(),
"refresh_token": "",
"expires_at": 4_102_444_800i64
},
"extra": {},
"concurrency": 3,
"priority": 50,
"rate_multiplier": null,
"auto_pause_on_expired": true
}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let store = SqliteAccountStore::new(pool);
let stored_metadata = store
.list_metadata_page(1, 10, None)
.await
.unwrap()
.items
.into_iter()
.next()
.unwrap();
let stored = store.get(&stored_metadata.id).await.unwrap().unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["data"]["imported"], 1);
assert_eq!(body["data"]["sourceFormat"], "sub2api");
assert_eq!(stored.account_id.as_deref(), Some("sub2api-at-account"));
assert_eq!(stored.user_id.as_deref(), Some("sub2api-at-user"));
assert_eq!(stored.email.as_deref(), Some("sub2api-at@example.com"));
assert_eq!(stored.plan_type.as_deref(), Some("k12"));
assert_eq!(stored.access_token.expose_secret(), access_token);
assert!(stored.refresh_token.is_none());
assert!(stored.next_refresh_at.is_none());
}
#[tokio::test]
async fn admin_accounts_import_should_read_cliproxyapi_codex_auth_files() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/usage"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"account_id": "cliproxyapi-account",
"user_id": "cliproxyapi-user",
"email": "cliproxyapi@example.com",
"plan_type": "plus",
"rate_limit": {
"allowed": true,
"limit_reached": false,
"primary_window": {
"used_percent": 3,
"reset_at": 1_800_000_000,
"limit_window_seconds": 18_000
}
}
})))
.mount(&server)
.await;
let (app, _state, pool, _dir) = admin_accounts_test_app_with_api_base_url(
"admin-accounts-import-cliproxyapi.sqlite",
128,
format!("{}/backend-api", server.uri()),
)
.await;
let access_token = unsigned_jwt(&json!({
"exp": 4_102_444_800i64,
"https://api.openai.com/auth": {},
"https://api.openai.com/profile": {},
}));
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/admin/accounts/import")
.header("content-type", "application/json")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_accounts_import_cliproxyapi")
.body(Body::from(
json!({
"sourceFormat": "cliproxyapi",
"accounts": [{
"type": "codex",
"access_token": access_token.clone(),
"refresh_token": "cliproxyapi-refresh",
"expired": "2100-01-01T00:00:00Z",
"email": "cliproxyapi@example.com",
"label": "CLIProxyAPI"
}]
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = response_json(response).await;
let stored = SqliteAccountStore::new(pool)
.list_metadata_page(1, 10, None)
.await
.unwrap()
.items
.into_iter()
.next()
.unwrap();
assert_eq!(status, StatusCode::OK);
assert_eq!(body["data"]["imported"], 1);
assert_eq!(body["data"]["sourceFormat"], "cliproxyapi");
assert_eq!(stored.account_id.as_deref(), Some("cliproxyapi-account"));
assert_eq!(stored.user_id.as_deref(), Some("cliproxyapi-user"));
assert_eq!(stored.email.as_deref(), Some("cliproxyapi@example.com"));
assert_eq!(stored.plan_type.as_deref(), Some("plus"));
}
#[tokio::test]
async fn admin_accounts_import_should_fetch_wham_usage_for_current_openai_token_identity() {
const CURRENT_IMPORT_ACCOUNT_ID: &str = "acct_import_current_token";
@@ -346,6 +596,7 @@ async fn admin_accounts_import_from_refresh_token_should_not_store_consumed_refr
);
let account = store.get(&stored.id).await.unwrap().unwrap();
assert!(account.refresh_token.is_none());
assert!(account.next_refresh_at.is_none());
}
#[tokio::test]
@@ -866,6 +866,7 @@ async fn admin_account_manual_create_from_refresh_token_should_not_store_consume
assert_eq!(status, StatusCode::OK);
assert!(stored.refresh_token.is_none());
assert!(stored.next_refresh_at.is_none());
}
#[tokio::test]
+174 -1
View File
@@ -2,7 +2,7 @@ use super::*;
use axum::body::to_bytes;
use codex_proxy_rs::upstream::protocol::sse::{encode_sse_event, parse_sse_events};
use wiremock::{
matchers::{method, path},
matchers::{header, method, path},
Mock, MockServer, ResponseTemplate,
};
@@ -75,6 +75,146 @@ async fn account_models_should_return_database_snapshot_models() {
assert_eq!(body["data"]["models"][1]["label"], "GPT 5.4");
}
#[tokio::test]
async fn account_models_should_fetch_missing_plan_snapshot_with_account_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/codex/models"))
.and(header("authorization", "Bearer access-plan-b"))
.and(header("chatgpt-account-id", "chatgpt-plan-b"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"models": [
{
"slug": "gpt-plan-b-live",
"display_name": "GPT Plan B Live",
"description": "Live model",
"is_default": true,
"supported_reasoning_efforts": [{"reasoning_effort": "medium", "description": "medium"}],
"default_reasoning_effort": "medium",
"input_modalities": ["text"],
"output_modalities": ["text"],
"supports_personality": false
}
]
})))
.mount(&server)
.await;
let (app, state, pool, _dir) = admin_accounts_test_app_with_api_base_url(
"admin-account-models-unfetched-plan.sqlite",
93,
format!("{}/backend-api", server.uri()),
)
.await;
seed_account(
&pool,
NewAccount {
id: "acct_plan_b".to_string(),
email: Some("plan-b@example.com".to_string()),
account_id: Some("chatgpt-plan-b".to_string()),
user_id: None,
label: None,
plan_type: Some("plan-b".to_string()),
access_token: SecretString::new("access-plan-b".to_string().into()),
refresh_token: None,
access_token_expires_at: None,
status: AccountStatus::Active,
added_at: None,
},
)
.await;
seed_model_snapshot(&pool, "plus").await;
state
.services
.models
.reload_from_store()
.await
.expect("model catalog should reload from seeded snapshot");
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/models?id=acct_plan_b")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_account_models_unfetched_plan")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(requests.len(), 1, "missing plan should be fetched upstream");
let body = response_json(response).await;
assert_eq!(body["data"]["models"][0]["id"], "gpt-plan-b-live");
assert_eq!(body["data"]["models"][0]["label"], "GPT Plan B Live");
let stored_plan_b: i64 =
sqlx::query_scalar("select count(*) from model_plan_snapshots where plan_type = ?")
.bind("plan-b")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(stored_plan_b, 1);
}
#[tokio::test]
async fn account_models_should_return_models_for_account_plan_snapshot() {
let server = MockServer::start().await;
let (app, state, pool, _dir) = admin_accounts_test_app_with_api_base_url(
"admin-account-models-matching-plan.sqlite",
94,
format!("{}/backend-api", server.uri()),
)
.await;
seed_account(
&pool,
NewAccount {
id: "acct_plan_b_snapshot".to_string(),
email: Some("plan-b-snapshot@example.com".to_string()),
account_id: Some("chatgpt-plan-b-snapshot".to_string()),
user_id: None,
label: None,
plan_type: Some("plan-b".to_string()),
access_token: SecretString::new("access-plan-b-snapshot".to_string().into()),
refresh_token: None,
access_token_expires_at: None,
status: AccountStatus::Active,
added_at: None,
},
)
.await;
seed_model_snapshot(&pool, "plan-a").await;
seed_single_model_snapshot(&pool, "plan-b", "gpt-plan-b-only", "GPT Plan B Only").await;
state
.services
.models
.reload_from_store()
.await
.expect("model catalog should reload from seeded snapshots");
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri("/api/admin/accounts/models?id=acct_plan_b_snapshot")
.header("cookie", "cpr_admin_session=session_1")
.header("x-request-id", "req_account_models_matching_plan")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let requests = server.received_requests().await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(requests.is_empty(), "model list should be loaded from DB");
let body = response_json(response).await;
assert_eq!(body["data"]["models"].as_array().unwrap().len(), 1);
assert_eq!(body["data"]["models"][0]["id"], "gpt-plan-b-only");
assert_eq!(body["data"]["models"][0]["label"], "GPT Plan B Only");
}
async fn seed_model_snapshot(pool: &SqlitePool, plan_type: &str) {
let models_json = json!([
{
@@ -116,6 +256,39 @@ async fn seed_model_snapshot(pool: &SqlitePool, plan_type: &str) {
.unwrap();
}
async fn seed_single_model_snapshot(
pool: &SqlitePool,
plan_type: &str,
model_id: &str,
display_name: &str,
) {
let models_json = json!([
{
"id": model_id,
"displayName": display_name,
"description": "Test model",
"isDefault": false,
"supportedReasoningEfforts": [{"reasoningEffort": "medium", "description": "medium"}],
"defaultReasoningEffort": "medium",
"inputModalities": ["text"],
"outputModalities": ["text"],
"supportsPersonality": false,
"upgrade": null,
"source": "test"
}
])
.to_string();
sqlx::query(
"insert or replace into model_plan_snapshots (plan_type, models_json, fetched_at) values (?, ?, ?)",
)
.bind(plan_type)
.bind(models_json)
.bind("2026-06-29T00:00:00Z")
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn account_test_stream_should_translate_upstream_responses_sse() {
let server = MockServer::start().await;
+28 -66
View File
@@ -4,8 +4,6 @@ use axum::{
};
use chrono::Utc;
use codex_proxy_rs::infra::{database::connect_sqlite, identity::hash_admin_password};
use serde_json::Value;
use sqlx::Row;
use sqlx::SqlitePool;
use tower::util::ServiceExt;
@@ -106,29 +104,23 @@ async fn admin_login_should_throttle_repeated_failures_from_same_source() {
}
#[tokio::test]
async fn admin_login_should_record_success_audit_event() {
async fn admin_login_should_not_write_usage_record_on_success() {
let (app, _dir, pool) =
admin_login_test_app("admin-login-success-audit.sqlite", "correct-password").await;
admin_login_test_app("admin-login-success-usage.sqlite", "correct-password").await;
let response = post_login_with_request_id(&app, "correct-password", "req_login_audit_ok").await;
let response = post_login_with_request_id(&app, "correct-password", "req_login_usage_ok").await;
assert_eq!(response.status(), StatusCode::OK);
let audit = admin_login_audit_row(&pool, "req_login_audit_ok").await;
assert_eq!(audit.kind, "admin_auth");
assert_eq!(audit.level, "info");
assert_eq!(audit.route, Some("/api/admin/login".to_string()));
assert_eq!(audit.status_code, Some(200));
assert_eq!(audit.failure_class, None);
assert_eq!(audit.message, "Admin login succeeded");
assert_eq!(audit.metadata["source"], "unknown");
assert_eq!(audit.metadata["usernameProvided"], false);
assert!(audit.metadata.get("username").is_none());
assert_eq!(
usage_record_count_by_request_id(&pool, "req_login_usage_ok").await,
0
);
}
#[tokio::test]
async fn admin_login_should_record_invalid_credentials_audit_event() {
async fn admin_login_should_not_write_usage_record_on_invalid_credentials() {
let (app, _dir, pool) =
admin_login_test_app("admin-login-invalid-audit.sqlite", "correct-password").await;
admin_login_test_app("admin-login-invalid-usage.sqlite", "correct-password").await;
let response = app
.clone()
@@ -137,7 +129,7 @@ async fn admin_login_should_record_invalid_credentials_audit_event() {
.method("POST")
.uri("/api/admin/login")
.header("content-type", "application/json")
.header("x-request-id", "req_login_audit_bad")
.header("x-request-id", "req_login_usage_bad")
.body(Body::from(
r#"{"username":"admin","password":"wrong-password"}"#,
))
@@ -146,36 +138,32 @@ async fn admin_login_should_record_invalid_credentials_audit_event() {
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let audit = admin_login_audit_row(&pool, "req_login_audit_bad").await;
assert_eq!(audit.level, "warn");
assert_eq!(audit.status_code, Some(401));
assert_eq!(audit.failure_class, Some("invalid_credentials".to_string()));
assert_eq!(audit.message, "Admin login failed");
assert_eq!(audit.metadata["usernameProvided"], true);
assert!(audit.metadata.get("username").is_none());
assert_eq!(
usage_record_count_by_request_id(&pool, "req_login_usage_bad").await,
0
);
}
#[tokio::test]
async fn admin_login_should_record_throttled_audit_event() {
async fn admin_login_should_not_write_usage_record_when_throttled() {
let (app, _dir, pool) =
admin_login_test_app("admin-login-throttled-audit.sqlite", "correct-password").await;
admin_login_test_app("admin-login-throttled-usage.sqlite", "correct-password").await;
for index in 0..5 {
let response =
post_login_with_request_id(&app, "wrong-password", &format!("req_login_audit_{index}"))
post_login_with_request_id(&app, "wrong-password", &format!("req_login_usage_{index}"))
.await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
let response =
post_login_with_request_id(&app, "correct-password", "req_login_audit_throttled").await;
post_login_with_request_id(&app, "correct-password", "req_login_usage_throttled").await;
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
let audit = admin_login_audit_row(&pool, "req_login_audit_throttled").await;
assert_eq!(audit.level, "warn");
assert_eq!(audit.status_code, Some(429));
assert_eq!(audit.failure_class, Some("login_throttled".to_string()));
assert_eq!(audit.message, "Admin login throttled");
assert_eq!(
usage_record_count_by_request_id(&pool, "req_login_usage_throttled").await,
0
);
}
async fn admin_login_test_app(
@@ -250,38 +238,12 @@ async fn post_login_with_request_id(
.unwrap()
}
#[derive(Debug)]
struct AdminLoginAuditRow {
kind: String,
level: String,
route: Option<String>,
status_code: Option<i64>,
failure_class: Option<String>,
message: String,
metadata: Value,
}
async fn admin_login_audit_row(pool: &SqlitePool, request_id: &str) -> AdminLoginAuditRow {
let row = sqlx::query(
"select kind, level, route, status_code, failure_class, message, metadata_json \
from usage_records \
where kind = 'admin_auth' and request_id = ? \
order by created_at desc, id desc \
limit 1",
)
.bind(request_id)
.fetch_one(pool)
.await
.unwrap();
AdminLoginAuditRow {
kind: row.get("kind"),
level: row.get("level"),
route: row.get("route"),
status_code: row.get("status_code"),
failure_class: row.get("failure_class"),
message: row.get("message"),
metadata: serde_json::from_str(&row.get::<String, _>("metadata_json")).unwrap(),
}
async fn usage_record_count_by_request_id(pool: &SqlitePool, request_id: &str) -> i64 {
sqlx::query_scalar("select count(*) from usage_records where request_id = ?")
.bind(request_id)
.fetch_one(pool)
.await
.unwrap()
}
async fn seed_admin_user(pool: &SqlitePool, password: &str) {
@@ -341,6 +341,88 @@ async fn token_refresh_task_should_skip_unauthorized_refresh_without_refresh_tok
);
}
#[tokio::test]
async fn token_refresh_task_should_clear_stale_refresh_time_without_refresh_token() {
let dir = tempfile::tempdir().expect("temp dir");
let db = dir.path().join("token-refresh-no-rt-clear-next.sqlite");
let pool = connect_sqlite(&format!("sqlite://{}", db.display()))
.await
.expect("sqlite pool");
let store = SqliteAccountStore::new(pool);
let now = Utc::now();
let old_access_token = test_jwt((now + Duration::minutes(10)).timestamp());
store
.insert(NewAccount {
id: "acct-refresh-no-rt-clear-next".to_string(),
email: Some("no-rt-clear-next@example.com".to_string()),
account_id: Some("chatgpt-no-rt-clear-next".to_string()),
user_id: Some("user-no-rt-clear-next".to_string()),
label: None,
plan_type: Some("plus".to_string()),
access_token: SecretString::new(old_access_token.clone().into()),
refresh_token: None,
added_at: None,
access_token_expires_at: Some(now + Duration::minutes(10)),
status: AccountStatus::Active,
})
.await
.expect("account should be inserted");
store
.set_next_refresh_at(
"acct-refresh-no-rt-clear-next",
Some(now + Duration::minutes(5)),
)
.await
.expect("stale future refresh time should persist");
let refresher = CountingTokenRefresher::default();
let task = codex_proxy_rs::upstream::accounts::token_refresh::RuntimeTokenRefreshService::new(
store.clone(),
RefreshPolicy {
refresh_margin_seconds: 300,
refresh_concurrency: 1,
},
refresher.clone(),
);
let timer_summary = task
.schedule_account_timers_once_at(now)
.await
.expect("timer scheduling should skip account without refresh token");
let after_timer_scan = store
.get("acct-refresh-no-rt-clear-next")
.await
.expect("account should load")
.expect("account should exist");
store
.set_next_refresh_at(
"acct-refresh-no-rt-clear-next",
Some(now - Duration::minutes(1)),
)
.await
.expect("stale due refresh time should persist");
let due_summary = task
.refresh_due_accounts_once_at(now)
.await
.expect("due refresh scan should skip account without refresh token");
let stored = store
.get("acct-refresh-no-rt-clear-next")
.await
.expect("account should load")
.expect("account should exist");
assert_eq!(timer_summary.skipped, 1);
assert_eq!(due_summary.skipped, 1);
assert_eq!(refresher.calls.load(Ordering::SeqCst), 0);
assert!(after_timer_scan.next_refresh_at.is_none());
assert!(stored.next_refresh_at.is_none());
assert_eq!(stored.status, AccountStatus::Active);
assert_eq!(
stored.access_token.expose_secret(),
old_access_token.as_str()
);
}
#[tokio::test]
async fn token_refresh_task_should_fire_per_account_timer_at_refresh_time() {
let dir = tempfile::tempdir().expect("temp dir");
+14 -2
View File
@@ -7,12 +7,14 @@ const props = withDefaults(
defineProps<{
viewClass?: string
maxHeight?: string
height?: string
horizontal?: boolean
vertical?: boolean
}>(),
{
viewClass: '',
maxHeight: undefined,
height: undefined,
horizontal: false,
vertical: true,
},
@@ -60,7 +62,7 @@ const horizontalThumbStyle = computed(() => ({
}))
const rootClasses = computed(() => [
'relative min-h-0 overflow-hidden',
props.maxHeight ? undefined : 'h-full',
props.maxHeight || props.height ? undefined : 'h-full',
])
const wrapClasses = computed(() => [
'min-h-0 overflow-auto max-h-[inherit] [-ms-overflow-style:none] scrollbar-none [&::-webkit-scrollbar]:h-0 [&::-webkit-scrollbar]:w-0 [&::-webkit-scrollbar]:bg-transparent',
@@ -204,6 +206,15 @@ async function scrollToTop() {
update()
}
async function scrollToBottom() {
const wrap = wrapRef.value
if (wrap) {
wrap.scrollTop = wrap.scrollHeight
}
await nextTick()
update()
}
function handleScroll() {
const wrap = wrapRef.value
if (!wrap) {
@@ -357,6 +368,7 @@ useEventListener(document, 'pointerup', handleHorizontalThumbPointerUp)
defineExpose({
update,
scrollToTop,
scrollToBottom,
wrapRef,
})
</script>
@@ -364,7 +376,7 @@ defineExpose({
<template>
<div
:class="rootClasses"
:style="{ maxHeight }"
:style="{ maxHeight, height }"
@mouseenter="activateScrollbar"
@mousemove="activateScrollbar"
@mouseleave="hideScrollbar"
@@ -27,7 +27,7 @@ const sizeClasses: Record<TextareaSize, string> = {
}
const textareaClasses = computed(() => [
'w-full resize-none rounded-(--cp-input-radius-base) border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] px-3.5 py-3 text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-(--cp-text-muted)',
'cp-scrollbar w-full resize-none rounded-(--cp-input-radius-base) border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] px-3.5 py-3 text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-(--cp-text-muted)',
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover) focus:bg-(--cp-input-soft-bg-focus) focus:shadow-(--cp-shadow-input-focus)',
'disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
sizeClasses[props.size],
@@ -1,11 +1,9 @@
<script setup lang="ts">
import { computed, onUnmounted, watch } from 'vue'
import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue'
import {
ArrowUpCircle,
CheckCircle2,
Circle,
ExternalLink,
GitBranch,
Power,
RefreshCw,
Terminal,
@@ -36,7 +34,6 @@ const {
updateStreaming,
updateStreamError,
hasUpdate,
isReleaseBuild,
canUpdate,
loadSystem,
checkUpdates,
@@ -47,6 +44,8 @@ const {
disconnectUpdateEvents,
} = useSystemUpdate()
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
const statusView = computed(() => {
if (updating.value) {
return {
@@ -111,40 +110,6 @@ const summaryItems = computed(() => [
},
])
const statusMessage = computed(() => {
if (updateError.value) return updateError.value
if (updateSuccess.value && needRestart.value) return '更新已完成,等待重启生效'
if (updating.value) return '正在替换应用包'
if (updateInfo.value?.unsupportedReason) return updateInfo.value.unsupportedReason
if (updateInfo.value?.warning) return updateInfo.value.warning
if (hasUpdate.value) return `可更新到 v${updateInfo.value?.latestVersion}`
if (updateInfo.value) return '当前版本已是最新'
return '尚未检查更新'
})
const statusToneClass = computed(() => {
if (updateError.value || updateInfo.value?.warning) {
return 'bg-(--cp-danger-bg) text-(--cp-danger-text)'
}
if (updateInfo.value?.unsupportedReason || (hasUpdate.value && !isReleaseBuild.value)) {
return 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
}
if (updateSuccess.value || hasUpdate.value) {
return 'bg-(--cp-success-bg) text-(--cp-success-text)'
}
return 'bg-(--cp-bg-muted) text-(--cp-text-secondary)'
})
const hasStatusNotice = computed(() =>
Boolean(
updateError.value ||
updateSuccess.value ||
updateInfo.value?.unsupportedReason ||
updateInfo.value?.warning ||
(hasUpdate.value && !isReleaseBuild.value),
),
)
const updateLogRows = computed(() =>
updateLogs.value.map((item) => ({
...item,
@@ -152,6 +117,10 @@ const updateLogRows = computed(() =>
})),
)
const showUpdateProgress = computed(
() => hasUpdate.value || updating.value || updateSuccess.value || updateLogRows.value.length > 0,
)
const streamStatusLabel = computed(() => {
if (updateStreaming.value) return '实时'
if (updateStreamError.value) return '断开'
@@ -221,6 +190,14 @@ watch(open, (visible) => {
}
})
watch(
() => updateLogs.value.at(-1)?.id,
async () => {
await nextTick()
await updateLogScrollbar.value?.scrollToBottom()
},
)
onUnmounted(() => {
clearRestartTimer()
disconnectUpdateEvents()
@@ -278,49 +255,10 @@ onUnmounted(() => {
</div>
</section>
<section class="grid gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3.5">
<div
v-if="!hasStatusNotice || updateInfo?.releaseUrl"
class="flex flex-wrap items-center justify-between gap-3"
>
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-text-muted)">状态</p>
<p
v-if="!hasStatusNotice"
class="mt-1.5 mb-0 wrap-break-word text-[13px] leading-normal font-[720] text-(--cp-text-primary)"
>
{{ statusMessage }}
</p>
</div>
<a
v-if="updateInfo?.releaseUrl"
:href="updateInfo.releaseUrl"
target="_blank"
rel="noreferrer"
class="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-(--cp-input-radius-base) bg-(--cp-info-bg) px-2.5 text-[12px] font-[720] text-(--cp-info-text) no-underline hover:bg-(--cp-info-bg-hover)"
>
发布页
<ExternalLink class="size-3.5" />
</a>
</div>
<div
v-if="hasStatusNotice"
class="flex items-center gap-2.5 rounded-(--cp-input-radius-base) px-3 py-2.5"
:class="statusToneClass"
>
<GitBranch
v-if="hasUpdate && !isReleaseBuild && !updateError && !updateInfo?.warning"
class="size-3.5 shrink-0"
/>
<component v-else :is="statusView.icon" class="size-3.5 shrink-0" />
<p class="m-0 wrap-break-word text-[12px] font-[650] leading-none">
{{ statusMessage }}
</p>
</div>
</section>
<section class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)">
<section
v-if="showUpdateProgress"
class="overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-subtle)"
>
<header class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
<div class="flex min-w-0 items-center gap-2">
<Terminal class="size-4 shrink-0 text-(--cp-success)" />
@@ -338,7 +276,7 @@ onUnmounted(() => {
</span>
</header>
<BaseScrollbar max-height="260px" view-class="px-4 pb-4">
<BaseScrollbar ref="updateLogScrollbar" height="260px" view-class="min-h-full px-4 pb-4">
<div v-if="updateLogRows.length" class="grid gap-2">
<div
v-for="log in updateLogRows"
+77 -39
View File
@@ -21,14 +21,16 @@ export const useUiStore = defineStore(
const sidebarCollapsed = shallowRef(false)
const themeMode = shallowRef<ThemeMode>('system')
const themeRevision = shallowRef(0)
const themeTransitioning = shallowRef(false)
const preferredMotion = usePreferredReducedMotion()
let themeApplied = false
let themeTransitionOrigin: ThemeTransitionOrigin | undefined
let themeTransitionRequested = false
let themeTransitioning = false
const { start: startFallbackTransitionTimer, stop: stopFallbackTransitionTimer } = useTimeoutFn(
() => {
document.documentElement.classList.remove('theme-fallback-transition')
themeTransitioning.value = false
},
180,
{ immediate: false },
@@ -55,7 +57,17 @@ export const useUiStore = defineStore(
}
function applyTheme(theme: ThemeName) {
if (!themeApplied || !themeTransitionRequested || preferredMotion.value === 'reduce') {
if (themeApplied && document.documentElement.dataset.theme === theme) {
themeTransitionRequested = false
return
}
if (
!themeApplied ||
!themeTransitionRequested ||
preferredMotion.value === 'reduce' ||
themeTransitioning.value
) {
commitTheme(theme)
themeApplied = true
themeTransitionRequested = false
@@ -75,16 +87,14 @@ export const useUiStore = defineStore(
function runThemeTransition(theme: ThemeName) {
const transitionDocument = document as ViewTransitionDocument
const previousTheme = document.documentElement.dataset.theme
const expanding = theme === 'dark'
const shrinkingDarkLayer =
document.documentElement.dataset.theme === 'dark' && theme === 'light'
if (!transitionDocument.startViewTransition) {
runFallbackThemeTransition(theme)
return
}
themeTransitioning = true
const origin = themeTransitionOrigin ?? {
x: window.innerWidth - 44,
y: 44,
@@ -93,44 +103,68 @@ export const useUiStore = defineStore(
const maxX = Math.max(origin.x, window.innerWidth - origin.x)
const maxY = Math.max(origin.y, window.innerHeight - origin.y)
const radius = Math.hypot(maxX, maxY)
document.documentElement.classList.toggle(
'theme-view-transition-reverse',
previousTheme === 'dark' && theme === 'light',
)
const transition = transitionDocument.startViewTransition(() => commitTheme(theme))
document.documentElement.classList.toggle('theme-view-transition-shrink', shrinkingDarkLayer)
themeTransitioning.value = true
let transition: ViewTransition
try {
transition = transitionDocument.startViewTransition(() => commitTheme(theme))
} catch {
document.documentElement.classList.remove('theme-view-transition-shrink')
themeTransitioning.value = false
runFallbackThemeTransition(theme)
return
}
transition.ready.then(() => {
document.documentElement.animate(
{
clipPath: expanding
? [
`circle(0px at ${origin.x}px ${origin.y}px)`,
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
]
: [
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
`circle(0px at ${origin.x}px ${origin.y}px)`,
],
},
{
duration: 420,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'both',
pseudoElement: expanding
? '::view-transition-new(root)'
: '::view-transition-old(root)',
},
)
})
transition.finished.finally(() => {
themeTransitioning = false
let cleaned = false
const cleanupTransition = () => {
if (cleaned) {
return
}
cleaned = true
requestAnimationFrame(() => {
document.documentElement.classList.remove('theme-view-transition-reverse')
document.documentElement.classList.remove('theme-view-transition-shrink')
themeTransitioning.value = false
})
}
const cleanupTimer = window.setTimeout(cleanupTransition, 1200)
void transition.finished.catch(() => undefined)
let activeAnimation: Animation | undefined
void transition.ready
.then(() => {
const animation = document.documentElement.animate(
{
clipPath: shrinkingDarkLayer
? [
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
`circle(0px at ${origin.x}px ${origin.y}px)`,
]
: [
`circle(0px at ${origin.x}px ${origin.y}px)`,
`circle(${radius}px at ${origin.x}px ${origin.y}px)`,
],
},
{
duration: 420,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'both',
pseudoElement: shrinkingDarkLayer
? '::view-transition-old(root)'
: '::view-transition-new(root)',
},
)
activeAnimation = animation
return animation.finished.catch(() => undefined)
})
.catch(() => undefined)
.finally(() => {
activeAnimation?.cancel()
window.clearTimeout(cleanupTimer)
cleanupTransition()
})
})
}
function runFallbackThemeTransition(theme: ThemeName) {
themeTransitioning.value = true
stopFallbackTransitionTimer()
document.documentElement.classList.add('theme-fallback-transition')
commitTheme(theme)
@@ -148,7 +182,10 @@ export const useUiStore = defineStore(
}
function toggleTheme(event?: MouseEvent) {
if (themeTransitioning) return
if (themeTransitioning.value) {
return
}
if (event) {
themeTransitionOrigin = {
x: event.clientX,
@@ -163,6 +200,7 @@ export const useUiStore = defineStore(
sidebarCollapsed,
themeMode,
themeRevision,
themeTransitioning,
effectiveTheme,
toggleSidebar,
initializeTheme,
+33 -2
View File
@@ -34,11 +34,11 @@ html.theme-fallback-transition body {
z-index: 2;
}
html.theme-view-transition-reverse::view-transition-old(root) {
html.theme-view-transition-shrink::view-transition-old(root) {
z-index: 2;
}
html.theme-view-transition-reverse::view-transition-new(root) {
html.theme-view-transition-shrink::view-transition-new(root) {
z-index: 1;
}
@@ -51,6 +51,37 @@ button:disabled {
cursor: not-allowed;
}
.cp-scrollbar {
scrollbar-color: var(--cp-scrollbar-thumb) transparent;
scrollbar-width: thin;
}
.cp-scrollbar::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.cp-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.cp-scrollbar::-webkit-scrollbar-thumb {
background: var(--cp-scrollbar-thumb);
border: 3px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
.cp-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--cp-scrollbar-thumb-hover);
border: 3px solid transparent;
background-clip: content-box;
}
.cp-scrollbar::-webkit-scrollbar-corner {
background: transparent;
}
@media (prefers-reduced-motion: reduce) {
html.theme-fallback-transition,
html.theme-fallback-transition body {
@@ -47,7 +47,7 @@ function connectionLogClass(tone: string) {
<section
class="flex items-center justify-between gap-4 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3"
>
<AccountIdentityCell :account="account" size="lg" />
<AccountIdentityCell :account="account" size="lg" show-plan />
<AccountStatusBadge :status="account.status" variant="pill" />
</section>
@@ -44,10 +44,11 @@ const { open: openImportFile, onChange: onImportFileChange } = useFileDialog({
})
const modeOptions = [
{ label: 'OAuth 授权', value: 'oauth' },
{ label: 'RT 导入', value: 'rt' },
{ label: 'OAuth', value: 'oauth' },
{ label: 'Token', value: 'token' },
{ label: 'CPR', value: 'cpr' },
{ label: 'Sub2API', value: 'sub2api' },
{ label: 'Sub2', value: 'sub2api' },
{ label: 'CPA', value: 'cliproxyapi' },
]
const mode = computed({
@@ -59,10 +60,10 @@ const mode = computed({
},
})
const refreshToken = computed({
get: () => form.value.refreshToken,
const tokenText = computed({
get: () => form.value.tokenText,
set: (value: string) => {
form.value = { ...form.value, refreshToken: value }
form.value = { ...form.value, tokenText: value }
},
})
@@ -107,7 +108,7 @@ const canSubmit = computed(() => {
form.value.oauthSessionId && oauthAuthUrl.value && oauthCallback.value.trim().length > 0,
)
}
if (mode.value === 'rt') return refreshToken.value.trim().length > 0
if (mode.value === 'token') return tokenText.value.trim().length > 0
return importText.value.trim().length > 0
})
@@ -115,8 +116,8 @@ const description = computed(() => {
if (props.reauthorizing) {
return '完成授权后粘贴回调地址,系统会更新账号凭据。'
}
if (mode.value === 'rt') {
return '一行一个 Refresh Token,导入时会自动换取访问令牌并补全账号信息。'
if (mode.value === 'token') {
return '一行一个 Access Token 或 Refresh Token,Access Token 会直接补全账号信息。'
}
if (mode.value === 'oauth') {
return '复制 OpenAI 授权链接,完成后粘贴回调地址,系统会自动写入或更新账号。'
@@ -124,6 +125,9 @@ const description = computed(() => {
if (mode.value === 'sub2api') {
return '导入 Sub2API 导出的账号 JSON,已存在账号会更新。'
}
if (mode.value === 'cliproxyapi') {
return '导入 CLIProxyAPI Codex auth JSON,已存在账号会更新。'
}
return '导入 Codex Proxy RS 账号 JSON,已存在账号会更新。'
})
@@ -166,12 +170,12 @@ async function copyOAuthAuthUrl() {
<div class="flex flex-col gap-4">
<BaseSegmented v-if="!reauthorizing" v-model="mode" :options="modeOptions" class="w-full" />
<BaseForm v-if="mode === 'rt'">
<BaseFormItem label="Refresh Token" required>
<BaseForm v-if="mode === 'token'">
<BaseFormItem label="Token" required>
<BaseTextarea
v-model="refreshToken"
v-model="tokenText"
size="md"
placeholder="rt_...&#10;rt_..."
placeholder="eyJ...&#10;rt_..."
:disabled="saving"
/>
</BaseFormItem>
@@ -1,143 +0,0 @@
<script setup lang="ts">
import { Fingerprint } from '@lucide/vue'
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseSwitch from '@/components/base/BaseSwitch.vue'
import AccountStatusBadge from './AccountStatusBadge.vue'
const props = defineProps<{
account: any
saving: boolean
}>()
const open = defineModel<boolean>({ default: false })
const form = defineModel<any>('form', { required: true })
const status = defineModel<string>('status', { required: true })
const emit = defineEmits<{
save: []
}>()
const accountEmail = computed(() => props.account?.email || '未绑定邮箱')
const accountPlan = computed(() => props.account?.planType || '-')
const detailItems = computed(() => [
{
label: 'ChatGPT 账号 ID',
value: props.account?.accountId,
itemClass: 'sm:col-span-2',
valueClass: 'font-mono text-[12px]',
},
{
label: '用户 ID',
value: props.account?.userId,
itemClass: '',
valueClass: 'font-mono text-[12px]',
},
{
label: '内部 ID',
value: props.account?.id,
itemClass: '',
valueClass: 'font-mono text-[12px]',
},
])
function formField(key: string) {
return computed({
get: () => form.value[key] ?? '',
set: (value: string) => {
form.value = { ...form.value, [key]: value }
},
})
}
const label = formField('label')
const scheduleEnabled = computed({
get: () => status.value !== 'disabled',
set: (enabled: boolean) => {
status.value = enabled ? 'active' : 'disabled'
},
})
</script>
<template>
<BaseModal
v-model="open"
title="编辑账号"
description="调整账号备注和调度状态。"
width="680px"
:close-disabled="saving"
>
<div class="space-y-6">
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-5 py-5">
<div class="grid min-w-0 gap-4 sm:grid-cols-[44px_minmax(0,1fr)_auto] sm:items-center">
<div
class="hidden size-11 items-center justify-center rounded-[14px] bg-(--cp-info-bg) text-(--cp-info-text) sm:inline-flex"
>
<Fingerprint class="size-5" :stroke-width="2.2" />
</div>
<div class="min-w-0">
<p class="m-0 text-[11px] leading-none font-[760] text-(--cp-info-text)">账号身份</p>
<p
class="mt-2 mb-0 truncate text-[16px] leading-tight font-[760] text-(--cp-text-primary)"
:title="accountEmail"
>
{{ accountEmail }}
</p>
<p class="mt-2 mb-0 text-[12px] leading-none font-[650] text-(--cp-text-secondary)">
套餐 {{ accountPlan }}
</p>
</div>
<AccountStatusBadge :status="account?.status" variant="pill" />
</div>
<dl
class="mt-5 grid gap-x-7 gap-y-3.5 pt-4 shadow-[inset_0_1px_0_var(--cp-divider-subtle)] sm:grid-cols-2"
>
<div
v-for="item in detailItems"
:key="item.label"
class="min-w-0"
:class="item.itemClass"
>
<dt class="text-[11px] leading-none font-[760] text-(--cp-text-muted)">
{{ item.label }}
</dt>
<dd
class="mt-2 mb-0 truncate text-[13px] leading-tight font-[650] text-(--cp-text-primary)"
:class="item.valueClass"
:title="item.value || '-'"
>
{{ item.value || '-' }}
</dd>
</div>
</dl>
</section>
<BaseForm :columns="2">
<BaseFormItem label="备注标签">
<BaseInput v-model="label" placeholder="例如:主账号 / 备用账号" :disabled="saving" />
</BaseFormItem>
<BaseFormItem label="调度开关">
<div class="grid box-content overflow-visible p-0.75">
<div class="flex h-(--cp-input-height-default) items-center">
<BaseSwitch v-model="scheduleEnabled" label="调度开关" :disabled="saving" />
</div>
</div>
</BaseFormItem>
</BaseForm>
</div>
<template #footer>
<BaseButton variant="ghost" :disabled="saving" @click="open = false">取消</BaseButton>
<BaseButton variant="primary" :loading="saving" @click="emit('save')">保存</BaseButton>
</template>
</BaseModal>
</template>
@@ -6,9 +6,11 @@ const props = withDefaults(
defineProps<{
account: any
size?: 'md' | 'lg'
showPlan?: boolean
}>(),
{
size: 'md',
showPlan: false,
},
)
@@ -33,6 +35,8 @@ const secondaryText = computed(() => {
const initial = computed(() => displayTitle.value.slice(0, 1).toUpperCase())
const planTypeLabel = computed(() => props.account.planType?.trim() || 'Free')
const avatarSizeClass = computed(() =>
props.size === 'lg' ? 'size-10 text-[15px]' : 'size-9 text-[13px]',
)
@@ -54,6 +58,20 @@ const avatarClass = computed(() => {
const hash = sumBy([...key], (char) => char.charCodeAt(0))
return palettes[hash % palettes.length]
})
const planClass = computed(() => {
const normalized = planTypeLabel.value.toLowerCase()
if (normalized.includes('enterprise') || normalized.includes('team')) {
return 'bg-(--cp-bg-dark) text-(--cp-white)'
}
if (normalized.includes('pro')) {
return 'bg-(--cp-info-bg) text-(--cp-info-text)'
}
if (normalized.includes('plus') || normalized.includes('basic')) {
return 'bg-(--cp-normal-bg) text-(--cp-normal-text)'
}
return 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'
})
</script>
<template>
@@ -65,8 +83,17 @@ const avatarClass = computed(() => {
{{ initial }}
</span>
<div class="min-w-0">
<div class="truncate text-[14px] font-[760] text-(--cp-text-primary)">
{{ displayTitle }}
<div class="flex min-w-0 items-center gap-2">
<span class="truncate text-[14px] font-[760] text-(--cp-text-primary)">
{{ displayTitle }}
</span>
<span
v-if="showPlan"
class="inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[11px] font-[760]"
:class="planClass"
>
{{ planTypeLabel }}
</span>
</div>
<div class="truncate font-[650]" :class="secondaryClass">
{{ secondaryText }}
@@ -90,7 +90,7 @@ function quotaWindowBarClass(window?: any) {
</div>
</div>
<div v-if="quotaWindows('shortTerm').length > 0" class="grid gap-2 sm:grid-cols-2">
<div v-if="quotaWindows('shortTerm').length > 0" class="grid gap-2">
<div
v-for="window in quotaWindows('shortTerm')"
:key="window.key"
@@ -107,7 +107,9 @@ function quotaWindowBarClass(window?: any) {
:style="quotaWindowBarStyle(window)"
/>
</div>
<div class="mt-3 flex flex-col gap-1 text-[12px] font-[620] text-(--cp-text-secondary)">
<div
class="mt-3 flex flex-wrap justify-between gap-x-3 gap-y-1 text-[12px] font-[620] text-(--cp-text-secondary)"
>
<span>重置时间: {{ window.resetAtDisplay }}</span>
<span>窗口已用: {{ window.windowUsedDisplay }}</span>
</div>
@@ -1,14 +1,5 @@
<script setup lang="ts">
import {
KeyRound,
LoaderCircle,
MoreHorizontal,
Pencil,
Power,
RefreshCw,
Trash2,
Wifi,
} from '@lucide/vue'
import { KeyRound, LoaderCircle, MoreHorizontal, Power, RefreshCw, Trash2, Wifi } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BasePopover from '@/components/base/BasePopover.vue'
@@ -23,7 +14,6 @@ defineProps<{
}>()
const emit = defineEmits<{
edit: [account: any]
delete: [account: any]
test: [account: any]
refresh: [accountId: string]
@@ -34,16 +24,6 @@ const emit = defineEmits<{
<template>
<div class="relative flex items-center justify-end gap-1">
<BaseButton
icon-only
variant="ghost"
size="sm"
title="编辑账号"
@click.stop="emit('edit', account)"
>
<Pencil class="size-3.5" />
</BaseButton>
<BaseButton
icon-only
variant="ghost"
@@ -19,6 +19,8 @@ import { useDownload } from '@/composables/useDownload'
import { useIdSet } from '@/composables/useIdSet'
import { withMinimumDuration } from '@/utils/async'
type TokenImportAccount = { token: string } | { refreshToken: string }
export function useAccountMutations(options: {
page: Ref<number>
pageSize: Ref<number>
@@ -39,8 +41,6 @@ export function useAccountMutations(options: {
const showDeleteModal = ref(false)
const showSingleDeleteModal = ref(false)
const reauthorizingAccount = ref<any>(null)
const editingAccount = ref<any>(null)
const editingScheduleStatus = ref('active')
const pendingDeleteAccount = ref<any>(null)
const refreshingAccounts = useIdSet<string>()
const refreshingQuotaAccounts = useIdSet<string>()
@@ -48,7 +48,6 @@ export function useAccountMutations(options: {
const deletingAccountAction = useAsyncAction()
const creatingAccountAction = useAsyncAction()
const authorizingOAuthAction = useAsyncAction()
const savingAccountAction = useAsyncAction()
const batchDeletingAction = useAsyncAction()
const exportingAccountsAction = useAsyncAction()
const refreshingAccountIds = refreshingAccounts.ids
@@ -57,24 +56,18 @@ export function useAccountMutations(options: {
const deletingAccount = deletingAccountAction.loading
const creatingAccount = creatingAccountAction.loading
const authorizingOAuth = authorizingOAuthAction.loading
const savingAccount = savingAccountAction.loading
const batchDeleting = batchDeletingAction.loading
const exportingAccounts = exportingAccountsAction.loading
const createForm = ref({
mode: 'oauth',
refreshToken: '',
tokenText: '',
importText: '',
oauthSessionId: '',
oauthAuthUrl: '',
oauthCallback: '',
})
const editForm = ref({
label: '',
status: 'active',
})
const showCreateModal = computed({
get: () => createModalOpen.value,
set: (value: boolean) => {
@@ -85,24 +78,6 @@ export function useAccountMutations(options: {
},
})
const showEditModal = computed({
get: () => editingAccount.value !== null,
set: (value: boolean) => {
if (!value) {
editingAccount.value = null
}
},
})
const editStatusModel = computed<string>({
get: () => editForm.value.status,
set: (value) => {
if (value === 'active' || value === 'disabled') {
editForm.value.status = value
}
},
})
async function loadAccounts() {
try {
loading.value = true
@@ -165,12 +140,11 @@ export function useAccountMutations(options: {
}
}
if (createForm.value.mode === 'rt') {
const accounts = createForm.value.refreshToken
if (createForm.value.mode === 'token') {
const accounts = createForm.value.tokenText
.split(/\r?\n/)
.map((token) => token.trim())
.filter(Boolean)
.map((refreshToken) => ({ refreshToken }))
.map(accountFromTokenLine)
.filter((account): account is TokenImportAccount => account !== null)
return accounts.length ? { sourceFormat: 'cpr', accounts } : null
}
@@ -185,7 +159,7 @@ export function useAccountMutations(options: {
throw new Error('JSON 格式不正确')
}
const sourceFormat = createForm.value.mode === 'sub2api' ? 'sub2api' : 'cpr'
const sourceFormat = accountImportSourceFormat()
if (Array.isArray(parsed)) {
return { sourceFormat, accounts: parsed }
}
@@ -195,6 +169,21 @@ export function useAccountMutations(options: {
return { sourceFormat, accounts: [parsed] }
}
function accountImportSourceFormat() {
if (createForm.value.mode === 'sub2api') return 'sub2api'
if (createForm.value.mode === 'cliproxyapi') return 'cliproxyapi'
return 'cpr'
}
function accountFromTokenLine(line: string): TokenImportAccount | null {
const token = line.trim()
if (!token) return null
if (token.startsWith('rt_')) {
return { refreshToken: token }
}
return { token }
}
async function handleAuthorizeOAuth() {
if (authorizingOAuth.value) return
@@ -236,7 +225,7 @@ export function useAccountMutations(options: {
function resetCreateForm() {
createForm.value = {
mode: 'oauth',
refreshToken: '',
tokenText: '',
importText: '',
oauthSessionId: '',
oauthAuthUrl: '',
@@ -273,45 +262,6 @@ export function useAccountMutations(options: {
void handleAuthorizeOAuth()
}
function openEditAccount(account: any) {
const scheduleStatus = account.status === 'disabled' ? 'disabled' : 'active'
editingAccount.value = account
editingScheduleStatus.value = scheduleStatus
editForm.value = {
label: account.label || '',
status: scheduleStatus,
}
}
async function handleSaveAccount() {
if (savingAccount.value) return
const account = editingAccount.value
if (!account) return
await savingAccountAction.run(
async () => {
const payload: any = {
id: account.id,
label: nullableTrimmedValue(editForm.value.label),
}
if (editForm.value.status !== editingScheduleStatus.value) {
payload.status = editForm.value.status
}
await updateAccount(payload)
editingAccount.value = null
await loadAccounts()
toast.success('账号已更新')
},
{ errorText: '保存失败' },
)
}
function nullableTrimmedValue(value: string) {
const trimmed = value.trim()
return trimmed || null
}
function requestDeleteAccount(account: any) {
pendingDeleteAccount.value = account
showSingleDeleteModal.value = true
@@ -442,9 +392,7 @@ export function useAccountMutations(options: {
showCreateModal,
showDeleteModal,
showSingleDeleteModal,
showEditModal,
reauthorizingAccount,
editingAccount,
pendingDeleteAccount,
refreshingAccountIds,
refreshingQuotaAccountIds,
@@ -452,20 +400,15 @@ export function useAccountMutations(options: {
deletingAccount,
creatingAccount,
authorizingOAuth,
savingAccount,
batchDeleting,
exportingAccounts,
createForm,
editForm,
editStatusModel,
loadAccounts,
handleCreate,
handleAuthorizeOAuth,
handleExchangeOAuth,
openCreateAccount,
openReauthorizeAccount,
openEditAccount,
handleSaveAccount,
requestDeleteAccount,
handleDelete,
handleBatchDelete,
-18
View File
@@ -15,7 +15,6 @@ import { useAccountMutations } from './composables/useAccountMutations'
import { useAccountsTable } from './composables/useAccountsTable'
import AccountConnectionTestModal from './components/AccountConnectionTestModal.vue'
import AccountCreateModal from './components/AccountCreateModal.vue'
import AccountEditModal from './components/AccountEditModal.vue'
import AccountIdentityCell from './components/AccountIdentityCell.vue'
import AccountOverviewCards from './components/AccountOverviewCards.vue'
import AccountQuotaSummaryCell from './components/AccountQuotaSummaryCell.vue'
@@ -63,8 +62,6 @@ const {
showCreateModal,
showDeleteModal,
showSingleDeleteModal,
showEditModal,
editingAccount,
pendingDeleteAccount,
refreshingAccountIds,
refreshingQuotaAccountIds,
@@ -72,20 +69,15 @@ const {
deletingAccount,
creatingAccount,
authorizingOAuth,
savingAccount,
batchDeleting,
exportingAccounts,
reauthorizingAccount,
createForm,
editForm,
editStatusModel,
loadAccounts,
handleCreate,
handleAuthorizeOAuth,
openCreateAccount,
openReauthorizeAccount,
openEditAccount,
handleSaveAccount,
requestDeleteAccount,
handleDelete,
handleBatchDelete,
@@ -282,7 +274,6 @@ function quotaTokenDisplay(account: any) {
:testing="testingConnectionIds.has(row.id)"
:updating-status="updatingStatusAccountIds.has(row.id)"
@delete="requestDeleteAccount"
@edit="openEditAccount"
@reauthorize="openReauthorizeAccount"
@refresh="handleRefresh"
@test="openConnectionTest"
@@ -332,15 +323,6 @@ function quotaTokenDisplay(account: any) {
@generate-oauth="handleAuthorizeOAuth"
/>
<AccountEditModal
v-model="showEditModal"
v-model:form="editForm"
v-model:status="editStatusModel"
:account="editingAccount"
:saving="savingAccount"
@save="handleSaveAccount"
/>
<BaseConfirmModal
v-model="showDeleteModal"
title="确认删除"