diff --git a/backend/crates/gateway-admin/src/model/accounts.rs b/backend/crates/gateway-admin/src/model/accounts.rs index 7c4636d77..6ddba7c75 100644 --- a/backend/crates/gateway-admin/src/model/accounts.rs +++ b/backend/crates/gateway-admin/src/model/accounts.rs @@ -13,15 +13,142 @@ use super::{PageSize, Revision, observability::TimeRange}; pub use gateway_core::engine::credential::AccountAvailability; /// 管理页使用的归一化账号状态。 +/// +/// 只回答两个问题:是否可调度、是否需要人工干预。具体错误原因不进入此枚举, +/// 由 [`AccountErrorReason`] 承载;新增原因只扩展 reason,不改变分类。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountStatus { - Active, - RateLimited, + /// 正常,可调度。 + Normal, + /// 配额耗尽(持久化终态),等待额度恢复,无需干预。 QuotaExhausted, - Expired, - Invalid, + /// 限流中(quota 快照级信号),自动恢复,无需干预。 + RateLimited, + /// 管理员手动停用(`enabled = false`)。 Disabled, + /// 错误(过期 / 失效 / 封禁等),需要人工干预。具体原因见 [`AccountErrorReason`]。 + Error, +} + +impl AccountStatus { + /// 返回稳定 wire 值。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::QuotaExhausted => "quota_exhausted", + Self::RateLimited => "rate_limited", + Self::Disabled => "disabled", + Self::Error => "error", + } + } + + /// 解析稳定 wire 值。 + #[must_use] + pub fn parse(value: &str) -> Option { + match value { + "normal" => Some(Self::Normal), + "quota_exhausted" => Some(Self::QuotaExhausted), + "rate_limited" => Some(Self::RateLimited), + "disabled" => Some(Self::Disabled), + "error" => Some(Self::Error), + _ => None, + } + } +} + +/// 状态派生所需的最小事实;由 store / use_case 各自构造,派生规则唯一。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AccountStatusSignals { + pub enabled: bool, + pub availability: AccountAvailability, + pub access_token_expired: bool, + pub quota_limit_reached: bool, +} + +impl AccountStatusSignals { + /// 派生产出运营分类。终态优先于瞬时信号:停用 > 配额耗尽 > 错误 > 限流。 + #[must_use] + pub const fn derive(self) -> AccountStatus { + if !self.enabled { + AccountStatus::Disabled + } else if matches!(self.availability, AccountAvailability::QuotaExhausted) { + AccountStatus::QuotaExhausted + } else if self.access_token_expired + || matches!( + self.availability, + AccountAvailability::Expired + | AccountAvailability::Invalid + | AccountAvailability::Banned + | AccountAvailability::Unknown + ) + { + AccountStatus::Error + } else if self.quota_limit_reached { + AccountStatus::RateLimited + } else { + AccountStatus::Normal + } + } +} + +/// 错误分类下的具体原因;仅在 `AccountStatus::Error` 时存在。 +/// +/// 受控枚举用于展示与聚合,扩展不改变状态分类。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountErrorReason { + Expired, + TokenExpired, + Invalid, Banned, + Unknown, +} + +impl AccountErrorReason { + /// 返回稳定 wire 值。 + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Expired => "expired", + Self::TokenExpired => "token_expired", + Self::Invalid => "invalid", + Self::Banned => "banned", + Self::Unknown => "unknown", + } + } + + /// 解析稳定 wire 值。 + #[must_use] + pub fn parse(value: &str) -> Option { + match value { + "expired" => Some(Self::Expired), + "token_expired" => Some(Self::TokenExpired), + "invalid" => Some(Self::Invalid), + "banned" => Some(Self::Banned), + "unknown" => Some(Self::Unknown), + _ => None, + } + } +} + +/// 由信号派生错误原因;非错误分类(正常 / 配额耗尽 / 限流 / 停用)返回 `None`。 +/// +/// 与 [`AccountStatusSignals::derive`] 保持同一判定:`Error` 当且仅当此处返回 `Some`。 +#[must_use] +pub fn derive_error_reason(signals: AccountStatusSignals) -> Option { + if !signals.enabled { + return None; + } + match signals.availability { + AccountAvailability::QuotaExhausted => None, + AccountAvailability::Ready => signals + .access_token_expired + .then_some(AccountErrorReason::TokenExpired), + AccountAvailability::Expired => Some(AccountErrorReason::Expired), + AccountAvailability::Invalid => Some(AccountErrorReason::Invalid), + AccountAvailability::Banned => Some(AccountErrorReason::Banned), + AccountAvailability::Unknown => Some(AccountErrorReason::Unknown), + } } /// 账号列表排序字段。 @@ -78,6 +205,8 @@ pub struct AccountRecord { pub enabled: bool, pub availability: AccountAvailability, pub availability_observed_at: DateTime, + /// 最近一次非 ready 状态变更的原始原因(上游错误原文 / 原因码);恢复 ready 后清空。 + pub last_error_message: Option, pub quota_observed_at: Option>, pub created_at: DateTime, pub updated_at: DateTime, @@ -170,14 +299,15 @@ pub struct AccountPage { /// 统一账号目录的全局状态计数,不受当前筛选和分页影响。 /// -/// 配额耗尽与限流中是可恢复状态,分别计数;`unavailable` 只包含过期、失效、禁用和封禁账号。 +/// 计数与 [`AccountStatus`] 一一对应,由 store 按派生状态聚合。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AccountSummary { pub total: u64, - pub active: u64, + pub normal: u64, pub quota_exhausted: u64, pub rate_limited: u64, - pub unavailable: u64, + pub disabled: u64, + pub error: u64, } /// 账号启停写入命令。 diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index 36e6e4b5d..735bb870d 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -11,7 +11,10 @@ use gateway_core::{ use super::{ AdminError, MutationActor, MutationContext, PageSize, Revision, - accounts::{AccountAvailability, AccountRecord, AccountStatus, AccountSummary, AccountUsage}, + accounts::{ + AccountAvailability, AccountErrorReason, AccountRecord, AccountStatus, AccountSummary, + AccountUsage, + }, }; /// Provider-owned JSON;公共层只搬运且 Debug 不输出值。 @@ -688,6 +691,8 @@ impl fmt::Debug for ProviderExport { pub struct AccountDirectoryItem { pub account: AccountRecord, pub status: AccountStatus, + /// `status == Error` 时的具体原因;其余状态为 `None`。 + pub error_reason: Option, pub usage: Option, pub quota: ProviderQuota, } diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index b729a4707..cc3ce60db 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -18,7 +18,8 @@ use crate::{ AdminError, MutationContext, accounts::{ AccountConnectionTestEvent, AccountConnectionTestEventStream, AccountListQuery, - AccountRecord, AccountStatus, AccountUsageWindowQuery, + AccountRecord, AccountStatus, AccountStatusSignals, AccountUsageWindowQuery, + derive_error_reason, }, observability::TimeRange, provider_credentials::{ @@ -216,8 +217,10 @@ impl DefaultAccountsService { std::slice::from_mut(&mut quota), ) .await?; + let signals = account_signals(&account, quota.limit_reached, now); Ok(AccountDirectoryItem { - status: account_status(&account, quota.limit_reached, now), + status: signals.derive(), + error_reason: derive_error_reason(signals), usage: usage.into_iter().next(), account, quota, @@ -301,11 +304,15 @@ impl AccountsService for DefaultAccountsService { .items .into_iter() .zip(quotas) - .map(|(account, quota)| AccountDirectoryItem { - status: account_status(&account, quota.limit_reached, now), - usage: usage.get(&account.id).cloned(), - account, - quota, + .map(|(account, quota)| { + let signals = account_signals(&account, quota.limit_reached, now); + AccountDirectoryItem { + status: signals.derive(), + error_reason: derive_error_reason(signals), + usage: usage.get(&account.id).cloned(), + account, + quota, + } }) .collect(); Ok(AccountDirectoryPage { @@ -516,39 +523,29 @@ fn empty_quota() -> ProviderQuota { } } -/// 互斥页面状态:availability 终态优先,quota 快照级 `limit_reached` 派生 -/// `rate_limited`,`Unknown` 保守映射 `invalid`(不可调度,不显示为 active)。 +/// 构造状态派生所需的信号快照。 +fn account_signals( + account: &AccountRecord, + quota_limit_reached: bool, + now: chrono::DateTime, +) -> AccountStatusSignals { + AccountStatusSignals { + enabled: account.enabled, + availability: account.availability, + access_token_expired: account + .access_token_expires_at + .is_some_and(|expires_at| expires_at <= now), + quota_limit_reached, + } +} + +/// 账号运营分类(互斥展示状态)。 fn account_status( account: &AccountRecord, quota_limit_reached: bool, now: chrono::DateTime, ) -> AccountStatus { - if !account.enabled { - AccountStatus::Disabled - } else if account.availability == crate::model::accounts::AccountAvailability::Banned { - AccountStatus::Banned - } else if account.availability == crate::model::accounts::AccountAvailability::QuotaExhausted { - AccountStatus::QuotaExhausted - } else if matches!( - account.availability, - crate::model::accounts::AccountAvailability::Invalid - ) { - AccountStatus::Invalid - } else if matches!( - account.availability, - crate::model::accounts::AccountAvailability::Expired - ) || account - .access_token_expires_at - .is_some_and(|expires_at| expires_at <= now) - { - AccountStatus::Expired - } else if quota_limit_reached { - AccountStatus::RateLimited - } else if account.availability == crate::model::accounts::AccountAvailability::Unknown { - AccountStatus::Invalid - } else { - AccountStatus::Active - } + account_signals(account, quota_limit_reached, now).derive() } fn retained_usage_range(now: chrono::DateTime, retention_days: u32) -> TimeRange { diff --git a/backend/crates/gateway-admin/tests/use_case/accounts.rs b/backend/crates/gateway-admin/tests/use_case/accounts.rs index 3cb8bdb5f..76a21528c 100644 --- a/backend/crates/gateway-admin/tests/use_case/accounts.rs +++ b/backend/crates/gateway-admin/tests/use_case/accounts.rs @@ -451,10 +451,11 @@ impl AccountStore for FakeAccountStore { total: accounts.len() as u64, summary: AccountSummary { total: accounts.len() as u64, - active: 1, + normal: 1, quota_exhausted: 0, rate_limited: 0, - unavailable: 0, + disabled: 0, + error: 0, }, }) } @@ -845,7 +846,7 @@ async fn connection_test_should_probe_unavailable_account() { } #[tokio::test] -async fn connection_test_rate_limited_probe_preserves_active_account_status() { +async fn connection_test_rate_limited_probe_preserves_normal_account_status() { let events = events(); let provider = FakeProviderAdmin::new("xai", events.clone()); let store = FakeAccountStore::new("xai", events); @@ -868,7 +869,7 @@ async fn connection_test_rate_limited_probe_preserves_active_account_status() { assert!(matches!( events.last(), Some(AccountConnectionTestEvent::Failed { - account_status: gateway_admin::model::accounts::AccountStatus::Active, + account_status: gateway_admin::model::accounts::AccountStatus::Normal, .. }) )); @@ -949,7 +950,7 @@ async fn accounts_refresh_should_keep_guard_through_store_commit() { assert_eq!(result.account.account.provider_kind.as_str(), "openai"); assert_eq!( result.account.status, - gateway_admin::model::accounts::AccountStatus::Active + gateway_admin::model::accounts::AccountStatus::Normal ); assert_eq!( @@ -985,12 +986,12 @@ async fn accounts_list_should_return_complete_directory_semantics() { .expect("complete account directory"); assert_eq!(page.summary.total, 1); - assert_eq!(page.summary.active, 1); + assert_eq!(page.summary.normal, 1); let account = page.items.first().expect("account item"); assert_eq!(account.account.provider_kind.as_str(), "openai"); assert_eq!( account.status, - gateway_admin::model::accounts::AccountStatus::Active + gateway_admin::model::accounts::AccountStatus::Normal ); } @@ -1089,8 +1090,8 @@ async fn accounts_list_should_prefer_quota_exhaustion_over_stale_token_expiry() } #[tokio::test] -async fn accounts_list_should_map_unknown_availability_to_invalid_not_active() { - // Unknown 不可调度,Admin 不得显示为 active。 +async fn accounts_list_should_map_unknown_availability_to_error_not_normal() { + // Unknown 不可调度,Admin 不得显示为 normal。 let provider = FakeProviderAdmin::new("openai", events()); let mut account = account_record("openai"); account.availability = AccountAvailability::Unknown; @@ -1112,7 +1113,7 @@ async fn accounts_list_should_map_unknown_availability_to_invalid_not_active() { assert_eq!( page.items.first().expect("account item").status, - gateway_admin::model::accounts::AccountStatus::Invalid, + gateway_admin::model::accounts::AccountStatus::Error, ); } @@ -1333,6 +1334,7 @@ pub(super) fn account_record(kind: &str) -> AccountRecord { enabled: true, availability: AccountAvailability::Ready, availability_observed_at: now, + last_error_message: None, quota_observed_at: None, created_at: now, updated_at: now, diff --git a/backend/crates/gateway-api/src/admin/accounts.rs b/backend/crates/gateway-api/src/admin/accounts.rs index 56bc339b1..e1c08727e 100644 --- a/backend/crates/gateway-api/src/admin/accounts.rs +++ b/backend/crates/gateway-api/src/admin/accounts.rs @@ -91,7 +91,8 @@ impl ListQuery { let status = match self.status.as_deref().map(str::trim) { None | Some("") => None, Some(value) => Some( - parse_account_status(value).ok_or_else(|| WireValidationError::new("status"))?, + DomainAccountStatus::parse(&value.to_ascii_lowercase()) + .ok_or_else(|| WireValidationError::new("status"))?, ), }; let sort = match (self.sort_by.as_deref(), self.sort_direction.as_deref()) { @@ -127,19 +128,6 @@ fn parse_provider(value: &str) -> Result, WireValidationErr .map_err(|_| WireValidationError::new("provider")) } -fn parse_account_status(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "active" => Some(DomainAccountStatus::Active), - "rate_limited" => Some(DomainAccountStatus::RateLimited), - "expired" => Some(DomainAccountStatus::Expired), - "invalid" => Some(DomainAccountStatus::Invalid), - "quota_exhausted" => Some(DomainAccountStatus::QuotaExhausted), - "disabled" => Some(DomainAccountStatus::Disabled), - "banned" => Some(DomainAccountStatus::Banned), - _ => None, - } -} - fn parse_sort_field(value: &str) -> Option { match value.trim() { "email" => Some(AccountSortField::Email), @@ -174,10 +162,11 @@ pub struct AccountPageData { #[serde(rename_all = "camelCase")] pub struct AccountSummaryView { pub total: u64, - pub active: u64, + pub normal: u64, pub quota_exhausted: u64, pub rate_limited: u64, - pub unavailable: u64, + pub disabled: u64, + pub error: u64, } /// 一条安全账号视图。 @@ -197,6 +186,10 @@ pub struct AccountView { pub has_refresh_token: bool, pub status: String, pub display_status: String, + /// `status == "error"` 时的具体原因;其余状态为 `null`。 + pub error_reason: Option, + /// 最近一次失败的上游错误描述;仅错误状态存在。 + pub error_message: Option, pub availability: String, pub enabled: bool, pub access_token_expires_at: Option, @@ -530,7 +523,7 @@ impl From for AccountConnectionTestEvent { DomainConnectionTestEvent::Completed { account_status } => serde_json::json!({ "type": "test_complete", "success": true, - "accountStatus": account_status_name(account_status) + "accountStatus": account_status.as_str() }), DomainConnectionTestEvent::Failed { message, @@ -548,7 +541,7 @@ impl From for AccountConnectionTestEvent { "upstreamStatus": upstream_status, "upstreamContentType": upstream_content_type, "upstreamBody": upstream_body, - "accountStatus": account_status_name(account_status) + "accountStatus": account_status.as_str() }), }; Self { data } @@ -1336,10 +1329,11 @@ fn account_page_data( page: PageMeta::new(page, u32::from(page_size), result.total, total_pages), summary: AccountSummaryView { total: result.summary.total, - active: result.summary.active, + normal: result.summary.normal, quota_exhausted: result.summary.quota_exhausted, rate_limited: result.summary.rate_limited, - unavailable: result.summary.unavailable, + disabled: result.summary.disabled, + error: result.summary.error, }, } } @@ -1370,10 +1364,11 @@ fn account_view(item: AccountDirectoryItem, now: DateTime) -> AccountView { let AccountDirectoryItem { account, status, + error_reason, usage, quota, } = item; - let status = account_status_name(status).to_owned(); + let status = status.as_str().to_owned(); let expires_at = account.access_token_expires_at.as_ref().map(china_rfc3339); let added_at = china_rfc3339(&account.created_at); let updated_at = china_rfc3339(&account.updated_at); @@ -1392,6 +1387,8 @@ fn account_view(item: AccountDirectoryItem, now: DateTime) -> AccountView { has_refresh_token: account.has_refresh_token, status: status.clone(), display_status: status, + error_reason: error_reason.map(|reason| reason.as_str().to_owned()), + error_message: account.last_error_message, availability: account.availability.as_str().to_owned(), enabled: account.enabled, access_token_expires_at: expires_at, @@ -1717,18 +1714,6 @@ fn provider_document_value(document: ProviderDocument) -> Value { Value::Object(document.into_provider_data().into_inner()) } -fn account_status_name(status: DomainAccountStatus) -> &'static str { - match status { - DomainAccountStatus::Active => "active", - DomainAccountStatus::RateLimited => "rate_limited", - DomainAccountStatus::QuotaExhausted => "quota_exhausted", - DomainAccountStatus::Expired => "expired", - DomainAccountStatus::Invalid => "invalid", - DomainAccountStatus::Disabled => "disabled", - DomainAccountStatus::Banned => "banned", - } -} - fn relative_time(value: DateTime, now: DateTime) -> String { let elapsed = now.signed_duration_since(value); if elapsed.num_seconds() < 0 { diff --git a/backend/crates/gateway-api/tests/admin/accounts.rs b/backend/crates/gateway-api/tests/admin/accounts.rs index 3371e06e5..7a331db5c 100644 --- a/backend/crates/gateway-api/tests/admin/accounts.rs +++ b/backend/crates/gateway-api/tests/admin/accounts.rs @@ -10,7 +10,7 @@ mod query { "pageSize": 20, "provider": "xai", "search": " operator ", - "status": "active", + "status": "normal", "sortBy": "lastUsedAt", "sortDirection": "desc" })) @@ -23,7 +23,7 @@ mod query { Some(ref provider) if provider.as_str() == "xai" )); assert_eq!(query.search.as_deref(), Some("operator")); - assert_eq!(query.status, Some(AccountStatus::Active)); + assert_eq!(query.status, Some(AccountStatus::Normal)); assert_eq!( query.sort.expect("sort").field, AccountSortField::LastUsedAt @@ -344,7 +344,7 @@ mod actions { text: "OK".to_owned(), }, DomainConnectionTestEvent::Completed { - account_status: AccountStatus::Active, + account_status: AccountStatus::Normal, }, DomainConnectionTestEvent::Failed { message: "upstream unavailable".to_owned(), @@ -378,7 +378,7 @@ mod actions { } }), json!({ "type": "content", "text": "OK" }), - json!({ "type": "test_complete", "success": true, "accountStatus": "active" }), + json!({ "type": "test_complete", "success": true, "accountStatus": "normal" }), json!({ "type": "error", "error": "upstream unavailable", diff --git a/backend/crates/gateway-core/src/engine/credential.rs b/backend/crates/gateway-core/src/engine/credential.rs index 458ceab2d..76bd88fb7 100644 --- a/backend/crates/gateway-core/src/engine/credential.rs +++ b/backend/crates/gateway-core/src/engine/credential.rs @@ -607,6 +607,8 @@ pub struct AccountStateChange { pub expected_revision: CredentialRevision, pub availability: AccountAvailability, pub observed_at: SystemTime, + /// 上游原因(错误原文 / 原因码);`Some` 写入持久化错误信息,`None` 不更新,恢复 `Ready` 时清空。 + pub message: Option, } /// `provider_accounts` 的数据库中立端口。 diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs index f25f345df..0cebdb526 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/core_adapter.rs @@ -247,6 +247,7 @@ impl ProviderAccountStore for PgProviderAccountRepository { .map_err(core_store_error)?, availability: change.availability, availability_observed_at: DateTime::::from(change.observed_at), + message: change.message, }) .await .map_err(core_store_error)?; diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs index 3809abbfe..8622afd41 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/mapping.rs @@ -2,6 +2,8 @@ use super::*; +use gateway_admin::model::accounts::AccountStatusSignals; + pub(crate) struct AdminAccountListItem { pub(crate) account: ProviderAccountSummary, pub(crate) status: AdminAccountStatus, @@ -59,27 +61,16 @@ pub(crate) fn admin_account_status( account: &ProviderAccountSummary, now: DateTime, ) -> AdminAccountStatus { - if !account.enabled { - AdminAccountStatus::Disabled - } else if account.availability == AccountAvailability::Banned { - AdminAccountStatus::Banned - } else if account.availability == AccountAvailability::QuotaExhausted { - AdminAccountStatus::QuotaExhausted - } else if account.availability == AccountAvailability::Invalid { - AdminAccountStatus::Invalid - } else if account.availability == AccountAvailability::Expired - || account + // 派生规则唯一归属 gateway-admin 域层,此处只做类型适配。 + AccountStatusSignals { + enabled: account.enabled, + availability: account.availability, + access_token_expired: account .access_token_expires_at - .is_some_and(|expires_at| expires_at <= now) - { - AdminAccountStatus::Expired - } else if account.quota_limit_reached { - AdminAccountStatus::RateLimited - } else if account.availability == AccountAvailability::Unknown { - AdminAccountStatus::Invalid - } else { - AdminAccountStatus::Active + .is_some_and(|expires_at| expires_at <= now), + quota_limit_reached: account.quota_limit_reached, } + .derive() } pub(crate) fn admin_account_summary( @@ -88,26 +79,25 @@ pub(crate) fn admin_account_summary( ) -> AccountSummary { let mut summary = AccountSummary { total: u64::try_from(accounts.len()).unwrap_or(u64::MAX), - active: 0, + normal: 0, quota_exhausted: 0, rate_limited: 0, - unavailable: 0, + disabled: 0, + error: 0, }; for account in accounts { match admin_account_status(account, now) { - AdminAccountStatus::Active => summary.active = summary.active.saturating_add(1), + AdminAccountStatus::Normal => summary.normal = summary.normal.saturating_add(1), AdminAccountStatus::QuotaExhausted => { summary.quota_exhausted = summary.quota_exhausted.saturating_add(1); } AdminAccountStatus::RateLimited => { summary.rate_limited = summary.rate_limited.saturating_add(1); } - AdminAccountStatus::Expired - | AdminAccountStatus::Invalid - | AdminAccountStatus::Disabled - | AdminAccountStatus::Banned => { - summary.unavailable = summary.unavailable.saturating_add(1); + AdminAccountStatus::Disabled => { + summary.disabled = summary.disabled.saturating_add(1); } + AdminAccountStatus::Error => summary.error = summary.error.saturating_add(1), } } summary @@ -124,9 +114,7 @@ pub(crate) fn sort_admin_account_items( items.sort_by(|left, right| { let ordering = match sort.field { AdminAccountSortField::Email => left.account.email.cmp(&right.account.email), - AdminAccountSortField::Status => { - admin_account_status_name(left.status).cmp(admin_account_status_name(right.status)) - } + AdminAccountSortField::Status => left.status.as_str().cmp(right.status.as_str()), AdminAccountSortField::PlanType => left.account.plan_type.cmp(&right.account.plan_type), AdminAccountSortField::Usage => left .usage @@ -156,18 +144,6 @@ pub(crate) fn sort_admin_account_items( }); } -pub(crate) const fn admin_account_status_name(status: AdminAccountStatus) -> &'static str { - match status { - AdminAccountStatus::Active => "active", - AdminAccountStatus::RateLimited => "rate_limited", - AdminAccountStatus::Expired => "expired", - AdminAccountStatus::Invalid => "invalid", - AdminAccountStatus::QuotaExhausted => "quota_exhausted", - AdminAccountStatus::Disabled => "disabled", - AdminAccountStatus::Banned => "banned", - } -} - pub(crate) fn admin_account_record( summary: ProviderAccountSummary, ) -> AdminStoreResult { @@ -193,6 +169,7 @@ pub(crate) fn admin_account_record( enabled: summary.enabled, availability: summary.availability, availability_observed_at: summary.availability_observed_at, + last_error_message: summary.last_error_message, quota_observed_at: summary.quota_observed_at, created_at: summary.created_at, updated_at: summary.updated_at, diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs index 5d4d0f8af..57b34186f 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/repository.rs @@ -95,7 +95,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository { upstream_account_id, plan_type, authentication_kind, credential_revision, has_refresh_token, access_token_expires_at, next_refresh_at, enabled, availability, availability_observed_at, - quota_observed_at, quota_limit_reached, created_at, updated_at + quota_observed_at, quota_limit_reached, last_error_message, created_at, updated_at from provider_accounts where ($1::text is null or provider_kind = $1) and ($2 or enabled) order by provider_kind, name, id", @@ -214,6 +214,11 @@ impl ProviderAccountRepository for PgProviderAccountRepository { when enabled then $4 else availability_observed_at end, + last_error_message = case + when enabled then + case when $3 = 'ready' then null else coalesce($5, last_error_message) end + else last_error_message + end, updated_at = case when enabled then greatest(now(), $4) else updated_at end where id = $1 and credential_revision = $2 and (availability_observed_at is null or availability_observed_at <= $4)", @@ -222,6 +227,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository { .bind(to_i64(update.expected_revision.get())?) .bind(update.availability.as_str()) .bind(update.availability_observed_at) + .bind(update.message.as_deref()) .execute(&self.pool) .await .map_err(|_| postgres_unavailable("apply provider account state"))?; diff --git a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs index c8b54afa0..03f405c1f 100644 --- a/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs +++ b/backend/crates/gateway-store/src/postgres/provider_accounts/rows.rs @@ -90,6 +90,8 @@ pub struct ProviderAccountSummary { pub quota_observed_at: Option>, /// 快照级限流事实(Provider 物化)。 pub quota_limit_reached: bool, + /// 最近一次非 ready 状态变更的原始原因(上游错误原文 / 原因码);恢复 ready 后清空。 + pub last_error_message: Option, pub created_at: DateTime, pub updated_at: DateTime, } @@ -310,6 +312,8 @@ pub struct ProviderAccountStateUpdate { pub expected_revision: Revision, pub availability: AccountAvailability, pub availability_observed_at: DateTime, + /// 上游原因(错误原文 / 原因码);`None` 不更新错误信息列,恢复 `Ready` 时清空。 + pub message: Option, } impl ProviderAccountStateUpdate { @@ -335,14 +339,14 @@ impl ProviderAccountObservation { pub(crate) const ACCOUNT_SELECT: &str = "select id, provider_kind, name, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, has_refresh_token, access_token_expires_at, next_refresh_at, enabled, availability, - provider_quota_json, quota_limit_reached, + provider_quota_json, quota_limit_reached, last_error_message, availability_observed_at, quota_observed_at, created_at, updated_at from provider_accounts where id = $1"; pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select id, provider_kind, name, email, upstream_user_id, upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision, has_refresh_token, access_token_expires_at, next_refresh_at, enabled, availability, - provider_quota_json, quota_limit_reached, + provider_quota_json, quota_limit_reached, last_error_message, availability_observed_at, quota_observed_at, created_at, updated_at from provider_accounts where id = any($1::text[]) and provider_kind = $2 @@ -445,6 +449,7 @@ pub(crate) fn account_summary_from_row( availability_observed_at: get(&row, "availability_observed_at")?, quota_observed_at: get(&row, "quota_observed_at")?, quota_limit_reached: get(&row, "quota_limit_reached")?, + last_error_message: get(&row, "last_error_message")?, created_at: get(&row, "created_at")?, updated_at: get(&row, "updated_at")?, }) diff --git a/backend/crates/gateway-store/tests/postgres/provider_accounts.rs b/backend/crates/gateway-store/tests/postgres/provider_accounts.rs index 73eac2252..f651f45b6 100644 --- a/backend/crates/gateway-store/tests/postgres/provider_accounts.rs +++ b/backend/crates/gateway-store/tests/postgres/provider_accounts.rs @@ -160,6 +160,7 @@ async fn delayed_provider_observations_cannot_overwrite_newer_state_or_quota() { repository .apply_state_change(AccountStateChange { + message: None, account_id: account_id.clone(), expected_revision: revision, availability: AccountAvailability::QuotaExhausted, @@ -170,6 +171,7 @@ async fn delayed_provider_observations_cannot_overwrite_newer_state_or_quota() { assert!( repository .apply_state_change(AccountStateChange { + message: None, account_id: account_id.clone(), expected_revision: revision, availability: AccountAvailability::Ready, @@ -200,6 +202,61 @@ async fn delayed_provider_observations_cannot_overwrite_newer_state_or_quota() { database.close().await; } +#[tokio::test] +async fn account_state_message_is_persisted_and_cleared_on_recovery() { + let Some(database) = TestDatabase::create("provider_account_state_message").await else { + return; + }; + let repository = PgProviderAccountRepository::new(database.pool.clone()); + let account_id = ProviderAccountId::new("acct_state_message").expect("account ID"); + repository + .insert_provider_account(account(account_id.as_str(), "user-state-message")) + .await + .expect("insert state message fixture"); + let revision = CredentialRevision::new(1).expect("revision"); + + repository + .apply_state_change(AccountStateChange { + message: Some("upstream returned 402 payment required".to_owned()), + account_id: account_id.clone(), + expected_revision: revision, + availability: AccountAvailability::QuotaExhausted, + observed_at: SystemTime::now(), + }) + .await + .expect("persist error message"); + let stored: Option = + sqlx::query_scalar("select last_error_message from provider_accounts where id = $1") + .bind(account_id.as_str()) + .fetch_one(&database.pool) + .await + .expect("read error message"); + assert_eq!( + stored.as_deref(), + Some("upstream returned 402 payment required") + ); + + repository + .apply_state_change(AccountStateChange { + message: None, + account_id: account_id.clone(), + expected_revision: revision, + availability: AccountAvailability::Ready, + observed_at: SystemTime::now(), + }) + .await + .expect("recover account"); + let cleared: Option = + sqlx::query_scalar("select last_error_message from provider_accounts where id = $1") + .bind(account_id.as_str()) + .fetch_one(&database.pool) + .await + .expect("read cleared error message"); + assert_eq!(cleared, None); + + database.close().await; +} + #[tokio::test] async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_usage() { let Some(database) = TestDatabase::create("provider_account_terminal_list").await else { @@ -322,16 +379,18 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u assert_eq!(usage_page.config_revision.get(), 1); assert_eq!(usage_page.total, 6); assert_eq!(usage_page.summary.total, 6); - assert_eq!(usage_page.summary.active, 1); + assert_eq!(usage_page.summary.normal, 1); assert_eq!(usage_page.summary.quota_exhausted, 1); assert_eq!(usage_page.summary.rate_limited, 1); - assert_eq!(usage_page.summary.unavailable, 3); + assert_eq!(usage_page.summary.disabled, 1); + assert_eq!(usage_page.summary.error, 2); assert_eq!( usage_page.summary.total, - usage_page.summary.active + usage_page.summary.normal + usage_page.summary.quota_exhausted + usage_page.summary.rate_limited - + usage_page.summary.unavailable + + usage_page.summary.disabled + + usage_page.summary.error ); assert_eq!( usage_page @@ -371,7 +430,7 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u page_size: PageSize::new(10).expect("page size"), provider_kind: Some(ProviderKind::new("openai").expect("Provider kind")), search: Some("ALPHA@EXAMPLE".to_owned()), - status: Some(AccountStatus::Active), + status: Some(AccountStatus::Normal), sort: None, }) .await @@ -381,18 +440,20 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u assert_eq!(filtered.items[0].id, "acct_alpha"); assert_eq!(filtered.items[0].provider_kind.as_str(), "openai"); - let banned = store + let error_accounts = store .list_accounts(AccountListQuery { page: 1, page_size: PageSize::new(10).expect("page size"), provider_kind: None, search: None, - status: Some(AccountStatus::Banned), + status: Some(AccountStatus::Error), sort: None, }) .await - .expect("filter banned accounts"); - assert_eq!(banned.items[0].id, "acct_beta"); + .expect("filter error accounts"); + assert_eq!(error_accounts.items.len(), 2); + assert_eq!(error_accounts.items[0].id, "acct_beta"); + assert_eq!(error_accounts.items[1].id, "acct_invalid"); database.close().await; } @@ -1210,6 +1271,7 @@ async fn xai_free_quota_exhaustion_state_is_persisted() { repository .apply_state_change(AccountStateChange { + message: None, account_id: account_id.clone(), expected_revision: CredentialRevision::new(1).expect("credential revision"), availability: AccountAvailability::QuotaExhausted, @@ -1248,6 +1310,7 @@ async fn xai_resettable_usage_limit_state_is_persisted() { let observed_at = SystemTime::now(); repository .apply_state_change(AccountStateChange { + message: None, account_id: account_id.clone(), expected_revision: CredentialRevision::new(1).expect("credential revision"), availability: AccountAvailability::Ready, @@ -1297,6 +1360,7 @@ async fn disabled_account_preserves_user_state_during_refresh_writes() { repository .apply_state_change(AccountStateChange { + message: None, account_id: account_id.clone(), expected_revision: CredentialRevision::new(1).expect("credential revision"), availability: AccountAvailability::Ready, diff --git a/backend/crates/providers/openai/src/credential/repository.rs b/backend/crates/providers/openai/src/credential/repository.rs index 9108f7251..4cc2210a4 100644 --- a/backend/crates/providers/openai/src/credential/repository.rs +++ b/backend/crates/providers/openai/src/credential/repository.rs @@ -234,6 +234,18 @@ impl CodexCredentialRepository { account: &ProviderAccount, availability: AccountAvailability, observed_at: SystemTime, + ) -> Result<(), CredentialRepositoryError> { + self.apply_state_with_message(account, availability, observed_at, None) + .await + } + + /// 带上游错误描述的状态写入;`message` 为 `Some` 时持久化最近错误,恢复 `Ready` 时清空。 + pub async fn apply_state_with_message( + &self, + account: &ProviderAccount, + availability: AccountAvailability, + observed_at: SystemTime, + message: Option, ) -> Result<(), CredentialRepositoryError> { if !account.enabled() { return Ok(()); @@ -244,6 +256,7 @@ impl CodexCredentialRepository { expected_revision: account.revision(), availability, observed_at, + message, }) .await?; Ok(()) diff --git a/backend/crates/providers/openai/src/credential/selector.rs b/backend/crates/providers/openai/src/credential/selector.rs index e4d4a1446..21b1b29f7 100644 --- a/backend/crates/providers/openai/src/credential/selector.rs +++ b/backend/crates/providers/openai/src/credential/selector.rs @@ -685,26 +685,32 @@ impl CodexCredentialSelector { &self, account: &ProviderAccount, failure: CodexAccountFailure, + message: Option, ) -> Result<(), CredentialSelectionError> { let now = SystemTime::now(); match failure { CodexAccountFailure::CredentialExpired => { - self.apply_state(account, AccountAvailability::Expired, now) + self.apply_state_with_message(account, AccountAvailability::Expired, now, message) .await } CodexAccountFailure::IdentityVerificationRequired => { - self.apply_state(account, AccountAvailability::Invalid, now) + self.apply_state_with_message(account, AccountAvailability::Invalid, now, message) .await } CodexAccountFailure::Banned => { - self.apply_state(account, AccountAvailability::Banned, now) + self.apply_state_with_message(account, AccountAvailability::Banned, now, message) .await } // 402:真额度耗尽,由 quota worker 按窗口 reset 自动恢复。 CodexAccountFailure::QuotaExhausted | CodexAccountFailure::UsageLimitExhausted { .. } => { - self.apply_state(account, AccountAvailability::QuotaExhausted, now) - .await + self.apply_state_with_message( + account, + AccountAvailability::QuotaExhausted, + now, + message, + ) + .await } // 429:临时限流只写 quota,availability 不改变。 CodexAccountFailure::RateLimited { retry_after } => { @@ -727,8 +733,13 @@ impl CodexCredentialSelector { CodexAccountFailure::CloudflarePathBlocked => { let blocked = self.record_cloudflare_path_block(account.id(), now); if blocked >= CLOUDFLARE_PATH_BLOCK_THRESHOLD { - self.apply_state(account, AccountAvailability::Invalid, now) - .await?; + self.apply_state_with_message( + account, + AccountAvailability::Invalid, + now, + message, + ) + .await?; } self.apply_cookie_recovery(account, CookieRecovery::Clear) .await?; @@ -737,14 +748,15 @@ impl CodexCredentialSelector { } } - async fn apply_state( + async fn apply_state_with_message( &self, account: &ProviderAccount, availability: AccountAvailability, observed_at: SystemTime, + message: Option, ) -> Result<(), CredentialSelectionError> { self.repository - .apply_state(account, availability, observed_at) + .apply_state_with_message(account, availability, observed_at, message) .await?; Ok(()) } diff --git a/backend/crates/providers/openai/src/provider.rs b/backend/crates/providers/openai/src/provider.rs index 342739406..25d36825c 100644 --- a/backend/crates/providers/openai/src/provider.rs +++ b/backend/crates/providers/openai/src/provider.rs @@ -1697,6 +1697,8 @@ fn map_selection_error(error: CredentialSelectionError) -> ProviderError { struct MappedProviderFailure { error: ProviderError, account_failure: Option, + /// 原始上游错误描述,随 availability 状态一并持久化。 + error_message: Option, cyber_policy_failure: bool, set_cookie_headers: Vec, rate_limit_headers: Vec<(String, String)>, @@ -1709,6 +1711,7 @@ impl MappedProviderFailure { Self { error, account_failure: None, + error_message: None, cyber_policy_failure: false, set_cookie_headers: Vec::new(), rate_limit_headers: Vec::new(), @@ -1882,7 +1885,7 @@ async fn apply_failure( .await; if let Err(error) = context .selector - .record_failure(account, account_failure) + .record_failure(account, account_failure, failure.error_message.clone()) .await { tracing::warn!( @@ -2162,6 +2165,7 @@ fn map_upstream_failure( MappedProviderFailure { error, account_failure: account_failure(category, failure.retry_after_seconds), + error_message: failure.client_message, cyber_policy_failure, set_cookie_headers: failure.set_cookie_headers, rate_limit_headers: failure.rate_limit_headers, diff --git a/backend/crates/providers/openai/tests/admin.rs b/backend/crates/providers/openai/tests/admin.rs index 3042048fd..21d5b6294 100644 --- a/backend/crates/providers/openai/tests/admin.rs +++ b/backend/crates/providers/openai/tests/admin.rs @@ -544,6 +544,7 @@ async fn openai_admin_projects_confirmed_quota_exhaustion_as_full_without_mutati .expect("persist raw quota"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: CoreAccountAvailability::QuotaExhausted, @@ -584,6 +585,7 @@ async fn openai_admin_projects_confirmed_quota_exhaustion_as_full_without_mutati store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: CoreAccountAvailability::Ready, @@ -711,6 +713,7 @@ fn account_record(account: &ProviderAccount) -> AccountRecord { enabled: account.enabled(), availability: AdminAccountAvailability::Ready, availability_observed_at: now, + last_error_message: None, quota_observed_at: None, created_at: now, updated_at: now, diff --git a/backend/crates/providers/openai/tests/credential/contract.rs b/backend/crates/providers/openai/tests/credential/contract.rs index e60bef372..b5411aace 100644 --- a/backend/crates/providers/openai/tests/credential/contract.rs +++ b/backend/crates/providers/openai/tests/credential/contract.rs @@ -507,6 +507,7 @@ async fn selector_should_escape_a_quota_exhausted_affinity_account() { let first = store.account("acct_first").expect("first account"); store .apply_state_change(AccountStateChange { + message: None, account_id: first.id().clone(), expected_revision: first.revision(), availability: AccountAvailability::QuotaExhausted, @@ -663,8 +664,12 @@ fn credential_expired_failure_marks_unified_account_expired() { }), ) .expect("select account"); - block_on(selector.record_failure(lease.account(), CodexAccountFailure::CredentialExpired)) - .expect("record credential expiry"); + block_on(selector.record_failure( + lease.account(), + CodexAccountFailure::CredentialExpired, + None, + )) + .expect("record credential expiry"); assert_eq!( store .account("acct_primary") @@ -697,6 +702,7 @@ fn rate_limited_failure_records_quota_window_without_changing_availability() { CodexAccountFailure::RateLimited { retry_after: Some(Duration::from_secs(30)), }, + None, )) .expect("record rate-limit failure"); @@ -743,6 +749,7 @@ async fn usage_limit_exhaustion_marks_quota_exhausted_without_usage_probe() { CodexAccountFailure::UsageLimitExhausted { retry_after: Some(Duration::from_secs(30)), }, + None, ) .await .expect("record usage-limit exhaustion"); @@ -758,6 +765,7 @@ fn rate_limited_failure_does_not_downgrade_persisted_quota_exhaustion() { create_account(&store, "acct_primary", "at-primary"); let account = store.account("acct_primary").expect("account"); block_on(store.apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, @@ -771,6 +779,7 @@ fn rate_limited_failure_does_not_downgrade_persisted_quota_exhaustion() { CodexAccountFailure::RateLimited { retry_after: Some(Duration::from_secs(30)), }, + None, )) .expect("record rate-limit failure"); @@ -796,6 +805,7 @@ fn rate_limited_failure_does_not_consult_stale_quota_snapshot() { CodexAccountFailure::RateLimited { retry_after: Some(Duration::from_secs(30)), }, + None, )) .expect("record rate-limit failure"); @@ -816,6 +826,7 @@ fn rate_limited_failure_does_not_overwrite_stale_authentication_state() { create_account(&store, "acct_primary", "at-primary"); let account = store.account("acct_primary").expect("account"); block_on(store.apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::Invalid, @@ -830,6 +841,7 @@ fn rate_limited_failure_does_not_overwrite_stale_authentication_state() { CodexAccountFailure::RateLimited { retry_after: Some(Duration::from_secs(30)), }, + None, )) .expect("record rate-limit failure"); @@ -854,6 +866,7 @@ fn successful_upstream_response_recovers_non_quota_terminal_states() { create_account(&store, "acct_primary", "at-primary"); let account = store.account("acct_primary").expect("account"); block_on(store.apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: stale, @@ -892,12 +905,14 @@ fn rate_limited_failures_for_distinct_accounts_do_not_conflict() { CodexAccountFailure::RateLimited { retry_after: Some(Duration::from_secs(30)), }, + None, ), selector.record_failure( &second, CodexAccountFailure::RateLimited { retry_after: Some(Duration::from_secs(30)), }, + None, ), ); first_result.expect("record first account failure"); @@ -925,7 +940,7 @@ fn selector_keeps_a_quota_exhausted_account_eligible_when_configured_not_to_skip create_account(&store, "acct_primary", "at-primary"); let strict_selector = selector(&store, Arc::new(TestLeaseCoordinator::default())); let account = store.account("acct_primary").expect("account"); - block_on(strict_selector.record_failure(&account, CodexAccountFailure::QuotaExhausted)) + block_on(strict_selector.record_failure(&account, CodexAccountFailure::QuotaExhausted, None)) .expect("mark account exhausted"); let selector = selector_with_runtime( @@ -979,6 +994,7 @@ fn identity_verification_failure_isolates_only_selected_account() { block_on(selector.record_failure( lease.account(), CodexAccountFailure::IdentityVerificationRequired, + None, )) .expect("record identity verification failure"); @@ -1022,6 +1038,7 @@ fn cloudflare_challenge_does_not_change_availability() { block_on(selector.record_failure( lease.account(), CodexAccountFailure::CloudflareChallenge { retry_after: None }, + None, )) .expect("record challenge"); @@ -1058,9 +1075,11 @@ fn repeated_cloudflare_path_block_marks_only_the_affected_account_invalid() { .expect("select account"); for _ in 0..3 { - block_on( - selector.record_failure(lease.account(), CodexAccountFailure::CloudflarePathBlocked), - ) + block_on(selector.record_failure( + lease.account(), + CodexAccountFailure::CloudflarePathBlocked, + None, + )) .expect("record path block"); } @@ -1114,6 +1133,7 @@ fn cloudflare_challenge_expires_provider_owned_cookies_at_cooldown_boundary() { block_on(selector.record_failure( second.account(), CodexAccountFailure::CloudflareChallenge { retry_after: None }, + None, )) .expect("record challenge"); @@ -1157,8 +1177,12 @@ fn cloudflare_path_block_deletes_provider_owned_cookies() { session_affinity_key: None, })) .expect("select revised account"); - block_on(selector.record_failure(second.account(), CodexAccountFailure::CloudflarePathBlocked)) - .expect("record path block"); + block_on(selector.record_failure( + second.account(), + CodexAccountFailure::CloudflarePathBlocked, + None, + )) + .expect("record path block"); let account = store.account("acct_primary").expect("account"); let data = block_on(store.repository().load_complete_data(&account)).expect("credential data"); @@ -1194,7 +1218,7 @@ fn response_cookie_rotation_returns_a_current_account_for_later_fenced_writes() assert_eq!(outcome.credential_revision, Some(current.revision().get())); assert_ne!(current.revision(), lease.account().revision()); - block_on(selector.record_failure(¤t, CodexAccountFailure::QuotaExhausted)) + block_on(selector.record_failure(¤t, CodexAccountFailure::QuotaExhausted, None)) .expect("record failure with current revision"); assert_eq!( store diff --git a/backend/crates/providers/openai/tests/credential/quota/mod.rs b/backend/crates/providers/openai/tests/credential/quota/mod.rs index cf8da5ae2..f68ec019f 100644 --- a/backend/crates/providers/openai/tests/credential/quota/mod.rs +++ b/backend/crates/providers/openai/tests/credential/quota/mod.rs @@ -235,6 +235,7 @@ async fn manual_quota_refresh_preserves_disabled_account_state() { let account = store.account(account_id).expect("created disabled account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::Expired, @@ -288,6 +289,7 @@ async fn manual_quota_auth_rejection_does_not_invalidate_refreshable_credential( let account = store.account(account_id).expect("created account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, @@ -494,6 +496,7 @@ async fn periodic_quota_synchronization_preserves_availability_when_usage_peaks( let reset_at = Utc::now().timestamp() + 24 * 60 * 60; store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::Ready, @@ -560,6 +563,7 @@ async fn periodic_quota_synchronization_does_not_downgrade_exhaustion_on_rate_li .expect("created account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, @@ -599,6 +603,7 @@ async fn periodic_quota_synchronization_respects_retry_after_on_service_unavaila .expect("created account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, @@ -639,6 +644,7 @@ async fn periodic_quota_synchronization_skips_ready_accounts_without_quota_reach .expect("created account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::Ready, @@ -671,6 +677,7 @@ async fn periodic_quota_synchronization_attempts_quota_exhausted_accounts() { .expect("created account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, @@ -696,6 +703,7 @@ async fn periodic_quota_synchronization_rechecks_exhausted_accounts_without_wait .expect("created account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, @@ -761,6 +769,7 @@ async fn periodic_quota_synchronization_does_not_recover_from_percent_drop_befor .expect("seed post-failure quota snapshot"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, @@ -851,6 +860,7 @@ async fn periodic_quota_synchronization_recovers_after_recorded_reset() { let account = store.account(account_id).expect("created account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, @@ -900,6 +910,7 @@ async fn periodic_quota_synchronization_throttles_the_same_exhausted_account() { .expect("created account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, @@ -923,6 +934,7 @@ async fn periodic_quota_synchronization_does_not_bypass_throttle_for_a_new_reset let account = store.account(account_id).expect("created account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, @@ -977,6 +989,7 @@ async fn invalid_quota_json_for_one_account_does_not_abort_the_batch() { let account = store.account(account_id).expect("created account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, diff --git a/backend/crates/providers/openai/tests/credential/quota/recovery.rs b/backend/crates/providers/openai/tests/credential/quota/recovery.rs index c1826b1b8..663a44da4 100644 --- a/backend/crates/providers/openai/tests/credential/quota/recovery.rs +++ b/backend/crates/providers/openai/tests/credential/quota/recovery.rs @@ -12,6 +12,7 @@ async fn manual_quota_refresh_keeps_confirmed_exhaustion_until_the_recorded_rese let account = store.account(account_id).expect("created account"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::Expired, @@ -161,6 +162,7 @@ async fn expired_old_reset_with_fresh_allowed_snapshot_recovers_ready() { .expect("seed old exhausted snapshot"); store .apply_state_change(AccountStateChange { + message: None, account_id: account.id().clone(), expected_revision: account.revision(), availability: AccountAvailability::QuotaExhausted, diff --git a/backend/crates/providers/xai/src/credential/repository.rs b/backend/crates/providers/xai/src/credential/repository.rs index 413d02b87..28e8c74ac 100644 --- a/backend/crates/providers/xai/src/credential/repository.rs +++ b/backend/crates/providers/xai/src/credential/repository.rs @@ -436,6 +436,7 @@ impl GrokCredentialRepository { expected_revision: input.expected_revision, availability: input.availability.into(), observed_at: to_system_time(input.observed_at), + message: input.availability_reason.clone(), }) .await .map_err(map_store_error) diff --git a/backend/crates/providers/xai/tests/admin.rs b/backend/crates/providers/xai/tests/admin.rs index 8064f79d3..d82ee12a6 100644 --- a/backend/crates/providers/xai/tests/admin.rs +++ b/backend/crates/providers/xai/tests/admin.rs @@ -473,6 +473,7 @@ fn account_record(account: &ProviderAccount) -> AccountRecord { enabled: account.enabled(), availability: account.availability(), availability_observed_at: now, + last_error_message: None, quota_observed_at: None, created_at: now, updated_at: now, diff --git a/backend/crates/providers/xai/tests/credential/catalog.rs b/backend/crates/providers/xai/tests/credential/catalog.rs index 460f5c604..6d0ba6290 100644 --- a/backend/crates/providers/xai/tests/credential/catalog.rs +++ b/backend/crates/providers/xai/tests/credential/catalog.rs @@ -204,6 +204,7 @@ async fn set_account_state( ) { store .apply_state_change(AccountStateChange { + message: None, account_id: id.clone(), expected_revision: CredentialRevision::new(1).expect("revision"), availability, @@ -485,6 +486,7 @@ async fn quota_exhausted_account_remains_eligible_for_catalog_discovery() { let id = account_id("quota-exhausted"); store .apply_state_change(AccountStateChange { + message: None, account_id: id, expected_revision: CredentialRevision::new(1).expect("revision"), availability: AccountAvailability::QuotaExhausted, @@ -755,6 +757,7 @@ async fn quota_refresh_preserves_ready_availability_across_concurrent_state_writ let transport = Arc::new(MutatingBillingTransport { store: Arc::clone(&store), mutation: Mutex::new(Some(BillingMutation::State(AccountStateChange { + message: None, account_id: id.clone(), expected_revision: CredentialRevision::new(1).expect("revision"), availability: AccountAvailability::Ready, diff --git a/backend/migrations/.frozen-sha256 b/backend/migrations/.frozen-sha256 index c0e2f59c6..1c38e73b2 100644 --- a/backend/migrations/.frozen-sha256 +++ b/backend/migrations/.frozen-sha256 @@ -1 +1 @@ -609d455f410f7be362bcdce841630728aaf2681f5cd6b2a6012529e5f8b8b1f6 0001_initial.sql +5f79e74c9a7a7366c39cd1afe43175eff6c44afe9217c0f26f33f173bd40c781 0001_initial.sql diff --git a/backend/migrations/0001_initial.sql b/backend/migrations/0001_initial.sql index 827575831..67c60813e 100644 --- a/backend/migrations/0001_initial.sql +++ b/backend/migrations/0001_initial.sql @@ -133,6 +133,7 @@ create table provider_accounts ( availability text not null default 'unknown', provider_quota_json jsonb, quota_limit_reached boolean not null default false, + last_error_message text, availability_observed_at timestamptz not null, quota_observed_at timestamptz, created_at timestamptz not null, diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 808e60e58..96aebc2f0 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -104,6 +104,8 @@ export interface Account { hasRefreshToken: boolean status: string displayStatus: string + errorReason: string | null + errorMessage: string | null availability: string enabled: boolean accessTokenExpiresAt: string | null @@ -127,10 +129,11 @@ export interface AccountPageMeta { export interface AccountSummary { total: number - active: number + normal: number quotaExhausted: number rateLimited: number - unavailable: number + disabled: number + error: number } export interface AccountListResponse { diff --git a/frontend/src/views/accounts/components/AccountConnectionTestModal.vue b/frontend/src/views/accounts/components/AccountConnectionTestModal.vue index 4ced4bb5b..659de9417 100644 --- a/frontend/src/views/accounts/components/AccountConnectionTestModal.vue +++ b/frontend/src/views/accounts/components/AccountConnectionTestModal.vue @@ -57,7 +57,12 @@ function connectionLogClass(tone: string) { class="flex items-center justify-between gap-4 rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3" > - +
diff --git a/frontend/src/views/accounts/components/AccountOverviewCards.vue b/frontend/src/views/accounts/components/AccountOverviewCards.vue index 0708312db..cc53d21d8 100644 --- a/frontend/src/views/accounts/components/AccountOverviewCards.vue +++ b/frontend/src/views/accounts/components/AccountOverviewCards.vue @@ -20,7 +20,7 @@ const overviewItems = computed(() => [ }, { label: '正常账号', - value: formatCount(props.summary.active), + value: formatCount(props.summary.normal), caption: '可参与调度', tone: 'success', icon: ShieldCheck, @@ -33,9 +33,9 @@ const overviewItems = computed(() => [ icon: Gauge, }, { - label: '异常账号', - value: formatCount(props.summary.unavailable), - caption: '过期 / 失效 / 禁用 / 封禁', + label: '待处理', + value: formatCount((props.summary.disabled ?? 0) + (props.summary.error ?? 0)), + caption: '已停用 / 错误', tone: 'danger', icon: AlertTriangle, }, diff --git a/frontend/src/views/accounts/components/AccountStatusBadge.vue b/frontend/src/views/accounts/components/AccountStatusBadge.vue index b35c3ec1c..c8b395935 100644 --- a/frontend/src/views/accounts/components/AccountStatusBadge.vue +++ b/frontend/src/views/accounts/components/AccountStatusBadge.vue @@ -1,20 +1,36 @@ diff --git a/frontend/src/views/accounts/components/AccountTableActions.vue b/frontend/src/views/accounts/components/AccountTableActions.vue index 5feb45f09..6a3285025 100644 --- a/frontend/src/views/accounts/components/AccountTableActions.vue +++ b/frontend/src/views/accounts/components/AccountTableActions.vue @@ -26,7 +26,7 @@ const emit = defineEmits<{ const canRefreshToken = computed( () => props.account.hasRefreshToken - && (props.account.status === 'active' || props.account.status === 'quota_exhausted'), + && (props.account.status === 'normal' || props.account.status === 'quota_exhausted'), ) const canReauthorize = computed(() => props.account.provider === 'openai') diff --git a/frontend/src/views/accounts/composables/useAccountMutations.ts b/frontend/src/views/accounts/composables/useAccountMutations.ts index cc9851193..db124eb75 100644 --- a/frontend/src/views/accounts/composables/useAccountMutations.ts +++ b/frontend/src/views/accounts/composables/useAccountMutations.ts @@ -24,13 +24,11 @@ import { useAccountOnboarding } from './useAccountOnboarding' type AccountRow = Awaited>['items'][number] const accountStatusSortRank: Record = { - active: 0, + normal: 0, quota_exhausted: 1, rate_limited: 2, - expired: 3, - invalid: 4, - disabled: 5, - banned: 6, + disabled: 3, + error: 4, } export function useAccountMutations(options: { @@ -234,11 +232,11 @@ export function useAccountMutations(options: { return options.accountSummary.value = { ...options.accountSummary.value, - active: Math.max( + normal: Math.max( 0, - options.accountSummary.value.active - + Number(status === 'active') - - Number(current.status === 'active'), + options.accountSummary.value.normal + + Number(status === 'normal') + - Number(current.status === 'normal'), ), quotaExhausted: Math.max( 0, @@ -252,22 +250,21 @@ export function useAccountMutations(options: { + Number(status === 'rate_limited') - Number(current.status === 'rate_limited'), ), - unavailable: Math.max( + disabled: Math.max( 0, - options.accountSummary.value.unavailable - + Number(isUnavailableAccountStatus(status)) - - Number(isUnavailableAccountStatus(current.status)), + options.accountSummary.value.disabled + + Number(status === 'disabled') + - Number(current.status === 'disabled'), + ), + error: Math.max( + 0, + options.accountSummary.value.error + + Number(status === 'error') + - Number(current.status === 'error'), ), } } - function isUnavailableAccountStatus(status: string) { - return status === 'expired' - || status === 'invalid' - || status === 'disabled' - || status === 'banned' - } - function sortAccountsByStatus(rows: AccountRow[]) { if (options.sort.value?.key !== 'status') return rows diff --git a/frontend/src/views/accounts/composables/useAccountsQuery.ts b/frontend/src/views/accounts/composables/useAccountsQuery.ts index 2a10db2b4..7c94e63db 100644 --- a/frontend/src/views/accounts/composables/useAccountsQuery.ts +++ b/frontend/src/views/accounts/composables/useAccountsQuery.ts @@ -14,10 +14,11 @@ export function useAccountsQuery() { const sort = shallowRef() const accountSummary = shallowRef({ total: 0, - active: 0, + normal: 0, quotaExhausted: 0, rateLimited: 0, - unavailable: 0, + disabled: 0, + error: 0, }) const query = usePagedQuery({ diff --git a/frontend/src/views/accounts/constants.ts b/frontend/src/views/accounts/constants.ts index 1b581989c..9caa34512 100644 --- a/frontend/src/views/accounts/constants.ts +++ b/frontend/src/views/accounts/constants.ts @@ -123,38 +123,41 @@ export const accountColumns = [ ] satisfies BaseTableColumn[] export const statusLabels: Record = { - active: '正常', - expired: '已过期', - invalid: '失效', - disabled: '已禁用', - banned: '已封禁', + normal: '正常', quota_exhausted: '配额耗尽', rate_limited: '限流中', + disabled: '已停用', + error: '错误', } export const statusTones: Record = { - active: 'success', - expired: 'warning', - invalid: 'warning', - disabled: 'normal', - banned: 'danger', + normal: 'success', quota_exhausted: 'warning', rate_limited: 'warning', + disabled: 'normal', + error: 'danger', } export const accountStatusFilterOptions = [ { label: '全部状态', value: '' }, - { label: statusLabels.active, value: 'active' }, + { label: statusLabels.normal, value: 'normal' }, { label: statusLabels.quota_exhausted, value: 'quota_exhausted' }, { label: statusLabels.rate_limited, value: 'rate_limited' }, - { label: statusLabels.expired, value: 'expired' }, - { label: statusLabels.invalid, value: 'invalid' }, { label: statusLabels.disabled, value: 'disabled' }, - { label: statusLabels.banned, value: 'banned' }, + { label: statusLabels.error, value: 'error' }, ] +/** `error` 分类下具体原因的展示文案(对应后端 `errorReason`)。 */ +export const errorReasonLabels: Record = { + expired: '账号已过期', + token_expired: '访问令牌已过期', + invalid: '凭据已失效', + banned: '账号已封禁', + unknown: '状态未知', +} + /** - * 后端已派生互斥状态(含 `rate_limited`,由快照级 quota.limitReached 产生); + * 后端已派生互斥状态(正常 / 配额耗尽 / 限流中 / 已停用 / 错误); * 前端只渲染,不再独立派生。 */ export function derivedAccountStatus(row: AccountRow): string { diff --git a/frontend/src/views/accounts/index.vue b/frontend/src/views/accounts/index.vue index 0e39fb13b..8fc8f72f7 100644 --- a/frontend/src/views/accounts/index.vue +++ b/frontend/src/views/accounts/index.vue @@ -202,7 +202,11 @@ const {