Compare commits

...
6 Commits
Author SHA1 Message Date
ZyyandCodex d81cb2d4f0 chore: bump version to 3.1.3
Co-authored-by: Codex <noreply@openai.com>
2026-08-03 21:29:16 +08:00
ZyyandClaude Opus 4.8 8e2edb680d feat(accounts): converge status classification and persist upstream errors
Unify account status derivation in gateway-admin via AccountStatusSignals,
removing the duplicated rules in store and use_case. AccountStatus now has
five operational classes (normal/quota_exhausted/rate_limited/disabled/error),
with AccountErrorReason carrying the concrete cause.

Add provider_accounts.last_error_message, written by record_failure with the
raw upstream error and cleared on recovery to ready. The admin error badge
shows the cause and upstream text on hover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 21:23:28 +08:00
ZyyandCodex 00879be47d fix(accounts): separate rate-limited account status
Co-authored-by: Codex <noreply@openai.com>
2026-08-03 17:01:23 +08:00
ZyyandCodex 34656a3f07 refactor(ui): standardize card spacing and layouts
Co-authored-by: Codex <noreply@openai.com>
2026-08-03 17:01:23 +08:00
ZyyandCodex 27cf037309 fix(ui): center button loading spinner
Co-authored-by: Codex <noreply@openai.com>
2026-08-03 17:01:23 +08:00
ZyyandCodex 2198fa69ab refactor(migrations): consolidate schema into initial baseline
Co-authored-by: Codex <noreply@openai.com>
2026-08-03 17:01:23 +08:00
68 changed files with 723 additions and 438 deletions
@@ -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<Self> {
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<Self> {
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<AccountErrorReason> {
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<Utc>,
/// 最近一次非 ready 状态变更的原始原因(上游错误原文 / 原因码);恢复 ready 后清空。
pub last_error_message: Option<String>,
pub quota_observed_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
@@ -170,13 +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 unavailable: u64,
pub rate_limited: u64,
pub disabled: u64,
pub error: u64,
}
/// 账号启停写入命令。
@@ -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<AccountErrorReason>,
pub usage: Option<super::accounts::AccountUsage>,
pub quota: ProviderQuota,
}
@@ -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<Utc>,
) -> 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<Utc>,
) -> 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<Utc>, retention_days: u32) -> TimeRange {
@@ -451,9 +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,
unavailable: 0,
rate_limited: 0,
disabled: 0,
error: 0,
},
})
}
@@ -844,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);
@@ -867,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,
..
})
));
@@ -948,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!(
@@ -984,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
);
}
@@ -1088,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;
@@ -1111,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,
);
}
@@ -1332,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,
@@ -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,17 +128,6 @@ fn parse_provider(value: &str) -> Result<Option<ProviderKind>, WireValidationErr
.map_err(|_| WireValidationError::new("provider"))
}
fn parse_account_status(value: &str) -> Option<DomainAccountStatus> {
match value.trim().to_ascii_lowercase().as_str() {
"active" => Some(DomainAccountStatus::Active),
"expired" => Some(DomainAccountStatus::Expired),
"quota_exhausted" => Some(DomainAccountStatus::QuotaExhausted),
"disabled" => Some(DomainAccountStatus::Disabled),
"banned" => Some(DomainAccountStatus::Banned),
_ => None,
}
}
fn parse_sort_field(value: &str) -> Option<AccountSortField> {
match value.trim() {
"email" => Some(AccountSortField::Email),
@@ -172,9 +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 unavailable: u64,
pub rate_limited: u64,
pub disabled: u64,
pub error: u64,
}
/// 一条安全账号视图。
@@ -194,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<String>,
/// 最近一次失败的上游错误描述;仅错误状态存在。
pub error_message: Option<String>,
pub availability: String,
pub enabled: bool,
pub access_token_expires_at: Option<String>,
@@ -527,7 +523,7 @@ impl From<DomainConnectionTestEvent> 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,
@@ -545,7 +541,7 @@ impl From<DomainConnectionTestEvent> 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 }
@@ -1333,9 +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,
unavailable: result.summary.unavailable,
rate_limited: result.summary.rate_limited,
disabled: result.summary.disabled,
error: result.summary.error,
},
}
}
@@ -1366,10 +1364,11 @@ fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> 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);
@@ -1388,6 +1387,8 @@ fn account_view(item: AccountDirectoryItem, now: DateTime<Utc>) -> 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,
@@ -1713,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<Utc>, now: DateTime<Utc>) -> String {
let elapsed = now.signed_duration_since(value);
if elapsed.num_seconds() < 0 {
@@ -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
@@ -34,6 +34,19 @@ mod query {
);
}
#[test]
fn account_query_should_parse_rate_limited_status() {
let query: ListQuery = serde_json::from_value(json!({
"status": "rate_limited"
}))
.expect("deserialize account query");
assert_eq!(
query.validate().expect("validate account query").status,
Some(AccountStatus::RateLimited)
);
}
#[test]
fn account_query_should_reject_unbounded_page_size() {
let query: ListQuery =
@@ -331,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(),
@@ -365,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",
@@ -607,6 +607,8 @@ pub struct AccountStateChange {
pub expected_revision: CredentialRevision,
pub availability: AccountAvailability,
pub observed_at: SystemTime,
/// 上游原因(错误原文 / 原因码);`Some` 写入持久化错误信息,`None` 不更新,恢复 `Ready` 时清空。
pub message: Option<String>,
}
/// `provider_accounts` 的数据库中立端口。
@@ -247,6 +247,7 @@ impl ProviderAccountStore for PgProviderAccountRepository {
.map_err(core_store_error)?,
availability: change.availability,
availability_observed_at: DateTime::<Utc>::from(change.observed_at),
message: change.message,
})
.await
.map_err(core_store_error)?;
@@ -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,26 +61,16 @@ pub(crate) fn admin_account_status(
account: &ProviderAccountSummary,
now: DateTime<Utc>,
) -> 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::Expired
|| account.availability == AccountAvailability::Invalid
|| 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(
@@ -87,23 +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,
unavailable: 0,
rate_limited: 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
| AdminAccountStatus::Expired
| AdminAccountStatus::Invalid
| AdminAccountStatus::Disabled
| AdminAccountStatus::Banned => {
summary.unavailable = summary.unavailable.saturating_add(1);
AdminAccountStatus::RateLimited => {
summary.rate_limited = summary.rate_limited.saturating_add(1);
}
AdminAccountStatus::Disabled => {
summary.disabled = summary.disabled.saturating_add(1);
}
AdminAccountStatus::Error => summary.error = summary.error.saturating_add(1),
}
}
summary
@@ -120,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
@@ -152,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<AccountRecord> {
@@ -189,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,
@@ -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"))?;
@@ -90,6 +90,8 @@ pub struct ProviderAccountSummary {
pub quota_observed_at: Option<DateTime<Utc>>,
/// 快照级限流事实(Provider 物化)。
pub quota_limit_reached: bool,
/// 最近一次非 ready 状态变更的原始原因(上游错误原文 / 原因码);恢复 ready 后清空。
pub last_error_message: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
@@ -310,6 +312,8 @@ pub struct ProviderAccountStateUpdate {
pub expected_revision: Revision,
pub availability: AccountAvailability,
pub availability_observed_at: DateTime<Utc>,
/// 上游原因(错误原文 / 原因码);`None` 不更新错误信息列,恢复 `Ready` 时清空。
pub message: Option<String>,
}
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")?,
})
@@ -156,7 +156,7 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly
.expect("drop migration test database");
admin.close().await;
assert_eq!((first_table_count, migration_count), (10, 4));
assert_eq!((first_table_count, migration_count), (10, 1));
assert_eq!(response_id_types, ["bytea", "bytea"]);
assert!(!raw_response_id_index_exists);
}
@@ -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<String> =
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<String> =
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 {
@@ -231,6 +288,21 @@ async fn terminal_admin_list_filters_and_sorts_before_pagination_with_retained_u
.await
.expect("insert account list fixture");
}
repository
.compare_and_swap_quota(QuotaObservation {
account_id: ProviderAccountId::new("acct_charlie").expect("account id"),
expected_revision: CredentialRevision::new(1).expect("credential revision"),
quota: Some(OpaqueProviderData::new(
json!({"rate_limit": {"limit_reached": true}})
.as_object()
.expect("quota object")
.clone(),
)),
observed_at: Some(SystemTime::now()),
limit_reached: Some(true),
})
.await
.expect("seed rate-limited account");
seed_model_request(
&database.pool,
@@ -307,14 +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, 2);
assert_eq!(usage_page.summary.normal, 1);
assert_eq!(usage_page.summary.quota_exhausted, 1);
assert_eq!(usage_page.summary.unavailable, 3);
assert_eq!(usage_page.summary.rate_limited, 1);
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.unavailable
+ usage_page.summary.rate_limited
+ usage_page.summary.disabled
+ usage_page.summary.error
);
assert_eq!(
usage_page
@@ -354,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
@@ -364,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;
}
@@ -1193,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,
@@ -1231,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,
@@ -1280,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,
@@ -627,7 +627,7 @@ impl CodexCredentialQuotaService {
expected_revision: account.revision(),
quota: Some(OpaqueProviderData::new(quota)),
observed_at: Some(observed_at),
limit_reached: Some(true),
limit_reached: Some(fact.exhausted()),
})
.await?;
if outcome == QuotaWriteOutcome::Conflict {
@@ -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<String>,
) -> Result<(), CredentialRepositoryError> {
if !account.enabled() {
return Ok(());
@@ -244,6 +256,7 @@ impl CodexCredentialRepository {
expected_revision: account.revision(),
availability,
observed_at,
message,
})
.await?;
Ok(())
@@ -685,26 +685,32 @@ impl CodexCredentialSelector {
&self,
account: &ProviderAccount,
failure: CodexAccountFailure,
message: Option<String>,
) -> 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<String>,
) -> Result<(), CredentialSelectionError> {
self.repository
.apply_state(account, availability, observed_at)
.apply_state_with_message(account, availability, observed_at, message)
.await?;
Ok(())
}
@@ -1697,6 +1697,8 @@ fn map_selection_error(error: CredentialSelectionError) -> ProviderError {
struct MappedProviderFailure {
error: ProviderError,
account_failure: Option<CodexAccountFailure>,
/// 原始上游错误描述,随 availability 状态一并持久化。
error_message: Option<String>,
cyber_policy_failure: bool,
set_cookie_headers: Vec<String>,
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,
@@ -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,
@@ -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(&current, CodexAccountFailure::QuotaExhausted))
block_on(selector.record_failure(&current, CodexAccountFailure::QuotaExhausted, None))
.expect("record failure with current revision");
assert_eq!(
store
@@ -193,6 +193,40 @@ async fn passive_full_percent_with_explicit_allowance_keeps_account_ready() {
);
}
#[tokio::test]
async fn passive_headers_should_materialize_false_when_quota_is_not_exhausted() {
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, "acct_passive_quota_available").await;
let account = store
.account("acct_passive_quota_available")
.expect("account");
let service = quota_service(&store);
let headers = vec![
("x-codex-active-limit".to_owned(), "codex".to_owned()),
("x-codex-primary-used-percent".to_owned(), "31".to_owned()),
("x-codex-allowed".to_owned(), "true".to_owned()),
("x-codex-limit-reached".to_owned(), "false".to_owned()),
];
assert!(
service
.synchronize_passive_headers(&account, &headers)
.await
.expect("passive quota")
);
let observations = store
.get_quotas(std::slice::from_ref(account.id()))
.await
.expect("read quota observation");
assert_eq!(
observations
.first()
.and_then(|observation| observation.limit_reached),
Some(false)
);
}
#[tokio::test]
async fn manual_quota_refresh_preserves_disabled_account_state() {
let store = Arc::new(MemoryAccountStore::default());
@@ -201,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,
@@ -254,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,
@@ -460,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,
@@ -526,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,
@@ -565,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,
@@ -605,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,
@@ -637,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,
@@ -662,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,
@@ -727,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,
@@ -817,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,
@@ -866,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,
@@ -889,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,
@@ -943,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,
@@ -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,
@@ -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)
@@ -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,
@@ -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,
+1 -4
View File
@@ -1,4 +1 @@
54be07512951a561391b6e611355a1bfc119d82363253fb9bbdb225c9e3e39bb 0001_initial.sql
f480ac4cb70f6963eee71c0b8a67777ebb492f85501c90ebe7544bee0b2c0525 0002_remove_account_cooldown.sql
5cf5b5387c66b4d437d79c002cec46cb85196789060fa87c39e97a382487cac9 0004_fix_quota_limit_reached_null.sql
a3f236d558e7598f69277520458c801ee43130f7704ca25455df468cf8899ff2 0003_materialize_quota_limit_reached.sql
5f79e74c9a7a7366c39cd1afe43175eff6c44afe9217c0f26f33f173bd40c781 0001_initial.sql
+4 -18
View File
@@ -131,9 +131,9 @@ create table provider_accounts (
next_refresh_at timestamptz,
enabled boolean not null default true,
availability text not null default 'unknown',
availability_reason text,
cooldown_until timestamptz,
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,
@@ -163,23 +163,12 @@ create table provider_accounts (
availability in (
'unknown',
'ready',
'cooldown',
'quota_exhausted',
'expired',
'banned',
'invalid'
)
),
constraint provider_accounts_cooldown_ck check (
(
availability != 'cooldown'
or cooldown_until is not null
)
and (
cooldown_until is null
or availability in ('cooldown', 'quota_exhausted')
)
),
constraint provider_accounts_time_ck check (
created_at <= updated_at
and availability_observed_at <= updated_at
@@ -196,7 +185,7 @@ create unique index provider_accounts_upstream_identity_uq
create index provider_accounts_runtime_idx
on provider_accounts (provider_kind, enabled, id);
create index provider_accounts_availability_idx
on provider_accounts (availability, cooldown_until, id);
on provider_accounts (availability, id);
create index provider_accounts_access_expiry_idx
on provider_accounts (access_token_expires_at, id);
create index provider_accounts_refresh_due_idx
@@ -235,8 +224,6 @@ create table model_requests (
outcome text not null default 'running',
client_status_code integer,
upstream_status_code integer,
-- Provider response IDs are opaque JSON strings. Bytes preserve embedded NUL
-- and avoid imposing PostgreSQL text-index limits on protocol handles.
client_response_id bytea,
upstream_request_id text,
upstream_response_id bytea,
@@ -438,6 +425,7 @@ create index model_requests_running_deadline_idx
create index model_requests_retention_idx
on model_requests (completed_at)
where outcome <> 'running';
-- 运行期警告与错误事件。
create table ops_events (
id text primary key,
@@ -691,11 +679,9 @@ create unique index backup_records_active_uq
create unique index backup_records_object_key_uq
on backup_records (object_key)
where object_key is not null;
-- 同时服务 retentionDays 与 retentionCount。
create index backup_records_scheduled_completed_idx
on backup_records (completed_at desc, id desc)
where trigger_kind = 'scheduled' and status = 'completed';
-- 可清理记录(expires_at 已到期且非活跃)的扫描索引。
create index backup_records_retention_idx
on backup_records (expires_at)
where expires_at is not null and status in ('completed', 'failed');
@@ -1,43 +0,0 @@
-- 移除账号级 cooldown 状态模型(照 v2 语义:429 限流不进 availability,由 quota 数据驱动)。
--
-- 存量迁移:
-- - availability='cooldown' 的账号:429/Cloudflare 等临时冷却 → 'ready'
-- (限流事实留在 provider_quota_json,由投影滚动兜底);
-- 402 语义的 usage_limit_exhausted 且未到期 → 'quota_exhausted'。
-- 1. 迁移存量 cooldown 账号。
update provider_accounts
set availability = case
when availability_reason = 'usage_limit_exhausted'
and cooldown_until is not null
and cooldown_until > now()
then 'quota_exhausted'
else 'ready'
end
where availability = 'cooldown';
-- 2. 删除依赖 cooldown_until 的约束与索引。
alter table provider_accounts drop constraint provider_accounts_cooldown_ck;
drop index provider_accounts_availability_idx;
-- 3. availability CHECK 去掉 'cooldown'。
alter table provider_accounts drop constraint provider_accounts_availability_ck;
alter table provider_accounts
add constraint provider_accounts_availability_ck check (
availability in (
'unknown',
'ready',
'quota_exhausted',
'expired',
'banned',
'invalid'
)
);
-- 4. 删除列。
alter table provider_accounts drop column availability_reason;
alter table provider_accounts drop column cooldown_until;
-- 5. 重建可用性索引(不含 cooldown_until)。
create index provider_accounts_availability_idx
on provider_accounts (availability, id);
@@ -1,20 +0,0 @@
-- 物化快照级限流事实:Dashboard 复用 Provider 解析结果,不再 SQL 解析 raw quota JSON。
--
-- `quota_limit_reached` 由 Provider 在写 quota 时计算(快照级:顶层或任一窗口触顶,
-- 已过 reset 的窗口不计数),Admin/Dashboard 直接读该列。
alter table provider_accounts
add column quota_limit_reached boolean not null default false;
-- 存量回填:从现有 raw JSON 推导(仅 primary_window 的 limit_reached + reset 未过期)。
update provider_accounts
set quota_limit_reached = coalesce(
provider_quota_json is not null
and provider_quota_json->'rate_limit'->>'limit_reached' = 'true'
and (
provider_quota_json->'rate_limit'->'primary_window'->>'reset_at' is null
or (provider_quota_json->'rate_limit'->'primary_window'->>'reset_at')::bigint > extract(epoch from now())::bigint
),
false
)
where provider_quota_json is not null;
@@ -1,8 +0,0 @@
-- 修正 0003 存量回填:raw JSON 缺 rate_limit 键时表达式为 NULL,
-- 赋 NULL 违反 not-null 约束。0004 把仍为 NULL 的行兜底为 false
-- (0003 已应用的环境,add column 用 default false 填充,通常无 NULL;
-- 本迁移保证任何环境下该列都非 NULL)。
update provider_accounts
set quota_limit_reached = coalesce(quota_limit_reached, false)
where quota_limit_reached is null;
+6 -2
View File
@@ -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,9 +129,11 @@ export interface AccountPageMeta {
export interface AccountSummary {
total: number
active: number
normal: number
quotaExhausted: number
unavailable: number
rateLimited: number
disabled: number
error: number
}
export interface AccountListResponse {
+6 -4
View File
@@ -108,11 +108,13 @@ const labelClasses = computed(() => [
:title="title || (iconOnly ? label : undefined)"
:aria-busy="loading"
>
<LoaderCircle
<span
v-if="loading"
class="shrink-0 animate-spin origin-center transform-view will-change-transform"
:size="loadingIconSize[size]"
/>
class="inline-flex shrink-0 animate-spin origin-center will-change-transform"
aria-hidden="true"
>
<LoaderCircle :size="loadingIconSize[size]" />
</span>
<span v-if="$slots.icon && !loading" class="inline-flex shrink-0">
<slot name="icon" />
</span>
+26 -20
View File
@@ -1,14 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
type CardVariant = 'default' | 'dashboard' | 'elevated'
type CardPadding = 'default' | 'compact'
type CardLayout = 'content' | 'flush'
type HeaderCollapseAt = 'sm' | 'lg' | 'none'
const props = withDefaults(
defineProps<{
as?: keyof HTMLElementTagNameMap
padded?: boolean
variant?: CardVariant
padding?: CardPadding
layout?: CardLayout
title?: string
description?: string
headerCollapseAt?: HeaderCollapseAt
@@ -17,8 +18,8 @@ const props = withDefaults(
}>(),
{
as: 'section',
padded: true,
variant: 'default',
padding: 'default',
layout: 'content',
title: undefined,
description: undefined,
headerCollapseAt: 'sm',
@@ -36,16 +37,9 @@ const slots = defineSlots<{
default?: () => unknown
}>()
const paddingClasses: Record<CardVariant, string> = {
default: 'px-5 py-3',
dashboard: 'px-7 py-5.5',
elevated: 'px-5 py-3',
}
const shadowClasses: Record<CardVariant, string> = {
default: 'shadow-(--cp-shadow-card)',
dashboard: 'shadow-(--cp-shadow-card)',
elevated: 'shadow-(--cp-shadow-popover)',
const paddingClasses: Record<CardPadding, string> = {
default: 'p-5.5',
compact: 'p-4',
}
const hasManagedHeader = computed(
@@ -53,6 +47,12 @@ const hasManagedHeader = computed(
!!props.title || !!props.description || !!slots.actions || !!slots.title || !!slots.description,
)
const outerPaddingClass = computed(() => {
if (props.layout === 'flush')
return undefined
return paddingClasses[props.padding]
})
const managedHeaderLayoutClasses = computed(() => {
if (props.headerCollapseAt === 'none') {
return 'flex items-start justify-between gap-3'
@@ -69,14 +69,17 @@ const managedHeaderLayoutClasses = computed(() => {
<template>
<component
:is="props.as"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden bg-(--cp-bg-surface) rounded-(--cp-card-radius)"
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-card)"
:class="[
shadowClasses[props.variant],
props.padded ? paddingClasses[props.variant] : undefined,
outerPaddingClass,
]"
>
<template v-if="$slots.header || hasManagedHeader || $slots.body">
<header v-if="$slots.header || hasManagedHeader" class="shrink-0" :class="props.headerClass">
<header
v-if="$slots.header || hasManagedHeader"
class="shrink-0"
:class="props.headerClass"
>
<slot name="header">
<div :class="managedHeaderLayoutClasses">
<div class="min-w-0 pt-0.5">
@@ -105,7 +108,10 @@ const managedHeaderLayoutClasses = computed(() => {
</slot>
</header>
<div v-if="$slots.body || $slots.default" :class="props.bodyClass">
<div
v-if="$slots.body || $slots.default"
:class="props.bodyClass"
>
<slot name="body">
<slot />
</slot>
@@ -1,12 +1,20 @@
<script setup lang="ts">
defineProps<{
const props = defineProps<{
title: string
description?: string
actionsInline?: boolean
}>()
</script>
<template>
<header class="grid min-h-17 shrink-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-x-4 gap-y-3">
<header
class="grid min-h-17 shrink-0 items-start"
:class="
props.actionsInline
? 'grid-cols-[minmax(0,1fr)_auto] gap-x-4'
: 'grid-cols-1 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-x-4'
"
>
<div class="min-w-0">
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
{{ title }}
@@ -23,6 +31,7 @@ defineProps<{
<div
v-if="$slots.actions"
class="mt-0.5 flex shrink-0 items-center justify-end gap-2"
:class="props.actionsInline ? undefined : 'max-sm:w-full'"
>
<slot name="actions" />
</div>
@@ -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"
>
<AccountIdentityCell :account="account" size="lg" show-plan />
<AccountStatusBadge :status="account.status" variant="pill" />
<AccountStatusBadge
:status="account.status"
:error-reason="account.errorReason"
:error-message="account.errorMessage"
variant="pill"
/>
</section>
<section class="rounded-(--cp-card-radius) bg-(--cp-bg-subtle) px-4 py-3">
@@ -54,10 +54,13 @@ const provider = defineModel<string>('provider', { required: true })
/>
</div>
<div class="flex shrink-0 self-end items-center justify-end gap-2 md:ml-auto">
<div
class="grid w-full grid-cols-2 gap-2 md:flex md:w-auto md:shrink-0 md:self-end md:items-center md:justify-end md:ml-auto"
>
<BaseButton
v-if="selectedCount > 0"
variant="danger"
class="w-full whitespace-nowrap md:w-auto"
:disabled="batchDeleting"
@click="emit('deleteSelected')"
>
@@ -67,13 +70,19 @@ const provider = defineModel<string>('provider', { required: true })
<BaseButton
v-if="selectedCount > 0"
variant="default"
class="w-full whitespace-nowrap md:w-auto"
:loading="exportingAccounts"
@click="emit('exportSelected')"
>
<Download class="size-4" />
导出选中 ({{ selectedCount }})
</BaseButton>
<BaseButton variant="primary" @click="emit('create')">
<BaseButton
variant="primary"
class="whitespace-nowrap md:w-auto"
:class="selectedCount > 0 ? 'col-span-2 w-full' : 'col-span-2 justify-self-end'"
@click="emit('create')"
>
<Upload class="size-4" />
导入账号
</BaseButton>
@@ -20,22 +20,22 @@ const overviewItems = computed(() => [
},
{
label: '正常账号',
value: formatCount(props.summary.active),
value: formatCount(props.summary.normal),
caption: '可参与调度',
tone: 'success',
icon: ShieldCheck,
},
{
label: '配额耗尽',
value: formatCount(props.summary.quotaExhausted),
caption: '等待配额恢复',
label: '额度受限',
value: formatCount((props.summary.quotaExhausted ?? 0) + (props.summary.rateLimited ?? 0)),
caption: '等待额度恢复',
tone: 'warning',
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,
},
@@ -65,16 +65,15 @@ function overviewIconClass(tone: string) {
v-for="item in overviewItems"
:key="item.label"
as="article"
:padded="false"
class="h-24"
padding="compact"
>
<div class="flex h-full items-stretch justify-between gap-3 px-5 py-3">
<div class="flex min-w-0 flex-col justify-between">
<div class="flex items-stretch justify-between gap-3">
<div class="flex min-w-0 flex-col">
<p class="m-0 text-[12px] leading-none font-heavy text-(--cp-text-secondary)">
{{ item.label }}
</p>
<strong
class="block font-mono text-[26px] leading-none font-extrabold text-(--cp-text-primary)"
class="my-2 block font-mono text-[26px] leading-none font-extrabold text-(--cp-text-primary)"
>
{{ item.value }}
</strong>
@@ -1,20 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { statusLabels, statusTones } from '../constants'
import BasePopover from '@/components/base/BasePopover.vue'
import { errorReasonLabels, statusLabels, statusTones } from '../constants'
const props = withDefaults(
defineProps<{
status: string
/** `status === 'error'` 时的具体原因(对应后端 `errorReason`)。 */
errorReason?: string | null
/** 最近一次失败的上游错误原文(对应后端 `errorMessage`),作为辅助证据展示。 */
errorMessage?: string | null
variant?: 'inline' | 'pill'
}>(),
{
errorReason: null,
errorMessage: null,
variant: 'inline',
},
)
const tone = computed(() => statusTones[props.status])
const label = computed(() => statusLabels[props.status] || props.status)
/** 分类原因(受控枚举映射文案);`status === 'error'` 时有值。 */
const reasonLabel = computed(() => {
if (props.errorReason)
return errorReasonLabels[props.errorReason] || props.errorReason
return null
})
/** 上游错误原文(辅助证据);有则显、无则隐。 */
const errorText = computed(() => props.errorMessage || null)
const hasDetail = computed(() => Boolean(reasonLabel.value) || Boolean(errorText.value))
const textClass = computed(() => {
if (tone.value === 'success') {
@@ -50,19 +66,35 @@ const dotClass = computed(() => {
</script>
<template>
<span
v-if="variant === 'pill'"
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-[12px] font-heavy"
:class="textClass"
<BasePopover
:disabled="!hasDetail"
trigger="hover"
placement="top-start"
:width="240"
panel-class="p-2"
>
{{ label }}
</span>
<span
v-else
class="inline-flex min-w-16 items-center gap-1.5 text-[12px] leading-none font-emphasis"
:class="textClass"
>
<span class="size-1.5 rounded-full" :class="dotClass" />
<span>{{ label }}</span>
</span>
<template #trigger>
<span
v-if="variant === 'pill'"
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-[12px] font-heavy"
:class="textClass"
>
{{ label }}
</span>
<span
v-else
class="inline-flex min-w-16 items-center gap-1.5 text-[12px] leading-none font-emphasis"
:class="textClass"
>
<span class="size-1.5 rounded-full" :class="dotClass" />
<span>{{ label }}</span>
</span>
</template>
<p v-if="reasonLabel" class="m-0 text-[12px] font-medium leading-relaxed text-(--cp-text-primary)">
{{ reasonLabel }}
</p>
<p v-if="errorText" class="m-0 mt-1 text-[12px] leading-relaxed text-(--cp-text-secondary)">
{{ errorText }}
</p>
</BasePopover>
</template>
@@ -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')
</script>
@@ -24,11 +24,11 @@ import { useAccountOnboarding } from './useAccountOnboarding'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
const accountStatusSortRank: Record<string, number> = {
active: 0,
normal: 0,
quota_exhausted: 1,
expired: 2,
rate_limited: 2,
disabled: 3,
banned: 4,
error: 4,
}
export function useAccountMutations(options: {
@@ -232,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,
@@ -244,26 +244,34 @@ export function useAccountMutations(options: {
+ Number(status === 'quota_exhausted')
- Number(current.status === 'quota_exhausted'),
),
unavailable: Math.max(
rateLimited: Math.max(
0,
options.accountSummary.value.unavailable
+ Number(isUnavailableAccountStatus(status))
- Number(isUnavailableAccountStatus(current.status)),
options.accountSummary.value.rateLimited
+ Number(status === 'rate_limited')
- Number(current.status === 'rate_limited'),
),
disabled: Math.max(
0,
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 === 'disabled' || status === 'banned'
}
function sortAccountsByStatus(rows: AccountRow[]) {
if (options.sort.value?.key !== 'status')
return rows
const direction = options.sort.value.direction === 'asc' ? 1 : -1
return [...rows].sort((left, right) => {
const rankDifference
= (accountStatusSortRank[left.status] ?? 5) - (accountStatusSortRank[right.status] ?? 5)
= (accountStatusSortRank[left.status] ?? 7) - (accountStatusSortRank[right.status] ?? 7)
return rankDifference === 0
? left.id.localeCompare(right.id) * direction
: rankDifference * direction
@@ -14,9 +14,11 @@ export function useAccountsQuery() {
const sort = shallowRef<BaseTableSort>()
const accountSummary = shallowRef({
total: 0,
active: 0,
normal: 0,
quotaExhausted: 0,
unavailable: 0,
rateLimited: 0,
disabled: 0,
error: 0,
})
const query = usePagedQuery({
+19 -12
View File
@@ -123,34 +123,41 @@ export const accountColumns = [
] satisfies BaseTableColumn<AccountRow>[]
export const statusLabels: Record<string, string> = {
active: '正常',
expired: '已过期',
disabled: '已禁用',
banned: '已封禁',
normal: '正常',
quota_exhausted: '配额耗尽',
rate_limited: '限流中',
disabled: '已停用',
error: '错误',
}
export const statusTones: Record<string, 'success' | 'danger' | 'warning' | 'info' | 'normal'> = {
active: 'success',
expired: '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.expired, value: 'expired' },
{ label: statusLabels.rate_limited, value: 'rate_limited' },
{ label: statusLabels.disabled, value: 'disabled' },
{ label: statusLabels.banned, value: 'banned' },
{ label: statusLabels.error, value: 'error' },
]
/** `error` 分类下具体原因的展示文案(对应后端 `errorReason`)。 */
export const errorReasonLabels: Record<string, string> = {
expired: '账号已过期',
token_expired: '访问令牌已过期',
invalid: '凭据已失效',
banned: '账号已封禁',
unknown: '状态未知',
}
/**
* 后端已派生互斥状态(含 `rate_limited`,由快照级 quota.limitReached 产生);
* 后端已派生互斥状态(正常 / 配额耗尽 / 限流中 / 已停用 / 错误);
* 前端只渲染,不再独立派生。
*/
export function derivedAccountStatus(row: AccountRow): string {
+7 -5
View File
@@ -126,10 +126,8 @@ const {
<AccountOverviewCards :summary="accountSummary" />
<BaseCard
:padded="false"
class="mt-4 flex h-[calc(100dvh-248px)] min-h-125 flex-col"
header-class="px-4 pt-4 pb-2 md:px-5"
body-class="flex min-h-0 flex-1 px-4 pb-3 md:px-5"
class="mt-4 flex h-[calc(100dvh-250px)] min-h-125 flex-col"
body-class="mt-3 flex min-h-0 flex-1"
>
<template #header>
<AccountFilters
@@ -204,7 +202,11 @@ const {
</template>
<template #status="{ row }">
<AccountStatusBadge :status="derivedAccountStatus(row)" />
<AccountStatusBadge
:status="derivedAccountStatus(row)"
:error-reason="row.errorReason"
:error-message="row.errorMessage"
/>
</template>
<template #planType="{ row }">
@@ -19,11 +19,11 @@ const search = defineModel<string>('search', { required: true })
<template>
<div
class="flex w-full flex-col gap-3 md:flex-row md:flex-wrap md:items-center"
class="flex w-full flex-wrap items-center gap-3"
role="group"
aria-label="API Key 筛选与操作"
>
<div class="min-w-0 w-full md:w-96 md:flex-none">
<div class="min-w-0 flex-1 md:w-96 md:flex-none">
<BaseInput v-model="search" placeholder="搜索名称、标签或 ID" class="w-full">
<template #prefix>
<Search class="size-4.5 text-(--cp-text-tertiary)" />
@@ -31,7 +31,7 @@ const search = defineModel<string>('search', { required: true })
</BaseInput>
</div>
<div class="flex shrink-0 self-end items-center justify-end gap-2 md:ml-auto">
<div class="flex shrink-0 items-center justify-end gap-2 md:ml-auto">
<BaseButton
v-if="selectedCount > 0"
variant="danger"
+1 -3
View File
@@ -88,10 +88,8 @@ const {
/>
<BaseCard
:padded="false"
class="mt-5 flex h-[calc(100dvh-136px)] min-h-125 flex-col"
header-class="px-5 pt-4"
body-class="flex min-h-0 flex-1 px-5 py-3"
body-class="mt-3 flex min-h-0 flex-1"
>
<template #header>
<ApiKeyFilters
@@ -90,7 +90,9 @@ const statusRows = computed(() => {
}
const active = p?.active ?? 0
const refreshing = p?.refreshing ?? 0
const quota = p?.quotaExhausted ?? 0
const rateLimited = p?.rateLimited ?? 0
const quotaExhausted = p?.quotaExhausted ?? 0
const quotaLimited = rateLimited + quotaExhausted
const expired = p?.expired ?? 0
const invalid = p?.invalid ?? 0
const disabled = p?.disabled ?? 0
@@ -113,16 +115,9 @@ const statusRows = computed(() => {
icon: RefreshCw,
},
{
label: '限流中',
description: '任一额度窗口已触顶',
value: String(p?.rateLimited ?? 0),
tone: 'warning',
icon: TriangleAlert,
},
{
label: '配额耗尽',
description: '已确认 402 耗尽',
value: String(quota),
label: '额度受限',
description: `限流中 ${rateLimited} · 配额耗尽 ${quotaExhausted}`,
value: String(quotaLimited),
tone: 'warning',
icon: TriangleAlert,
},
@@ -149,23 +144,21 @@ const statusBars = computed(() => {
return []
const active = (p.active / p.total) * 100
const refreshing = ((p.refreshing ?? 0) / p.total) * 100
const rateLimited = ((p.rateLimited ?? 0) / p.total) * 100
const quota = (p.quotaExhausted / p.total) * 100
const quotaLimited = (((p.rateLimited ?? 0) + p.quotaExhausted) / p.total) * 100
const unavailable = ((p.expired + (p.invalid ?? 0) + p.disabled + p.banned) / p.total) * 100
return [
{ pct: active, cls: 'bg-(--cp-success)' },
{ pct: refreshing, cls: 'bg-(--cp-normal)' },
{ pct: rateLimited, cls: 'bg-(--cp-warning)' },
{ pct: quota, cls: 'bg-(--cp-warning)' },
{ pct: quotaLimited, cls: 'bg-(--cp-warning)' },
{ pct: unavailable, cls: 'bg-(--cp-danger)' },
].filter(b => b.pct > 0)
})
</script>
<template>
<BaseCard as="article" :padded="false" class="w-full xl:h-112.5">
<BaseCard as="article" class="w-full xl:h-112.5">
<div
class="grid px-4 pt-5 pb-6 lg:px-7 lg:pt-6 xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7 xl:pb-0"
class="grid xl:grid-cols-[minmax(0,0.9fr)_minmax(0,1.28fr)_minmax(280px,0.9fr)] xl:gap-7"
>
<section class="min-w-0 w-full pb-6 xl:h-100.5 xl:pb-0">
<h2 class="m-0 text-xl leading-[1.15] font-heavy text-(--cp-text-primary)">
@@ -69,7 +69,7 @@ const sparklineOption = computed<EChartsOption | null>(() => {
</script>
<template>
<BaseCard as="article" :padded="false" class="relative h-38.5 w-full px-6 pt-5">
<BaseCard as="article" padding="compact" class="relative h-38.5 w-full">
<div class="flex items-start gap-3">
<BaseMotionIcon
aria-hidden="true"
@@ -207,7 +207,6 @@ onBeforeUnmount(() => {
<template>
<BaseCard
as="article"
variant="dashboard"
:title="timeline.title"
:description="timeline.description"
header-collapse-at="lg"
@@ -39,7 +39,7 @@ const {
</script>
<template>
<BaseCard as="article" variant="dashboard" title="使用趋势" class="min-h-95 w-full">
<BaseCard as="article" title="使用趋势" class="min-h-95 w-full">
<template #actions>
<BaseSegmented
v-model="activeKind"
@@ -17,7 +17,6 @@ const dashboardUsageRecordColumns = usageRecordColumns.filter(column => column.k
<template>
<BaseCard
as="article"
variant="dashboard"
title="使用记录"
description="最近 10 条成功请求"
class="h-117 w-full"
@@ -171,7 +171,6 @@ function providerLabel(provider: string) {
<template>
<BaseCard
as="article"
variant="dashboard"
title="上游请求身份"
body-class="flex min-h-0 flex-1 flex-col"
class="flex min-h-95 w-full flex-col"
+1 -1
View File
@@ -35,7 +35,7 @@ const {
<template>
<div class="w-full">
<BasePageHeader title="系统概览">
<BasePageHeader title="系统概览" actions-inline>
<template #description>
<span>当日统计</span>
<DashboardHeartbeat :updated-at="lastRefreshedAt" />
@@ -48,8 +48,7 @@ function togglePasswordVisible(): void {
<template>
<BaseCard
as="form"
:padded="false"
variant="elevated"
layout="flush"
class="login-form relative grid min-h-129.25 w-[min(440px,100%)] gap-3 rounded-lg px-7.5 pt-6.5 pb-6 max-[560px]:min-h-auto max-[560px]:gap-3 max-[560px]:p-5.5"
@submit.prevent="emit('submit')"
>
@@ -26,11 +26,9 @@ const emit = defineEmits<{
<template>
<BaseCard
:padded="false"
title="管理员 API Key"
description="用于外部系统集成,具有管理员权限"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<template #actions>
<div class="flex flex-wrap items-center gap-2">
@@ -23,11 +23,9 @@ const emit = defineEmits<{
<template>
<BaseCard
:padded="false"
title="模型映射"
description="配置请求模型与上游模型的映射关系"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-3">
@@ -14,11 +14,9 @@ const model = defineModel<RotationStrategy | ''>({ required: true })
<template>
<BaseCard
:padded="false"
title="调度策略"
description="决定每次请求如何调度账号池"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<div class="grid max-w-6xl gap-3 lg:grid-cols-4">
<button
@@ -14,11 +14,9 @@ const requestIntervalMs = defineModel<string>('requestIntervalMs', { required: t
<template>
<BaseCard
:padded="false"
title="运行参数"
description="请求节奏、账号并发和 Token 刷新"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<BaseForm :columns="2" class="max-w-6xl">
<BaseFormItem
@@ -90,11 +90,9 @@ function canDelete(record: BackupRecord): boolean {
<template>
<BaseCard
:padded="false"
title="备份记录"
description="创建手动备份和管理已有备份记录"
header-class="px-5 pt-4"
body-class="px-5 py-3"
body-class="mt-3"
>
<template #actions>
<div class="flex flex-wrap items-center gap-2">
@@ -40,11 +40,9 @@ const TIMEZONE_OPTIONS = [
<template>
<BaseCard
:padded="false"
title="定时备份"
description="配置自动定时备份"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<template #actions>
<BaseButton variant="primary" :loading="saving" :disabled="loading" @click="emit('save')">
@@ -51,10 +51,8 @@ function toggleSecretVisible(): void {
<template>
<BaseCard
:padded="false"
title="S3 存储配置"
header-class="px-5 pt-4"
body-class="px-5 py-5"
body-class="mt-5"
>
<template #description>
<span class="text-(--cp-text-secondary)">
+14 -17
View File
@@ -71,24 +71,9 @@ watch(
<template>
<div class="w-full">
<BasePageHeader title="系统设置" description="管理运行参数、调度策略、模型映射与备份配置">
<template #actions>
<BaseButton
v-if="section === 'runtime'"
variant="primary"
:loading="saving"
:disabled="loading"
@click="saveSettings"
>
<template #icon>
<Save class="size-4" />
</template>
{{ saving ? '保存中...' : '保存' }}
</BaseButton>
</template>
</BasePageHeader>
<BasePageHeader title="系统设置" description="管理运行参数、调度策略、模型映射与备份配置" />
<div class="mt-4">
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<BaseSegmented
:model-value="section"
class="bg-(--cp-input-soft-bg)!"
@@ -99,6 +84,18 @@ watch(
aria-label="设置分区"
@update:model-value="switchSection"
/>
<BaseButton
v-if="section === 'runtime'"
variant="primary"
:loading="saving"
:disabled="loading"
@click="saveSettings"
>
<template #icon>
<Save class="size-4" />
</template>
{{ saving ? '保存中...' : '保存' }}
</BaseButton>
</div>
<div v-show="section === 'runtime'" class="mt-5 grid w-full gap-5">
@@ -239,12 +239,10 @@ function formatTooltip(params: unknown) {
<template>
<BaseCard
as="article"
:padded="false"
title="成本效率"
description="预估费用、Token 与缓存收益"
header-collapse-at="none"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
body-class="mt-3"
class="h-full min-h-90"
>
<template #actions>
@@ -114,12 +114,10 @@ function diagnosticNameDisplay(name: string) {
<template>
<BaseCard
as="article"
:padded="false"
title="热点诊断"
:description="`按${selectedDimensionLabel}定位错误、慢请求与费用热点`"
header-collapse-at="lg"
header-class="px-5 pt-4"
body-class="min-h-0 min-w-0 px-5 pt-3"
body-class="mt-3 min-h-0 min-w-0"
class="grid h-105 min-h-105 max-h-105 min-w-0 w-full grid-rows-[auto_minmax(0,1fr)] lg:h-90 lg:min-h-90 lg:max-h-90"
>
<template #actions>
@@ -146,11 +146,9 @@ function formatTooltip(params: unknown) {
<template>
<BaseCard
as="article"
:padded="false"
title="请求健康"
:description="`按${granularityText}观察请求量、成功率与失败时段`"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
body-class="mt-3"
class="h-full min-h-90"
>
<template #body>
@@ -167,12 +167,10 @@ function formatTooltip(params: unknown) {
<template>
<BaseCard
as="article"
:padded="false"
title="响应速度"
description="总耗时或首字的分位耗时,越低越快"
header-collapse-at="none"
header-class="px-5 pt-4"
body-class="px-5 pt-3 pb-4"
body-class="mt-3"
class="h-full min-h-90"
>
<template #actions>
@@ -3,6 +3,7 @@ import type { getUsageRecordSummary } from '@/api'
import { Activity, Database, FileText, Timer } from '@lucide/vue'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
const props = defineProps<{
@@ -54,10 +55,12 @@ const items = computed(() => [
class="mt-5 grid shrink-0 grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4"
aria-label="使用概览"
>
<article
<BaseCard
v-for="item in items"
:key="item.key"
class="grid min-h-23 grid-cols-[36px_minmax(0,1fr)] items-stretch gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-surface) px-4 py-3 shadow-(--cp-shadow-card)"
as="article"
padding="compact"
class="grid min-h-23 grid-cols-[36px_minmax(0,1fr)] items-stretch gap-3"
>
<BaseMotionIcon
aria-hidden="true"
@@ -79,6 +82,6 @@ const items = computed(() => [
{{ item.detail }}
</span>
</div>
</article>
</BaseCard>
</section>
</template>
+1
View File
@@ -5,6 +5,7 @@ export const usageRecordColumns = [
key: 'accountEmail',
label: '账号',
width: '280px',
fixed: false as const,
ellipsis: true,
},
{
+1 -3
View File
@@ -79,10 +79,8 @@ watch(timeRange, () => {
/>
<BaseCard
:padded="false"
class="mt-5 flex flex-col"
header-class="px-5 pt-4"
body-class="flex min-h-0 flex-col px-5 pt-3 pb-4"
body-class="mt-3 flex min-h-0 flex-col"
>
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
+1 -1
View File
@@ -1 +1 @@
version: "3.1.2"
version: "3.1.3"