mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 16:07:29 +00:00
fix: refine account import and testing flows
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_... rt_..."
|
||||
placeholder="eyJ... 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,
|
||||
|
||||
@@ -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="确认删除"
|
||||
|
||||
Reference in New Issue
Block a user