feat(accounts): aggregate personal information on demand (#83)

Load profile statistics and OpenAI subscription information concurrently through one on-demand admin endpoint. Keep subscription queries out of quota refreshes, preserve partial results and account identity checks, and show subscriptions in the approved personal-information dialog layout.

Update the current API and architecture contracts while leaving README unchanged.

Co-authored-by: Yanshan Chen <16632048+chenyanshan@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Yanshan Chen
2026-09-14 15:48:12 +08:00
committed by GitHub
co-authored by Yanshan Chen Codex
parent c9be17778d
commit 539764f7eb
32 changed files with 1172 additions and 245 deletions
@@ -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<DateTime<Utc>>,
pub expires_at: DateTime<Utc>,
pub will_renew: Option<bool>,
pub billing_period: Option<String>,
pub billing_currency: Option<String>,
pub observed_at: DateTime<Utc>,
}
/// 按需汇聚的个人信息;资料查询失败不丢弃可用的订阅结果。
#[derive(Debug, Clone, PartialEq)]
pub struct AccountPersonalInfo {
pub profile: Result<ProviderProfileStatistics, AdminError>,
pub subscription: Option<ProviderSubscription>,
}
/// Provider 官方个人资料中的累计摘要。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderProfileStatisticsSummary {
@@ -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<Option<ProviderSubscription>, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
async fn profile_statistics(
&self,
_account_id: &ProviderAccountId,
@@ -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<AccountQuotaForecastReport, AdminError>;
async fn profile_statistics(
async fn personal_info(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderProfileStatistics, AdminError> {
Err(AdminError::invalid("当前 Provider 不支持账号统计"))
) -> Result<AccountPersonalInfo, AdminError> {
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<ProviderProfileStatistics, AdminError> {
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<AccountPersonalInfo, AdminError> {
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(
@@ -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<Option<(Arc<FakeAccountStore>, AccountRecord)>>,
current_credential_revision: Mutex<Revision>,
reset_credit_commands: Mutex<Vec<ConsumeProviderResetCredit>>,
profile_result: Mutex<Result<ProviderProfileStatistics, ProviderAdminErrorKind>>,
subscription_result: Mutex<Result<Option<ProviderSubscription>, ProviderAdminErrorKind>>,
personal_info_barrier: Mutex<Option<Arc<tokio::sync::Barrier>>>,
}
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<ProviderProfileStatistics, ProviderAdminError> {
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<Option<ProviderSubscription>, 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<FakeProviderAdmin>,
store: Arc<FakeAccountStore>,
@@ -23,12 +23,12 @@ where
)
.route("/api/admin/accounts/quota", get(account_quota::<S>))
.route(
"/api/admin/accounts/quota-forecast",
get(account_quota_forecast::<S>),
"/api/admin/accounts/personal-info",
get(account_personal_info::<S>),
)
.route(
"/api/admin/accounts/profile-statistics",
get(account_profile_statistics::<S>),
"/api/admin/accounts/quota-forecast",
get(account_quota_forecast::<S>),
)
.route(
"/api/admin/accounts/profile-avatar",
@@ -389,7 +389,7 @@ where
))
}
async fn account_profile_statistics<S>(
async fn account_personal_info<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
@@ -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)),
))
}
@@ -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::{
@@ -653,6 +653,54 @@ pub struct AccountQuotaData {
pub account: AccountView,
}
/// 个人资料、累计统计与订阅的统一响应。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountPersonalInfoData {
pub profile: Option<AccountProfileStatisticsData>,
pub profile_error: Option<String>,
pub subscription: Option<AccountSubscriptionData>,
}
impl From<AccountPersonalInfo> 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<String>,
pub expires_at: String,
pub will_renew: Option<bool>,
pub billing_period: Option<String>,
pub billing_currency: Option<String>,
pub observed_at: String,
}
impl From<ProviderSubscription> 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")]
@@ -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::<AdminTestState>()
.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;
@@ -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;
+22 -1
View File
@@ -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<Option<ProviderSubscription>, 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,
@@ -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<Option<CodexSubscription>, 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,
@@ -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;
@@ -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<DateTime<Utc>>,
pub expires_at: DateTime<Utc>,
pub will_renew: Option<bool>,
pub billing_period: Option<String>,
pub billing_currency: Option<String>,
pub observed_at: DateTime<Utc>,
}
impl CodexBackendClient {
/// 可选展示查询失败只返回未知,不改变凭据或额度状态,也不重试。
pub async fn fetch_subscription(
&self,
context: CodexRequestContext<'_>,
account_id: &str,
) -> Option<CodexSubscription> {
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<String> {
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)
}
@@ -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());
}
@@ -5,6 +5,7 @@ mod refresh_timing;
mod scheduling;
mod slots;
mod snapshot;
mod subscription;
use std::sync::Arc;
use std::time::SystemTime;
@@ -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")
);
}
@@ -60,6 +60,7 @@ mod profile_contract;
mod protocol;
mod request;
mod reset_credits;
mod subscription;
mod tls;
mod usage;
mod websocket;
@@ -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()
);
}
+41 -5
View File
@@ -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 主动额度重置卡
+6
View File
@@ -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,不能直接改本地重置时间。
+18 -3
View File
@@ -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<AccountProfileStatisticsResponse>({
url: '/api/admin/accounts/profile-statistics',
export function getAccountPersonalInfo(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountPersonalInfoResponse>({
url: '/api/admin/accounts/personal-info',
method: 'GET',
params: data,
...options,
@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { AccountProfileStatisticsResponse } from '@/api'
import { computed } from 'vue'
import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
import { formatCompactNumber, formatInteger } from '@/utils/number'
const props = defineProps<{
profile: AccountProfileStatisticsResponse | null
loading: boolean
}>()
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),
},
])
function formatMetric(value: number | null | undefined) {
return value == null ? '—' : formatCompactNumber(value)
}
function formatExact(value: number | null | undefined) {
return value == null ? undefined : `${formatInteger(value)} Tokens`
}
function formatDays(value: number | null | undefined) {
return value == null ? '—' : `${formatInteger(value)} 天`
}
function formatDuration(value: number | null | undefined) {
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))} 秒`
}
</script>
<template>
<section class="min-w-0 overflow-x-auto py-1" aria-label="累计活动摘要" :aria-busy="loading">
<dl class="grid min-w-130 grid-cols-5">
<div v-for="metric in metrics" :key="metric.label" class="min-w-0 px-2 text-center sm:px-3">
<dd class="m-0 font-mono text-cp-lg leading-tight font-heavy tabular-nums text-cp-text" :title="metric.title">
<BaseSkeleton v-if="loading && !profile" shape="text" class="mx-auto h-4 w-14" />
<template v-else>
{{ metric.value }}
</template>
</dd>
<dt class="mt-1.5 text-cp-xs font-semibold text-cp-text-secondary">
{{ metric.label }}
</dt>
</div>
</dl>
</section>
</template>
@@ -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))} 秒`
}
</script>
<template>
<section
class="grid gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center lg:gap-4"
class="flex min-w-0 items-center"
aria-labelledby="profile-display-name"
>
<div class="flex min-w-0 items-center gap-3.5">
@@ -127,18 +73,5 @@ function formatDuration(value: number | null) {
</div>
</div>
</div>
<div class="min-w-0 overflow-x-auto py-1">
<dl class="grid min-w-130 grid-cols-5">
<div v-for="metric in metrics" :key="metric.label" class="min-w-0 px-2 text-center sm:px-3">
<dd class="m-0 font-mono text-cp-lg leading-tight font-heavy tabular-nums text-cp-text" :title="metric.title">
{{ metric.value }}
</dd>
<dt class="mt-1.5 text-cp-xs font-semibold text-cp-text-secondary">
{{ metric.label }}
</dt>
</div>
</dl>
</div>
</section>
</template>
@@ -4,34 +4,9 @@ import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
<template>
<div class="grid gap-8 sm:gap-10" role="status" aria-live="polite" aria-busy="true">
<span class="sr-only">正在加载官方个人资料</span>
<span class="sr-only">正在加载累计活动</span>
<section
class="grid gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center lg:gap-4"
>
<div class="flex min-w-0 items-center gap-3.5">
<BaseSkeleton shape="circle" class="size-14 shrink-0" />
<div class="grid min-w-0 flex-1 gap-1.5">
<div class="flex items-center gap-2">
<BaseSkeleton shape="text" class="h-4 w-20" />
<BaseSkeleton class="h-5 w-9 rounded-full opacity-70" />
</div>
<BaseSkeleton shape="text" class="w-24 opacity-60" />
<BaseSkeleton shape="text" class="w-full max-w-64 opacity-60" />
</div>
</div>
<div class="min-w-0 overflow-x-auto py-1">
<div class="grid min-w-130 grid-cols-5">
<div v-for="metric in 5" :key="metric" class="grid justify-items-center gap-2 px-2 sm:px-3">
<BaseSkeleton shape="text" class="h-4 w-14" />
<BaseSkeleton shape="text" class="w-20 opacity-60" />
</div>
</div>
</div>
</section>
<section class="grid gap-4">
<section class="grid min-w-0 gap-4 overflow-hidden">
<div class="flex items-center justify-between">
<BaseSkeleton shape="text" class="h-4 w-20" />
<BaseSkeleton class="h-7 w-32 rounded-cp opacity-70" />
@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { AccountSubscription } from '@/api'
import { computed } from 'vue'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
subscription: AccountSubscription
}>()
const renewal = computed(() => {
if (props.subscription?.willRenew === true)
return '自动续费'
if (props.subscription?.willRenew === false)
return '不自动续费'
return '未知'
})
const billingPeriod = computed(() => {
const period = props.subscription?.billingPeriod
if (!period)
return null
const labels: Record<string, string> = { month: '按月', monthly: '按月', year: '按年', yearly: '按年', annual: '按年' }
return labels[period.toLowerCase()] ?? period
})
const billing = computed(() => [billingPeriod.value, props.subscription?.billingCurrency].filter(Boolean).join(' · ') || '未知')
const dates = computed(() => [
{ label: '订阅开始', value: props.subscription?.startsAt },
{ label: '订阅结束', value: props.subscription?.expiresAt },
])
</script>
<template>
<section class="min-w-0 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5" aria-label="订阅">
<dl class="m-0 grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-[1.3fr_1.3fr_1fr_1fr]">
<div v-for="date in dates" :key="date.label" class="min-w-0">
<dt class="text-cp-xs text-cp-text-secondary">
{{ date.label }}
</dt>
<dd class="m-0 mt-1.5 font-mono text-cp-sm leading-relaxed tabular-nums text-cp-text">
<time v-if="date.value" :datetime="date.value">{{ formatDateTime(date.value, '未知', 'Asia/Shanghai') }}</time>
<span v-else>未知</span>
</dd>
</div>
<div class="min-w-0">
<dt class="text-cp-xs text-cp-text-secondary">
续费方式
</dt>
<dd class="m-0 mt-1.5 text-cp-sm leading-relaxed" :class="subscription.willRenew === true ? 'text-cp-success' : 'text-cp-text'">
{{ renewal }}
</dd>
</div>
<div class="min-w-0">
<dt class="text-cp-xs text-cp-text-secondary">
计费
</dt>
<dd class="m-0 mt-1.5 text-cp-sm leading-relaxed break-words text-cp-text">
{{ billing }}
</dd>
</div>
</dl>
</section>
</template>
@@ -34,7 +34,7 @@ const monthLabels = computed(() =>
</script>
<template>
<section aria-labelledby="profile-token-activity-title">
<section class="min-w-0" aria-labelledby="profile-token-activity-title">
<div class="mb-4 flex flex-wrap items-end justify-between gap-3">
<div>
<h3 id="profile-token-activity-title" class="m-0 text-cp-lg font-heavy text-cp-text">
@@ -47,7 +47,7 @@ const monthLabels = computed(() =>
<BaseEmpty
v-if="dailyUsage === null"
title="暂无 Token 活动"
description="官方个人资料没有返回每日 Token 活动。"
description="本次未获取到每日 Token 活动。"
size="sm"
surface="none"
/>
@@ -55,10 +55,11 @@ const monthLabels = computed(() =>
<div v-else class="overflow-x-auto pb-1" role="img" :aria-label="`Token 活动热力图,${rangeLabel}`">
<div class="min-w-190">
<div class="mb-1.5 grid gap-1" style="grid-template-columns: repeat(52, minmax(0, 1fr))">
<!-- 首尾月份可能只占一周;标签固定在同一行,避免跨列占位触发自动换行。 -->
<span
v-for="(month, monthIndex) in monthLabels"
:key="`${month.weekIndex}-${month.label}`"
class="h-4 whitespace-nowrap text-[10px] leading-4 font-semibold text-cp-text-quaternary"
class="row-start-1 h-4 whitespace-nowrap text-[10px] leading-4 font-semibold text-cp-text-quaternary"
:class="monthIndex === monthLabels.length - 1 ? 'text-right' : undefined"
:style="{
gridColumn: `${month.weekIndex + (monthIndex === monthLabels.length - 1 ? 0 : 1)} / span 2`,
@@ -6,72 +6,69 @@ import { toRef } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { useAccountProfileStatistics } from '../../composables/useAccountProfileStatistics'
import { useAccountPersonalInfo } from '../../composables/useAccountPersonalInfo'
import AccountProfileActivityInsights from './ActivityInsights.vue'
import AccountProfileMetrics from './Metrics.vue'
import AccountProfileHero from './ProfileHero.vue'
import AccountProfileSkeleton from './Skeleton.vue'
import AccountSubscription from './Subscription.vue'
import AccountProfileTokenActivity from './TokenActivity.vue'
const props = defineProps<{
account: AccountRow
}>()
const props = defineProps<{ account: AccountRow }>()
const open = defineModel<boolean>({ required: true })
const accountId = toRef(() => props.account.id)
const { profile, loading, error, load } = useAccountProfileStatistics(accountId, open)
const { profile, subscription, loading, error, load } = useAccountPersonalInfo(accountId, open)
</script>
<template>
<BaseModal
v-model="open"
title="个人资料"
description="来自 Codex 官方个人资料的累计活动与使用洞察"
size="xl"
>
<AccountProfileSkeleton v-if="loading && !profile" />
<BaseModal v-model="open" title="个人信息" size="xl">
<div class="flex min-h-0 min-w-0 flex-col gap-6 pb-2">
<div class="grid min-w-0 grid-cols-1 gap-4 rounded-cp-lg bg-cp-fill-alter p-4 sm:p-5 lg:grid-cols-[minmax(310px,1.05fr)_minmax(0,1.95fr)] lg:items-center">
<AccountProfileHero :account="account" :profile="profile" />
<AccountProfileMetrics :profile="profile" :loading="loading" />
</div>
<BaseEmpty
v-else-if="error && !profile"
title="个人资料加载失败"
:description="error"
:icon="TriangleAlert"
surface="none"
>
<template #action>
<BaseButton size="sm" :loading="loading" @click="load(true)">
重试
</BaseButton>
</template>
</BaseEmpty>
<div v-else-if="profile" class="flex min-h-0 flex-col gap-8 pb-2 sm:gap-10">
<AccountProfileHero :account="account" :profile="profile" />
<section v-if="subscription" class="grid min-w-0 grid-cols-1 gap-4" aria-labelledby="profile-subscription-title">
<h3 id="profile-subscription-title" class="m-0 text-cp font-heavy text-cp-text">
订阅信息
</h3>
<AccountSubscription :subscription="subscription" />
</section>
<AccountProfileSkeleton v-if="loading && !profile" />
<BaseEmpty
v-if="profile.hasStatsError"
title="个人统计暂不可用"
description="官方个人资料已加载,但本次没有返回统计数据。"
v-else-if="error && !profile"
title="个人资料加载失败"
:description="error"
:icon="TriangleAlert"
surface="none"
/>
<template v-else>
<AccountProfileTokenActivity :daily-usage="profile.dailyUsage" />
<AccountProfileActivityInsights :insights="profile.activityInsights" />
<template v-else-if="profile">
<BaseEmpty
v-if="profile.hasStatsError"
title="个人统计暂不可用"
description="本次未获取到统计数据,可以点击刷新信息重试。"
:icon="TriangleAlert"
surface="none"
/>
<template v-else>
<AccountProfileTokenActivity :daily-usage="profile.dailyUsage" />
<AccountProfileActivityInsights :insights="profile.activityInsights" />
</template>
<p v-if="error" role="status" class="m-0 text-cp-sm text-cp-warning">
本次信息刷新未完成,保留已获取的结果。
</p>
</template>
</div>
<template #footer>
<BaseButton variant="secondary" @click="open = false">
关闭
</BaseButton>
<BaseButton :loading="loading" @click="load(true)">
<template #loading>
<RefreshCw class="size-4 animate-spin motion-reduce:animate-none" />
</template>
<BaseButton :aria-busy="loading" :aria-disabled="loading" @click="load">
<template #icon>
<RefreshCw class="size-4" />
<RefreshCw class="size-4" :class="loading ? 'animate-spin motion-reduce:animate-none' : undefined" />
</template>
刷新资料
刷新信息
</BaseButton>
</template>
</BaseModal>
@@ -48,7 +48,7 @@ const profileOpen = shallowRef(false)
<div class="flex shrink-0 items-center gap-0.5">
<BaseIconButton
v-if="account.provider === 'openai'"
label="查看个人资料"
label="查看个人信息"
size="sm"
variant="ghost"
:pressed="profileOpen"
@@ -0,0 +1,53 @@
import type { Ref } from 'vue'
import type { AccountPersonalInfoResponse } from '@/api'
import { computed, shallowRef, watch } from 'vue'
import { getAccountPersonalInfo } from '@/api'
import { useRequestState } from '@/composables/useRequestState'
export function useAccountPersonalInfo(accountId: Ref<string>, open: Ref<boolean>) {
const info = shallowRef<AccountPersonalInfoResponse | null>(null)
const request = useRequestState()
const { loading } = request
const profile = computed(() => info.value?.profile ?? null)
const subscription = computed(() => info.value?.subscription ?? null)
const error = computed(() => request.error.value || info.value?.profileError || '')
async function load() {
const targetAccountId = accountId.value
if (!open.value || !targetAccountId || loading.value)
return
const version = request.start()
try {
const result = await getAccountPersonalInfo({ accountId: targetAccountId }, { signal: request.signal })
if (!request.isCurrent(version))
return
// 单项失败仍更新其他信息;仅在当前打开周期保留上次可用的统计。
info.value = { ...result, profile: result.profile ?? info.value?.profile ?? null }
}
catch (cause) {
request.fail(version, cause)
}
finally {
request.finish(version)
}
}
// 打开或切换账号只请求一次;关闭取消等待,刷新按钮复用同一入口。
watch([open, accountId], ([isOpen]) => {
request.invalidate()
info.value = null
request.error.value = ''
if (isOpen)
void load()
}, { immediate: true })
return {
profile,
subscription,
loading,
error,
load,
}
}
@@ -1,57 +0,0 @@
import type { Ref } from 'vue'
import { shallowRef, watch } from 'vue'
import { getAccountProfileStatistics } from '@/api'
import { useRequestState } from '@/composables/useRequestState'
export function useAccountProfileStatistics(accountId: Ref<string>, open: Ref<boolean>) {
const profile = shallowRef<Awaited<ReturnType<typeof getAccountProfileStatistics>> | null>(null)
const loadedAccountId = shallowRef('')
const request = useRequestState()
const { loading, error } = request
async function load(force = false) {
const targetAccountId = accountId.value
if (!targetAccountId)
return
if (!force && profile.value && loadedAccountId.value === targetAccountId)
return
const version = request.start()
try {
const result = await getAccountProfileStatistics({ accountId: targetAccountId }, { signal: request.signal })
if (!request.isCurrent(version))
return
profile.value = result
loadedAccountId.value = targetAccountId
}
catch (cause) {
request.fail(version, cause)
}
finally {
request.finish(version)
}
}
watch([open, accountId], ([isOpen, nextAccountId], previousValues) => {
const previousAccountId = previousValues?.[1]
if (nextAccountId !== previousAccountId) {
request.invalidate()
profile.value = null
loadedAccountId.value = ''
loading.value = false
error.value = ''
}
if (isOpen)
void load()
else
request.invalidate()
}, { immediate: true })
return {
profile,
loading,
error,
load,
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ export const accountColumns = defineTableColumns<AccountRow>([
},
{
key: 'accessTokenExpiresAtDisplay',
label: '过期时间',
label: '令牌过期',
kind: 'datetime',
sortable: 'expiresAt',
format: value => optionalAccountCell(value),
@@ -61,7 +61,7 @@ export function buildProfileActivityGrid(
})
weeks.push({
key: dateKey(weekStart),
monthLabel: monthLabel(weekDates, weekIndex),
monthLabel: monthLabel(weekDates),
cells,
})
}
@@ -117,12 +117,10 @@ function activityLevel(value: number, maxValue: number): ProfileActivityLevel {
return Math.min(4, Math.max(1, Math.ceil(value / maxValue * 4))) as ProfileActivityLevel
}
function monthLabel(weekDates: Date[], weekIndex: number) {
function monthLabel(weekDates: Date[]) {
const firstDayOfMonth = weekDates.find(date => date.getUTCDate() === 1)
if (weekIndex !== 0 && !firstDayOfMonth)
return null
const date = firstDayOfMonth ?? weekDates[0]
return `${(date?.getUTCMonth() ?? 0) + 1}月`
// 窗口开头不补不完整月份的标签,避免与紧接着的月份挤在一起。
return firstDayOfMonth ? `${firstDayOfMonth.getUTCMonth() + 1}月` : null
}
function localCalendarDate(value: Date) {