diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index 685db4412..3a3dae531 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -757,6 +757,24 @@ pub(crate) fn explicit_plan_type(value: Option<&str>) -> Option<&str> { }) } +/// 按需查询的订阅周期,不参与额度或账号可用性判断。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderSubscription { + pub starts_at: Option>, + pub expires_at: DateTime, + pub will_renew: Option, + pub billing_period: Option, + pub billing_currency: Option, + pub observed_at: DateTime, +} + +/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果。 +#[derive(Debug, Clone, PartialEq)] +pub struct AccountPersonalInfo { + pub profile: Result, + pub subscription: Option, +} + /// Provider 官方个人资料中的累计摘要。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderProfileStatisticsSummary { diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index f6f297fba..ac61c8b9b 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -19,7 +19,7 @@ use crate::model::provider_credentials::{ PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport, PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, - ProviderResetCreditResult, ProviderResetCredits, explicit_plan_type, + ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type, }; use crate::model::{ provider_credentials::{ProviderDocument, ProviderQuotaWindow}, @@ -185,6 +185,14 @@ pub trait ProviderAdmin: Send + Sync { } /// 查询 Provider 官方个人资料统计;不支持该能力的 Provider 使用默认拒绝。 + /// 只由个人信息显式查询,不在额度刷新或后台任务中预取。 + async fn subscription( + &self, + _account_id: &ProviderAccountId, + ) -> Result, ProviderAdminError> { + Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)) + } + async fn profile_statistics( &self, _account_id: &ProviderAccountId, diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index 1e500bf93..350dbf8f1 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -22,9 +22,9 @@ use crate::{ }, observability::TimeRange, provider_credentials::{ - AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult, - ConsumeProviderResetCredit, PrepareCredentialRefresh, ProviderModels, - ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest, + AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo, + AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh, + ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits, QuotaLocalUsageAttribution, }, @@ -90,11 +90,11 @@ pub trait AccountsService: Send + Sync { account_id: &ProviderAccountId, ) -> Result; - async fn profile_statistics( + async fn personal_info( &self, _account_id: &ProviderAccountId, - ) -> Result { - Err(AdminError::invalid("当前 Provider 不支持账号统计")) + ) -> Result { + Err(AdminError::invalid("当前 Provider 不支持个人信息")) } async fn profile_avatar( @@ -680,15 +680,36 @@ impl AccountsService for DefaultAccountsService { }) } - async fn profile_statistics( + async fn personal_info( &self, account_id: &ProviderAccountId, - ) -> Result { - let (_, provider) = self.provider_for_account(account_id).await?; - provider - .profile_statistics(account_id) - .await - .map_err(|error| map_provider_error(error, "provider profile statistics")) + ) -> Result { + let (initial, provider) = self.provider_for_account(account_id).await?; + // 两项读取相互独立;不因其中一项失败而取消另一项,也不触发凭据或额度刷新。 + let (profile, subscription) = futures::join!( + provider.profile_statistics(account_id), + provider.subscription(account_id), + ); + let profile = + profile.map_err(|error| map_provider_error(error, "provider profile statistics")); + let subscription = subscription + .map_err(|error| map_provider_error(error, "provider subscription")) + .ok() + .flatten(); + + // 汇聚等待期间发生重新授权、换绑或删除时,不返回混合身份的数据。 + let current = self.load_account(account_id).await?; + if current.account.provider_kind != initial.account.provider_kind + || current.account.credential_revision != initial.account.credential_revision + || current.account.upstream_user_id != initial.account.upstream_user_id + || current.account.upstream_account_id != initial.account.upstream_account_id + { + return Err(AdminError::conflict("账号身份已变化,请刷新信息后重试")); + } + Ok(AccountPersonalInfo { + profile, + subscription, + }) } async fn profile_avatar( diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 46b3f3b17..05ec5cb64 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -38,8 +38,10 @@ use gateway_admin::{ PrepareCredentialRefresh, PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedAuthorizationCredential, PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation, PreparedCredentialRotationFacts, ProviderDocument, - ProviderExport, ProviderExportCredentialInput, ProviderModels, ProviderQuota, - ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult, + ProviderExport, ProviderExportCredentialInput, ProviderModels, + ProviderProfileActivityInsights, ProviderProfileStatistics, + ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest, + ProviderQuotaWindow, ProviderResetCreditResult, ProviderSubscription, QuotaLocalUsageAttribution, }, quota_forecast_sampling::{QuotaForecastHistory, QuotaForecastUsage}, @@ -77,6 +79,9 @@ pub(super) struct FakeProviderAdmin { quota_refresh_account: Mutex, AccountRecord)>>, current_credential_revision: Mutex, reset_credit_commands: Mutex>, + profile_result: Mutex>, + subscription_result: Mutex, ProviderAdminErrorKind>>, + personal_info_barrier: Mutex>>, } impl FakeProviderAdmin { @@ -97,6 +102,9 @@ impl FakeProviderAdmin { quota_refresh_account: Mutex::new(None), current_credential_revision: Mutex::new(revision(1)), reset_credit_commands: Mutex::new(Vec::new()), + profile_result: Mutex::new(Ok(empty_profile_statistics())), + subscription_result: Mutex::new(Ok(None)), + personal_info_barrier: Mutex::new(None), }) } @@ -104,6 +112,14 @@ impl FakeProviderAdmin { *self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind)); } + async fn wait_personal_info_queries(&self) { + let barrier = self.personal_info_barrier.lock().unwrap().clone(); + if let Some(barrier) = barrier { + barrier.wait().await; + barrier.wait().await; + } + } + fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) { *self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind).with_message(message)); @@ -237,6 +253,32 @@ impl ProviderAdmin for FakeProviderAdmin { &self.kind } + async fn profile_statistics( + &self, + _: &ProviderAccountId, + ) -> Result { + self.record("provider.profile_statistics"); + self.wait_personal_info_queries().await; + self.profile_result + .lock() + .unwrap() + .clone() + .map_err(ProviderAdminError::new) + } + + async fn subscription( + &self, + _: &ProviderAccountId, + ) -> Result, ProviderAdminError> { + self.record("provider.subscription"); + self.wait_personal_info_queries().await; + self.subscription_result + .lock() + .unwrap() + .clone() + .map_err(ProviderAdminError::new) + } + fn plan_type_display(&self, plan_type: &str) -> String { format!("{} display: {plan_type}", self.kind) } @@ -2429,6 +2471,167 @@ fn rotation_result(command: CredentialRotationCommit) -> CredentialMutationResul } } +fn empty_profile_statistics() -> ProviderProfileStatistics { + ProviderProfileStatistics { + display_name: Some("Preview user".to_owned()), + username: None, + image_url: None, + has_stats_error: false, + summary: ProviderProfileStatisticsSummary { + total_text_tokens: Some(123), + peak_tokens: None, + longest_task_duration_ms: None, + current_streak_days: None, + longest_streak_days: None, + }, + daily_usage: None, + activity_insights: ProviderProfileActivityInsights { + fast_mode_percent: None, + reasoning_effort: None, + reasoning_effort_percent: None, + skills_explored: None, + total_skills_used: None, + total_threads: None, + invocations: None, + }, + } +} + +fn personal_info_subscription() -> ProviderSubscription { + ProviderSubscription { + starts_at: None, + expires_at: Utc::now(), + will_renew: Some(true), + billing_period: Some("monthly".to_owned()), + billing_currency: Some("USD".to_owned()), + observed_at: Utc::now(), + } +} + +#[tokio::test] +async fn personal_info_should_query_both_parts_concurrently_once_on_every_request() { + let events = events(); + let provider = FakeProviderAdmin::new("openai", events.clone()); + let subscription = personal_info_subscription(); + *provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone())); + *provider.personal_info_barrier.lock().unwrap() = Some(Arc::new(tokio::sync::Barrier::new(2))); + let store = FakeAccountStore::new("openai", events.clone()); + let services = accounts_service(provider, store).await; + let account_id = ProviderAccountId::new("acct_test").unwrap(); + + for _ in 0..2 { + let info = tokio::time::timeout( + std::time::Duration::from_secs(1), + services.accounts().personal_info(&account_id), + ) + .await + .expect("两项查询应并发执行") + .unwrap(); + assert_eq!(info.profile.unwrap(), empty_profile_statistics()); + assert_eq!(info.subscription, Some(subscription.clone())); + } + assert_eq!( + recorded(&events), + [ + "store.load_account", + "provider.profile_statistics", + "provider.subscription", + "store.load_account", + "store.load_account", + "provider.profile_statistics", + "provider.subscription", + "store.load_account", + ] + ); +} + +#[tokio::test] +async fn personal_info_should_keep_profile_when_subscription_is_missing_or_fails() { + for subscription in [Ok(None), Err(ProviderAdminErrorKind::Unavailable)] { + let provider = FakeProviderAdmin::new("openai", events()); + *provider.subscription_result.lock().unwrap() = subscription; + let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await; + let info = services + .accounts() + .personal_info(&ProviderAccountId::new("acct_test").unwrap()) + .await + .unwrap(); + assert_eq!(info.profile.unwrap(), empty_profile_statistics()); + assert_eq!(info.subscription, None); + } +} + +#[tokio::test] +async fn personal_info_should_keep_subscription_when_profile_fails() { + let provider = FakeProviderAdmin::new("openai", events()); + *provider.profile_result.lock().unwrap() = Err(ProviderAdminErrorKind::BadGateway); + let subscription = personal_info_subscription(); + *provider.subscription_result.lock().unwrap() = Ok(Some(subscription.clone())); + let services = accounts_service(provider, FakeAccountStore::new("openai", events())).await; + let info = services + .accounts() + .personal_info(&ProviderAccountId::new("acct_test").unwrap()) + .await + .unwrap(); + assert_eq!( + info.profile.unwrap_err().kind(), + gateway_admin::model::AdminErrorKind::BadGateway + ); + assert_eq!(info.subscription, Some(subscription)); +} + +#[tokio::test] +async fn personal_info_should_reject_missing_account_before_querying_provider() { + let events = events(); + let provider = FakeProviderAdmin::new("openai", events.clone()); + let store = FakeAccountStore::new("openai", events.clone()); + store.set_accounts(Vec::new()); + let services = accounts_service(provider, store).await; + let error = services + .accounts() + .personal_info(&ProviderAccountId::new("acct_test").unwrap()) + .await + .unwrap_err(); + assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::NotFound); + assert_eq!(recorded(&events), ["store.load_account"]); +} + +#[tokio::test] +async fn personal_info_should_discard_results_when_account_changes_during_query() { + for change in 0..5 { + let provider = FakeProviderAdmin::new("openai", events()); + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + *provider.personal_info_barrier.lock().unwrap() = Some(barrier.clone()); + let store = FakeAccountStore::new("openai", events()); + let services = accounts_service(provider, store.clone()).await; + let account_id = ProviderAccountId::new("acct_test").unwrap(); + let mutate = async { + barrier.wait().await; + let mut accounts = store.accounts.lock().unwrap().clone(); + match change { + 0 => accounts[0].credential_revision = revision(99), + 1 => accounts[0].upstream_account_id = Some("changed-account".to_owned()), + 2 => accounts[0].upstream_user_id = Some("changed-user".to_owned()), + 3 => accounts[0].provider_kind = ProviderKind::new("xai").unwrap(), + _ => accounts.clear(), + } + store.set_accounts(accounts); + barrier.wait().await; + }; + let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(1), async { + tokio::join!(services.accounts().personal_info(&account_id), mutate) + }) + .await + .unwrap(); + let expected = if change == 4 { + gateway_admin::model::AdminErrorKind::NotFound + } else { + gateway_admin::model::AdminErrorKind::Conflict + }; + assert_eq!(result.unwrap_err().kind(), expected); + } +} + async fn accounts_service( provider: Arc, store: Arc, diff --git a/backend/crates/gateway-api/src/admin/accounts/handlers.rs b/backend/crates/gateway-api/src/admin/accounts/handlers.rs index 18f4f403a..16f712e71 100644 --- a/backend/crates/gateway-api/src/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/src/admin/accounts/handlers.rs @@ -23,12 +23,12 @@ where ) .route("/api/admin/accounts/quota", get(account_quota::)) .route( - "/api/admin/accounts/quota-forecast", - get(account_quota_forecast::), + "/api/admin/accounts/personal-info", + get(account_personal_info::), ) .route( - "/api/admin/accounts/profile-statistics", - get(account_profile_statistics::), + "/api/admin/accounts/quota-forecast", + get(account_quota_forecast::), ) .route( "/api/admin/accounts/profile-avatar", @@ -389,7 +389,7 @@ where )) } -async fn account_profile_statistics( +async fn account_personal_info( _auth: AdminAuth, State(state): State, AdminQuery(query): AdminQuery, @@ -401,12 +401,12 @@ where let result = state .admin_services() .accounts() - .profile_statistics(&account_id) + .personal_info(&account_id) .await .map_err(map_service_error)?; Ok(AdminResponse::new( StatusCode::OK, - AdminEnvelope::ok(AccountProfileStatisticsData::from(result)), + AdminEnvelope::ok(AccountPersonalInfoData::from(result)), )) } diff --git a/backend/crates/gateway-api/src/admin/accounts/mod.rs b/backend/crates/gateway-api/src/admin/accounts/mod.rs index 1ad471c01..1a19adacb 100644 --- a/backend/crates/gateway-api/src/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/src/admin/accounts/mod.rs @@ -25,14 +25,15 @@ use gateway_admin::model::{ AccountWeight, AccountsUpdateResult, BatchUpdateAccounts, SortDirection, UpdateAccount, }, provider_credentials::{ - AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountRefreshResult, - AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization, + AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo, + AccountRefreshResult, AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult, CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials, ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, - ProviderResetCreditResult, ProviderResetCredits, RotateCredential, StartAuthorization, + ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, RotateCredential, + StartAuthorization, }, }; use gateway_core::{ diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index f9ad8171e..b276300b1 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -653,6 +653,54 @@ pub struct AccountQuotaData { pub account: AccountView, } +/// 个人资料、累计统计与订阅的统一响应。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountPersonalInfoData { + pub profile: Option, + pub profile_error: Option, + pub subscription: Option, +} + +impl From for AccountPersonalInfoData { + fn from(info: AccountPersonalInfo) -> Self { + let (profile, profile_error) = match info.profile { + Ok(profile) => (Some(AccountProfileStatisticsData::from(profile)), None), + Err(error) => (None, Some(error.message().to_owned())), + }; + Self { + profile, + profile_error, + subscription: info.subscription.map(AccountSubscriptionData::from), + } + } +} + +/// 按需读取的订阅安全字段,不暴露原始上游响应。 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AccountSubscriptionData { + pub starts_at: Option, + pub expires_at: String, + pub will_renew: Option, + pub billing_period: Option, + pub billing_currency: Option, + pub observed_at: String, +} + +impl From for AccountSubscriptionData { + fn from(subscription: ProviderSubscription) -> Self { + Self { + starts_at: subscription.starts_at.map(|value| value.to_rfc3339()), + expires_at: subscription.expires_at.to_rfc3339(), + will_renew: subscription.will_renew, + billing_period: subscription.billing_period, + billing_currency: subscription.billing_currency, + observed_at: subscription.observed_at.to_rfc3339(), + } + } +} + /// Provider 官方个人资料统计响应。 #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] diff --git a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs index 8f0dc3aaf..070e63e75 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs @@ -7,6 +7,42 @@ use tower::ServiceExt as _; use super::super::{AdminTestFixture, AdminTestState}; +#[tokio::test] +async fn personal_info_requires_admin_and_a_valid_account_query() { + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + for (query, authenticated, expected) in [ + ("?accountId=acct_test", false, StatusCode::UNAUTHORIZED), + ("", true, StatusCode::BAD_REQUEST), + ("?accountId=bad", true, StatusCode::BAD_REQUEST), + ( + "?accountId=acct_test&refresh=true", + true, + StatusCode::BAD_REQUEST, + ), + // 此夹具未提供账号 Store,合法查询应透传服务不可用,而非绕过查询。 + ( + "?accountId=acct_test", + true, + StatusCode::SERVICE_UNAVAILABLE, + ), + ] { + let mut request = Request::builder() + .uri(format!("/api/admin/accounts/personal-info{query}")) + .header("x-request-id", "req_personal_info"); + if authenticated { + request = request.header(header::COOKIE, "cpr_admin_session=valid-session"); + } + let response = admin::router::() + .with_state(fixture.state()) + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), expected, "{query}"); + assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store"); + } +} + #[tokio::test] async fn quota_forecast_requires_admin_and_valid_account_query() { let fixture = AdminTestFixture::new().await; diff --git a/backend/crates/gateway-api/tests/admin/accounts/mod.rs b/backend/crates/gateway-api/tests/admin/accounts/mod.rs index 232eef3c8..ee0e3a8f7 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/mod.rs @@ -1,6 +1,77 @@ mod handlers; mod presenter; +mod personal_info { + use chrono::{TimeZone as _, Utc}; + use gateway_admin::model::{ + AdminError, + provider_credentials::{AccountPersonalInfo, ProviderSubscription}, + }; + use gateway_api::admin::accounts::{AccountPersonalInfoData, AccountSubscriptionData}; + use serde_json::json; + + #[test] + fn subscription_response_exposes_only_display_fields_and_preserves_unknowns() { + let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap(); + let response = AccountSubscriptionData::from(ProviderSubscription { + starts_at: None, + expires_at: observed, + will_renew: None, + billing_period: None, + billing_currency: Some("USD".to_owned()), + observed_at: observed, + }); + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "startsAt": null, "expiresAt": "2026-09-14T00:00:00+00:00", + "willRenew": null, "billingPeriod": null, "billingCurrency": "USD", + "observedAt": "2026-09-14T00:00:00+00:00" + }) + ); + } + + #[test] + fn personal_info_response_preserves_subscription_when_profile_fails() { + let observed = Utc.with_ymd_and_hms(2026, 9, 14, 0, 0, 0).unwrap(); + let response = AccountPersonalInfoData::from(AccountPersonalInfo { + profile: Err(AdminError::bad_gateway("上游服务请求失败")), + subscription: Some(ProviderSubscription { + starts_at: None, + expires_at: observed, + will_renew: None, + billing_period: None, + billing_currency: None, + observed_at: observed, + }), + }); + let value = serde_json::to_value(response).unwrap(); + assert!(value["profile"].is_null()); + assert_eq!(value["profileError"], "上游服务请求失败"); + assert_eq!( + value["subscription"]["expiresAt"], + "2026-09-14T00:00:00+00:00" + ); + assert_eq!(value.as_object().unwrap().len(), 3); + } + + #[test] + fn personal_info_response_preserves_unknown_subscription_and_profile_error() { + let response = AccountPersonalInfoData::from(AccountPersonalInfo { + profile: Err(AdminError::unavailable("Provider 服务暂不可用")), + subscription: None, + }); + assert_eq!( + serde_json::to_value(response).unwrap(), + json!({ + "profile": null, + "profileError": "Provider 服务暂不可用", + "subscription": null, + }) + ); + } +} + mod query { use gateway_admin::model::accounts::{AccountSortField, AccountStatus, SortDirection}; use gateway_api::admin::accounts::ListQuery; diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index abeb5418f..85314187b 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -24,7 +24,7 @@ use gateway_admin::model::provider_credentials::{ ProviderProfileAvatar, ProviderProfileAvatarStreamError, ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole, - ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits, + ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, QuotaLocalUsageAttribution, }; use gateway_admin::model::quota_forecast_sampling::QuotaForecastObservation; @@ -513,6 +513,27 @@ impl ProviderAdmin for OpenAiAdminProvider { }) } + async fn subscription( + &self, + account_id: &ProviderAccountId, + ) -> Result, ProviderAdminError> { + self.account(account_id).await?; + self.profile_statistics + .subscription(account_id) + .await + .map(|subscription| { + subscription.map(|subscription| ProviderSubscription { + starts_at: subscription.starts_at, + expires_at: subscription.expires_at, + will_renew: subscription.will_renew, + billing_period: subscription.billing_period, + billing_currency: subscription.billing_currency, + observed_at: subscription.observed_at, + }) + }) + .map_err(map_profile_statistics_error) + } + async fn profile_statistics( &self, account_id: &ProviderAccountId, diff --git a/backend/crates/providers/openai/src/credential/profile_statistics.rs b/backend/crates/providers/openai/src/credential/profile_statistics.rs index 32008fe59..1cf52f651 100644 --- a/backend/crates/providers/openai/src/credential/profile_statistics.rs +++ b/backend/crates/providers/openai/src/credential/profile_statistics.rs @@ -13,6 +13,7 @@ use thiserror::Error; use uuid::Uuid; use crate::transport::profile::CodexWireProfileState; +use crate::transport::subscription::CodexSubscription; use crate::transport::{ CodexBackendClient, CodexClientError, CodexProfileAvatar, CodexProfileAvatarFetchError, CodexProfileStatistics, CodexRequestContext, fetch_profile_avatar, @@ -129,6 +130,53 @@ impl CodexCredentialProfileService { } } + /// 订阅只按需读取;不缓存、不刷新凭据,也不更新额度状态。 + pub async fn subscription( + &self, + account_id: &ProviderAccountId, + ) -> Result, CodexProfileStatisticsError> { + let (authorization, upstream_account_id, account) = + self.account_authentication(account_id).await?; + let Some(upstream_account_id) = upstream_account_id else { + return Ok(None); + }; + let request_id = format!("subscription_{}", Uuid::now_v7().simple()); + let subscription = CodexBackendClient::new( + self.http.clone(), + self.base_url.clone(), + self.profile.clone(), + ) + .for_account(&account) + .map_err(map_client_error)? + .fetch_subscription( + CodexRequestContext::auxiliary( + authorization.expose_secret(), + Some(&upstream_account_id), + &request_id, + None, + ), + &upstream_account_id, + ) + .await; + // 请求期间重新授权、换绑或删除账号时,丢弃旧身份的结果。 + let current = self + .repository + .store() + .get_account(account_id) + .await + .map_err(|error| CodexProfileStatisticsError::Store { + detail: error.to_string(), + })?; + if !current.is_some_and(|current| { + current.provider() == account.provider() + && current.revision() == account.revision() + && current.upstream_account_id() == Some(upstream_account_id.as_str()) + }) { + return Ok(None); + } + Ok(subscription) + } + pub async fn profile_statistics( &self, account_id: &ProviderAccountId, diff --git a/backend/crates/providers/openai/src/transport/mod.rs b/backend/crates/providers/openai/src/transport/mod.rs index a0f3efa38..1cfed0bbb 100644 --- a/backend/crates/providers/openai/src/transport/mod.rs +++ b/backend/crates/providers/openai/src/transport/mod.rs @@ -16,6 +16,7 @@ pub mod request; pub mod reset_credits; mod response_meta; pub(crate) mod session; +pub mod subscription; mod time; pub mod tls; pub mod usage; diff --git a/backend/crates/providers/openai/src/transport/subscription.rs b/backend/crates/providers/openai/src/transport/subscription.rs new file mode 100644 index 000000000..980a5407c --- /dev/null +++ b/backend/crates/providers/openai/src/transport/subscription.rs @@ -0,0 +1,97 @@ +//! 当前绑定账号的订阅周期安全投影,与令牌有效期和额度状态无关。 + +use std::time::Duration; + +use chrono::{DateTime, Utc}; + +use super::{CodexBackendClient, CodexRequestContext, client::read_capped_response_body}; + +const MAX_SUBSCRIPTION_BODY_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexSubscription { + pub starts_at: Option>, + pub expires_at: DateTime, + pub will_renew: Option, + pub billing_period: Option, + pub billing_currency: Option, + pub observed_at: DateTime, +} + +impl CodexBackendClient { + /// 可选展示查询失败只返回未知,不改变凭据或额度状态,也不重试。 + pub async fn fetch_subscription( + &self, + context: CodexRequestContext<'_>, + account_id: &str, + ) -> Option { + if account_id.is_empty() || account_id.len() > 512 { + return None; + } + tokio::time::timeout(Duration::from_secs(5), async { + let mut url = reqwest::Url::parse(&self.base_url).ok()?; + let base_path = url.path().trim_end_matches('/'); + let subscription_path = if base_path.ends_with("/backend-api") { + format!("{base_path}/subscriptions") + } else { + "/backend-api/subscriptions".to_owned() + }; + url.set_path(&subscription_path); + url.set_query(None); + url.set_fragment(None); + url.query_pairs_mut().append_pair("account_id", account_id); + let origin = url.origin().ascii_serialization(); + let headers = self.account_request_headers(context).ok()?; + let response = self + .client + .get(url) + .headers(headers) + .header(reqwest::header::ACCEPT, "application/json") + .header(reqwest::header::ORIGIN, &origin) + .header(reqwest::header::REFERER, format!("{origin}/")) + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let body = read_capped_response_body(response, MAX_SUBSCRIPTION_BODY_BYTES) + .await + .ok()?; + if body.limit_exceeded() { + return None; + } + let value: serde_json::Value = serde_json::from_str(&body.into_string()).ok()?; + let expires_at = + DateTime::parse_from_rfc3339(value.get("active_until")?.as_str()?.trim()) + .ok()? + .with_timezone(&Utc); + Some(CodexSubscription { + starts_at: value + .get("active_start") + .and_then(serde_json::Value::as_str) + .and_then(|value| DateTime::parse_from_rfc3339(value.trim()).ok()) + .map(|value| value.with_timezone(&Utc)), + expires_at, + will_renew: value.get("will_renew").and_then(serde_json::Value::as_bool), + billing_period: optional_short_text(&value, "billing_period"), + billing_currency: optional_short_text(&value, "billing_currency"), + observed_at: Utc::now(), + }) + }) + .await + .ok() + .flatten() + } +} + +fn optional_short_text(value: &serde_json::Value, key: &str) -> Option { + value + .get(key)? + .as_str() + .map(str::trim) + .filter(|value| { + !value.is_empty() && value.len() <= 64 && !value.chars().any(char::is_control) + }) + .map(str::to_owned) +} diff --git a/backend/crates/providers/openai/tests/credential/profile_statistics.rs b/backend/crates/providers/openai/tests/credential/profile_statistics.rs index 91a70d697..c84eda93d 100644 --- a/backend/crates/providers/openai/tests/credential/profile_statistics.rs +++ b/backend/crates/providers/openai/tests/credential/profile_statistics.rs @@ -6,7 +6,7 @@ use gateway_core::account::ProviderAccountId; use provider_openai::credential::{CodexCredentialProfileService, ImportCodexOAuthCredential}; use provider_openai::transport::profile::{CodexWireProfile, CodexWireProfileState}; use serde_json::json; -use wiremock::matchers::{header, method, path}; +use wiremock::matchers::{header, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; use crate::support::{MemoryAccountStore, profile, secret}; @@ -59,6 +59,11 @@ async fn service( #[tokio::test] async fn profile_statistics_uses_one_profile_request_and_preserves_official_fields() { let server = MockServer::start().await; + Mock::given(path("/backend-api/subscriptions")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; Mock::given(method("GET")) .and(path("/api/codex/profiles/me")) .and(header( @@ -227,3 +232,66 @@ async fn profile_avatar_reuses_cached_source_with_the_accounts_rotated_token() { "chatgpt-acct_profile_avatar" ); } + +#[tokio::test] +async fn subscription_is_read_only_and_makes_one_bound_account_request_per_query() { + let server = MockServer::start().await; + let (store, service) = service("acct_subscription", &server).await; + let account = store.account("acct_subscription").expect("account"); + Mock::given(method("GET")) + .and(path("/backend-api/subscriptions")) + .and(query_param("account_id", "chatgpt-acct_subscription")) + .and(header("chatgpt-account-id", "chatgpt-acct_subscription")) + .and(header("authorization", "Bearer token-acct_subscription")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "active_until": "2026-10-01T00:00:00Z", "will_renew": true + }))) + .expect(2) + .mount(&server) + .await; + for _ in 0..2 { + let subscription = service.subscription(account.id()).await.unwrap().unwrap(); + assert_eq!(subscription.will_renew, Some(true)); + } + let after = store.account("acct_subscription").expect("account"); + assert_eq!(after.revision(), account.revision()); + assert_eq!(after.quota(), account.quota()); + assert_eq!(server.received_requests().await.unwrap().len(), 2); +} + +#[tokio::test] +async fn subscription_discards_response_when_credential_rotates_during_query() { + let server = MockServer::start().await; + let (store, service) = service("acct_subscription_rotation", &server).await; + let account = store + .account("acct_subscription_rotation") + .expect("account"); + let requested = Arc::new(tokio::sync::Notify::new()); + let notify = Arc::clone(&requested); + Mock::given(path("/backend-api/subscriptions")) + .respond_with(move |_: &wiremock::Request| { + notify.notify_one(); + ResponseTemplate::new(200) + .set_body_json(json!({"active_until": "2026-10-01T00:00:00Z"})) + .set_delay(std::time::Duration::from_millis(100)) + }) + .expect(1) + .mount(&server) + .await; + let (result, ()) = tokio::join!(service.subscription(account.id()), async { + tokio::time::timeout(std::time::Duration::from_secs(3), requested.notified()) + .await + .unwrap(); + store + .repository() + .rotate_refreshed_oauth_secret( + &account, + secret("rotated-subscription-token"), + None, + None, + ) + .await + .unwrap(); + }); + assert!(result.unwrap().is_none()); +} diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs index 7474c9367..f9f2252e0 100644 --- a/backend/crates/providers/openai/tests/credential/quota/mod.rs +++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs @@ -5,6 +5,7 @@ mod refresh_timing; mod scheduling; mod slots; mod snapshot; +mod subscription; use std::sync::Arc; use std::time::SystemTime; diff --git a/backend/crates/providers/openai/tests/credential/quota/subscription.rs b/backend/crates/providers/openai/tests/credential/quota/subscription.rs new file mode 100644 index 000000000..a97c0c927 --- /dev/null +++ b/backend/crates/providers/openai/tests/credential/quota/subscription.rs @@ -0,0 +1,37 @@ +//! 订阅展示不得随手工或后台额度检查预取。 + +use super::*; + +#[tokio::test] +async fn quota_refresh_and_background_synchronize_do_not_query_subscription() { + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, "acct_quota_without_subscription").await; + let account = store.account("acct_quota_without_subscription").unwrap(); + let server = MockServer::start().await; + Mock::given(path("/backend-api/subscriptions")) + .respond_with(ResponseTemplate::new(500)) + .expect(0) + .mount(&server) + .await; + Mock::given(path("/api/codex/usage")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "rate_limit": { + "allowed": true, + "primary_window": { "used_percent": 20, "limit_window_seconds": 18000 } + } + }))) + .expect(2..) + .mount(&server) + .await; + let service = quota_service_with_base_url(&store, reqwest::Client::new(), server.uri()); + service.synchronize().await.unwrap(); + service.refresh_account(account.id()).await.unwrap(); + assert!( + server + .received_requests() + .await + .unwrap() + .iter() + .all(|request| request.url.path() == "/api/codex/usage") + ); +} diff --git a/backend/crates/providers/openai/tests/transport/mod.rs b/backend/crates/providers/openai/tests/transport/mod.rs index dd0c260b8..0317dfbea 100644 --- a/backend/crates/providers/openai/tests/transport/mod.rs +++ b/backend/crates/providers/openai/tests/transport/mod.rs @@ -60,6 +60,7 @@ mod profile_contract; mod protocol; mod request; mod reset_credits; +mod subscription; mod tls; mod usage; mod websocket; diff --git a/backend/crates/providers/openai/tests/transport/subscription.rs b/backend/crates/providers/openai/tests/transport/subscription.rs new file mode 100644 index 000000000..d239d1f12 --- /dev/null +++ b/backend/crates/providers/openai/tests/transport/subscription.rs @@ -0,0 +1,148 @@ +use super::{CodexBackendClient, CodexRequestContext, test_wire_profile}; +use serde_json::json; +use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, method, path, query_param}, +}; + +fn client(base: &str) -> CodexBackendClient { + CodexBackendClient::new( + reqwest::Client::builder().no_proxy().build().unwrap(), + base, + test_wire_profile(), + ) +} + +#[tokio::test] +async fn subscription_uses_exact_bound_account_and_projects_only_safe_facts() { + let server = MockServer::start().await; + let account = "personal/account + A"; + Mock::given(method("GET")) + .and(path("/backend-api/subscriptions")) + .and(query_param("account_id", account)) + .and(header("chatgpt-account-id", account)) + .and(header("authorization", "Bearer fixture")) + .and(header("accept", "application/json")) + .and(header("origin", server.uri())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id":"subscription-not-account", "plan_type":"enterprise", "secret":"ignored", + "active_start":"2019-12-02T08:00:00+08:00", + "active_until":" 2020-01-02T08:00:00+08:00 ", "will_renew":true, + "billing_period":"monthly", "billing_currency":"USD" + }))) + .expect(1) + .mount(&server) + .await; + let result = client(&format!("{}/backend-api", server.uri())) + .fetch_subscription( + CodexRequestContext::auxiliary( + "Bearer fixture", + Some(account), + "subscription_test", + None, + ), + account, + ) + .await + .unwrap(); + assert_eq!( + result.starts_at.unwrap().to_rfc3339(), + "2019-12-02T00:00:00+00:00" + ); + assert_eq!(result.billing_period.as_deref(), Some("monthly")); + assert_eq!(result.billing_currency.as_deref(), Some("USD")); + assert_eq!(result.expires_at.to_rfc3339(), "2020-01-02T00:00:00+00:00"); + assert_eq!(result.will_renew, Some(true)); +} + +#[tokio::test] +async fn subscription_failures_are_unknown_without_retry() { + for response in [ + ResponseTemplate::new(204), + ResponseTemplate::new(401), + ResponseTemplate::new(403), + ResponseTemplate::new(404), + ResponseTemplate::new(429), + ResponseTemplate::new(500), + ResponseTemplate::new(200).set_body_string("not json"), + ResponseTemplate::new(200) + .set_body_json(json!({"active_until":"invalid","will_renew":false})), + ResponseTemplate::new(200).set_body_json(json!({"will_renew":true})), + ResponseTemplate::new(200).set_body_string("x".repeat(65537)), + ResponseTemplate::new(200).set_delay(std::time::Duration::from_secs(6)), + ] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + assert!( + client(&server.uri()) + .fetch_subscription( + CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None), + "account" + ) + .await + .is_none() + ); + } +} + +#[tokio::test] +async fn subscription_unknown_renewal_is_not_false_and_empty_account_does_not_request() { + let server = MockServer::start().await; + for renewal in [json!(null), json!("false"), json!(false)] { + server.reset().await; + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(200).set_body_json( + json!({"active_until":"2026-10-01T00:00:00Z","will_renew":renewal}), + ), + ) + .expect(1) + .mount(&server) + .await; + let result = client(&server.uri()) + .fetch_subscription( + CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None), + "account", + ) + .await + .unwrap(); + assert_eq!(result.will_renew, renewal.as_bool()); + } + server.reset().await; + assert!( + client(&server.uri()) + .fetch_subscription( + CodexRequestContext::auxiliary("Bearer fixture", None, "req", None), + "" + ) + .await + .is_none() + ); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn subscription_preserves_configured_backend_path_prefix() { + let server = MockServer::start().await; + Mock::given(path("/proxy/backend-api/subscriptions")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"active_until":"2026-10-01T00:00:00Z"})), + ) + .expect(1) + .mount(&server) + .await; + assert!( + client(&format!("{}/proxy/backend-api/", server.uri())) + .fetch_subscription( + CodexRequestContext::auxiliary("Bearer fixture", Some("account"), "req", None), + "account" + ) + .await + .is_some() + ); +} diff --git a/docs/api.md b/docs/api.md index 98792dd9d..a345ea7f9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -256,7 +256,7 @@ Responses wire 之间的协议转换层,转换只在 xAI Provider 内完成。 | `GET` | `/api/admin/accounts/quota` | `accountId` | 读取当前额度,不强制访问上游 | | `GET` | `/api/admin/accounts/quota-forecast` | `accountId` | 按需读取周/月容量预测、源窗口剩余估算与采样依据,不刷新上游额度 | | `POST` | `/api/admin/accounts/quota/refresh` | `{ accountId }` | 访问 Provider 并刷新额度,同时同步额度所属状态 | -| `GET` | `/api/admin/accounts/profile-statistics` | `accountId` | 实时查询 OpenAI/Codex 官方个人资料中的累计活动与使用洞察 | +| `GET` | `/api/admin/accounts/personal-info` | `accountId` | 按需汇聚 OpenAI/Codex 官方个人资料、累计活动与订阅信息,不更新额度或 credential | | `GET` | `/api/admin/accounts/reset-credits` | `accountId` | 查询 OpenAI 上游主动额度重置卡,不读取本地库存 | | `POST` | `/api/admin/accounts/reset-credits` | `{ accountId, creditId?, redeemRequestId }` | 使用 UUIDv4 幂等键消费一张 OpenAI 上游重置卡 | | `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 | @@ -514,10 +514,26 @@ OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明 上游消耗和模型组合变化仍可能造成误差;等价 USD 费用不是官方订阅价格或固定额度承诺, 30 天折算也不是自然月额度。记录覆盖率不等于预测准确率,不输出未经校准的置信区间。 -### OpenAI 官方个人资料统计 +### OpenAI 个人信息 -`GET /api/admin/accounts/profile-statistics?accountId=...` 仅支持 OpenAI/Codex OAuth 账号。每次查询直接 -访问官方个人资料端点,不读取本地 usage/billing 记录,也不缓存或估算统计结果。响应 `data` 包含: +`GET /api/admin/accounts/personal-info?accountId=...` 需要管理员会话,当前由 OpenAI/Codex OAuth +账号提供。后端并发读取资料统计与订阅,一次返回;每次请求均重新查询,不自动重试或 +刷新 credential,不读取本地 usage/billing 记录,也不缓存或估算统计结果。 + +响应 `data` 包含: + +| 字段 | 类型 | 含义 | +| --- | --- | --- | +| `profile` | object 或 null | 官方个人资料、累计统计与活动洞察;查询失败为 null | +| `profileError` | string 或 null | 资料查询失败时的安全错误提示;成功为 null | +| `subscription` | object 或 null | 当前绑定账号的订阅周期;无可用周期或查询失败为 null | + +两部分的查询结果独立:资料失败时仍返回可用订阅,订阅失败时仍返回资料。账号不存在、查询参数不合法或 +无管理员会话时,仍返回标准错误;请求期间账号身份或 credential revision 改变时拒绝整份结果。 + +#### 官方资料与累计统计 + +`profile` 包含: - `displayName`、`username`、`imageUrl`:官方账号资料; - `summary`:累计文本 Token、单日峰值 Token、最长任务时长、当前连续天数和最长连续天数; @@ -526,7 +542,27 @@ OpenAI 复用已有限流协议解析器匹配额度桶、槽位、时长和明 以及插件与 Skill 调用排行。 官方未返回的字段保持 `null`,不使用本地数据补齐;`hasStatsError: true` 表示账号资料可用,但官方统计 -部分不可用。access token 已过期或官方返回 401 时,接口要求先刷新 credential 或重新授权。 +部分不可用。access token 已过期或官方返回 401 时,通过 `profileError` 提示先刷新 credential 或重新授权。 + +#### 订阅信息 + +后端使用当前凭据、绑定的上游账号 ID 和账号出站代理访问 `/backend-api/subscriptions?account_id=...`, +不枚举其他账号,不返回上游订阅 ID 或原始响应。 + +`subscription` 为 `null`(未获得可用订阅周期),或包含以下字段: + +| 字段 | 类型 | 含义 | +| --- | --- | --- | +| `startsAt` | RFC 3339 字符串或 null | 上游本期开始时间 | +| `expiresAt` | RFC 3339 字符串 | 上游本期结束时间,不代表自动续费账号最终失效 | +| `willRenew` | boolean 或 null | 自动续费状态;未知不推断为 false | +| `billingPeriod` | string 或 null | 上游计费周期标识 | +| `billingCurrency` | string 或 null | 上游计费币种 | +| `observedAt` | RFC 3339 字符串 | 本次查询时间 | + +订阅不写入额度快照或数据库,不参与账号状态或调度;单次上游查询最多 5 秒、响应最多 64 KiB,不重试。 +上游失败或未提供有效周期时返回未知,不据此标记免费、过期或禁用;请求期间账号身份或 credential +revision 变化时丢弃结果。 ### OpenAI 主动额度重置卡 diff --git a/docs/architecture.md b/docs/architecture.md index 9c142202c..6f3ee9aa7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -383,6 +383,12 @@ credential 与 quota 是两组独立事实:credential refresh 不等于 quota - quota refresh、正常推理返回的 rate-limit headers 和后台健康任务汇入同一额度事实;套餐只用于展示与 目录 cache 隔离,不创建套餐专属状态机。 +OpenAI 订阅周期属于按需个人信息,不是额度事实。Admin 账号用例通过现有 Provider 管理端口并发读取 +个人资料统计与订阅,汇聚为一次只读响应;Provider 继续拥有各自的认证、出站代理与上游协议处理。 +订阅不由 quota、导入或后台任务触发,不持久化。汇聚结果返回前核对账号身份与 credential revision, +避免重新授权期间展示混合身份信息;单项失败不丢弃另一项可用结果。前端只在打开「个人信息」或手动 +刷新信息时请求,关闭后取消等待,不维护两套请求状态。 + 主动额度重置是 OpenAI Provider 的不可逆上游操作:列表查询和消费都直接使用当前 Desktop 请求画像; 卡片不写 PostgreSQL/Redis。消费请求携带调用方生成的 UUIDv4 幂等键,同一账号的消费在进程内串行; 发送结果不明确时必须复用原键。确认成功后管理端再显式刷新卡片与 quota,不能直接改本地重置时间。 diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 32a819d28..e7a063d80 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -242,6 +242,15 @@ export interface AccountProfileActivityInsights { invocations: AccountProfileInvocation[] | null } +export interface AccountSubscription { + startsAt: string | null + expiresAt: string + willRenew: boolean | null + billingPeriod: string | null + billingCurrency: string | null + observedAt: string +} + export interface AccountProfileStatisticsResponse { displayName: string | null username: string | null @@ -252,6 +261,12 @@ export interface AccountProfileStatisticsResponse { activityInsights: AccountProfileActivityInsights } +export interface AccountPersonalInfoResponse { + profile: AccountProfileStatisticsResponse | null + profileError: string | null + subscription: AccountSubscription | null +} + export interface AccountResetCredit { id: string status: string | null @@ -420,9 +435,9 @@ export function recoverAccount(data: AccountIdParam, options: RequestOptions = { }) } -export function getAccountProfileStatistics(data: AccountIdParam, options: RequestOptions = {}) { - return request({ - url: '/api/admin/accounts/profile-statistics', +export function getAccountPersonalInfo(data: AccountIdParam, options: RequestOptions = {}) { + return request({ + url: '/api/admin/accounts/personal-info', method: 'GET', params: data, ...options, diff --git a/frontend/src/views/accounts/components/AccountProfileModal/Metrics.vue b/frontend/src/views/accounts/components/AccountProfileModal/Metrics.vue new file mode 100644 index 000000000..fe8c77929 --- /dev/null +++ b/frontend/src/views/accounts/components/AccountProfileModal/Metrics.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend/src/views/accounts/components/AccountProfileModal/ProfileHero.vue b/frontend/src/views/accounts/components/AccountProfileModal/ProfileHero.vue index ae2a90fff..11425072b 100644 --- a/frontend/src/views/accounts/components/AccountProfileModal/ProfileHero.vue +++ b/frontend/src/views/accounts/components/AccountProfileModal/ProfileHero.vue @@ -5,93 +5,39 @@ import { UserRound } from '@lucide/vue' import { computed, shallowRef, watch } from 'vue' import { accountProfileAvatarUrl } from '@/api' -import { formatCompactNumber, formatInteger } from '@/utils/number' import { stablePresetVisualToneClass } from '../../utils/visualTone' import AccountPlanBadge from '../AccountPlanBadge.vue' const props = defineProps<{ account: AccountRow - profile: AccountProfileStatisticsResponse + profile: AccountProfileStatisticsResponse | null }>() const imageFailed = shallowRef(false) const displayName = computed( - () => props.profile.displayName || props.profile.username || props.account.email || props.account.name || 'Codex 用户', + () => props.profile?.displayName || props.profile?.username || props.account.email || props.account.name || 'Codex 用户', ) -const username = computed(() => (props.profile.username ? `@${props.profile.username.replace(/^@/, '')}` : null)) +const username = computed(() => (props.profile?.username ? `@${props.profile?.username.replace(/^@/, '')}` : null)) const accountIdentity = computed(() => props.account.email?.trim() || props.account.accountId?.trim()) const initial = computed(() => Array.from(displayName.value.trim())[0]?.toUpperCase() || null) const avatarToneClass = computed(() => stablePresetVisualToneClass(props.account.id || accountIdentity.value || displayName.value), ) const avatarUrl = computed(() => { - const sourceUrl = props.profile.imageUrl?.trim() + const sourceUrl = props.profile?.imageUrl?.trim() return sourceUrl ? accountProfileAvatarUrl(props.account.id, sourceUrl) : null }) -const metrics = computed(() => [ - { - label: '累计 Token 数', - value: formatMetric(props.profile.summary.totalTextTokens), - title: formatExact(props.profile.summary.totalTextTokens), - }, - { - label: '峰值 Token 数', - value: formatMetric(props.profile.summary.peakTokens), - title: formatExact(props.profile.summary.peakTokens), - }, - { - label: '最长聊天时长', - value: formatDuration(props.profile.summary.longestTaskDurationMs), - }, - { - label: '当前连续天数', - value: formatDays(props.profile.summary.currentStreakDays), - }, - { - label: '最长连续天数', - value: formatDays(props.profile.summary.longestStreakDays), - }, -]) - watch( avatarUrl, () => { imageFailed.value = false }, ) - -function formatMetric(value: number | null) { - return value === null ? '—' : formatCompactNumber(value) -} - -function formatExact(value: number | null) { - return value === null ? undefined : `${formatInteger(value)} Tokens` -} - -function formatDays(value: number | null) { - return value === null ? '—' : `${formatInteger(value)} 天` -} - -function formatDuration(value: number | null) { - if (value === null) - return '—' - const totalMinutes = Math.floor(value / 60_000) - const days = Math.floor(totalMinutes / (24 * 60)) - const hours = Math.floor((totalMinutes % (24 * 60)) / 60) - const minutes = totalMinutes % 60 - if (days > 0) - return hours > 0 ? `${days} 天 ${hours} 小时` : `${days} 天` - if (hours > 0) - return minutes > 0 ? `${hours} 小时 ${minutes} 分` : `${hours} 小时` - if (totalMinutes > 0) - return `${totalMinutes} 分` - return `${Math.max(0, Math.floor(value / 1000))} 秒` -} diff --git a/frontend/src/views/accounts/components/AccountProfileModal/Skeleton.vue b/frontend/src/views/accounts/components/AccountProfileModal/Skeleton.vue index bc9508d31..508dcdc90 100644 --- a/frontend/src/views/accounts/components/AccountProfileModal/Skeleton.vue +++ b/frontend/src/views/accounts/components/AccountProfileModal/Skeleton.vue @@ -4,34 +4,9 @@ import BaseSkeleton from '@/components/base/BaseSkeleton.vue'