mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
co-authored by
Yanshan Chen
Codex
parent
c9be17778d
commit
539764f7eb
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 主动额度重置卡
|
||||
|
||||
|
||||
@@ -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,不能直接改本地重置时间。
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user