feat(oauth): 完善账号异常识别并在调度/展示层拦截失效 OAuth 密钥

- 新增 aether-admin provider status 模块,统一解析账号状态(禁用/工作区停用等)
- 调度器 runtime 增加 oauth_invalid 判定,跳过刷新失败或已撤销的 OAuth 密钥(REQUEST_FAILED 保留可选)
- gateway state 在 local oauth 刷新返回 4xx 时持久化失败原因并同步状态快照
- admin pool 列表/详情回填 account 状态与 scheduling 阻塞原因(account_blocked)
- 共享 catalog 的 status_snapshot payload 附加 account 字段
This commit is contained in:
fawney19
2026-04-19 20:50:31 +08:00
parent d719a1329c
commit 77aac74590
12 changed files with 1409 additions and 60 deletions

View File

@@ -674,6 +674,11 @@ fn admin_pool_scheduling_payload(
cooldown_ttl_seconds: Option<u64>,
health_score: f64,
circuit_breaker_open: bool,
account_blocked: bool,
account_status_code: Option<&str>,
account_status_label: Option<&str>,
account_status_reason: Option<&str>,
account_status_source: Option<&str>,
account_quota_exhausted: bool,
) -> (String, String, String, Vec<serde_json::Value>) {
if !key.is_active {
@@ -691,6 +696,21 @@ fn admin_pool_scheduling_payload(
})],
);
}
if account_blocked {
return (
"blocked".to_string(),
"account_blocked".to_string(),
account_status_label.unwrap_or("账号异常").to_string(),
vec![json!({
"code": account_status_code.unwrap_or("account_blocked"),
"label": account_status_label.unwrap_or("账号异常"),
"blocking": true,
"source": account_status_source,
"ttl_seconds": serde_json::Value::Null,
"detail": account_status_reason,
})],
);
}
if account_quota_exhausted {
return (
"blocked".to_string(),
@@ -776,15 +796,6 @@ pub(super) fn build_admin_pool_key_payload(
.as_ref()
.is_some_and(|config| config.skip_exhausted_accounts)
&& admin_provider_pool_pure::admin_pool_key_account_quota_exhausted(key, provider_type);
let (scheduling_status, scheduling_reason, scheduling_label, scheduling_reasons) =
admin_pool_scheduling_payload(
key,
cooldown_reason.as_deref(),
cooldown_ttl_seconds,
health_score,
circuit_breaker_open,
account_quota_exhausted,
);
let auth_config = state.parse_catalog_auth_config_json(key);
let oauth_expires_at =
admin_pool_derive_oauth_expires_at(provider_type, key, auth_config.as_ref());
@@ -841,6 +852,20 @@ pub(super) fn build_admin_pool_key_payload(
.unwrap_or(false);
let account_status_source =
admin_pool_trimmed_string(account_snapshot.and_then(|item| item.get("source")));
let (scheduling_status, scheduling_reason, scheduling_label, scheduling_reasons) =
admin_pool_scheduling_payload(
key,
cooldown_reason.as_deref(),
cooldown_ttl_seconds,
health_score,
circuit_breaker_open,
account_status_blocked,
account_status_code.as_deref(),
account_status_label.as_deref(),
account_status_reason.as_deref(),
account_status_source.as_deref(),
account_quota_exhausted,
);
let mut payload = serde_json::Map::new();
payload.insert("key_id".to_string(), json!(key.id));

View File

@@ -2,6 +2,7 @@ use crate::handlers::shared::{json_string_list, unix_secs_to_rfc3339};
use crate::provider_key_auth::provider_key_auth_semantics;
use crate::AppState;
use aether_admin::provider::quota as admin_provider_quota_pure;
use aether_admin::provider::status as admin_provider_status_pure;
#[cfg(test)]
use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
use aether_crypto::{decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext};
@@ -154,6 +155,25 @@ pub(crate) fn default_provider_key_status_snapshot() -> serde_json::Value {
})
}
fn build_provider_key_account_status_snapshot(
key: &StoredProviderCatalogKey,
provider_type: &str,
) -> Value {
let snapshot = admin_provider_status_pure::resolve_account_status_snapshot(
Some(provider_type),
key.upstream_metadata.as_ref(),
key.oauth_invalid_reason.as_deref(),
);
json!({
"code": snapshot.code,
"label": snapshot.label,
"reason": snapshot.reason,
"blocked": snapshot.blocked,
"source": snapshot.source,
"recoverable": snapshot.recoverable,
})
}
fn provider_key_status_snapshot_object(
status_snapshot: Option<&Value>,
) -> Option<Map<String, Value>> {
@@ -817,20 +837,29 @@ pub(crate) fn provider_key_status_snapshot_payload(
.and_then(|snapshot| snapshot.get("quota"))
.and_then(Value::as_object);
if quota_snapshot_has_materialized_data(quota_snapshot, provider_type) {
return status_snapshot
let payload = if quota_snapshot_has_materialized_data(quota_snapshot, provider_type) {
status_snapshot
.cloned()
.unwrap_or_else(default_provider_key_status_snapshot);
}
.unwrap_or_else(default_provider_key_status_snapshot)
} else {
sync_provider_key_quota_status_snapshot(
status_snapshot,
provider_type,
key.upstream_metadata.as_ref(),
"catalog_fallback",
)
.or_else(|| status_snapshot.cloned())
.unwrap_or_else(default_provider_key_status_snapshot)
};
sync_provider_key_quota_status_snapshot(
status_snapshot,
provider_type,
key.upstream_metadata.as_ref(),
"catalog_fallback",
)
.or_else(|| status_snapshot.cloned())
.unwrap_or_else(default_provider_key_status_snapshot)
let mut snapshot = provider_key_status_snapshot_object(Some(&payload))
.or_else(|| default_provider_key_status_snapshot().as_object().cloned())
.unwrap_or_default();
snapshot.insert(
"account".to_string(),
build_provider_key_account_status_snapshot(key, provider_type),
);
Value::Object(snapshot)
}
pub(crate) fn provider_key_health_summary(
@@ -1540,4 +1569,47 @@ mod tests {
Some(2usize)
);
}
#[test]
fn provider_key_status_snapshot_payload_backfills_account_block_from_oauth_invalid_reason() {
let mut key = sample_catalog_key();
key.oauth_invalid_reason = Some("[ACCOUNT_BLOCK] account has been deactivated".to_string());
let payload = provider_key_status_snapshot_payload(&key, "codex");
let account = payload
.get("account")
.and_then(Value::as_object)
.expect("account snapshot should be object");
assert_eq!(account.get("code"), Some(&json!("account_disabled")));
assert_eq!(account.get("label"), Some(&json!("账号停用")));
assert_eq!(
account.get("reason"),
Some(&json!("account has been deactivated"))
);
assert_eq!(account.get("blocked"), Some(&json!(true)));
assert_eq!(account.get("source"), Some(&json!("oauth_invalid")));
}
#[test]
fn provider_key_status_snapshot_payload_backfills_workspace_deactivated_from_metadata() {
let mut key = sample_catalog_key();
key.upstream_metadata = Some(json!({
"codex": {
"account_disabled": true,
"reason": "deactivated_workspace"
}
}));
let payload = provider_key_status_snapshot_payload(&key, "codex");
let account = payload
.get("account")
.and_then(Value::as_object)
.expect("account snapshot should be object");
assert_eq!(account.get("code"), Some(&json!("workspace_deactivated")));
assert_eq!(account.get("label"), Some(&json!("工作区停用")));
assert_eq!(account.get("blocked"), Some(&json!(true)));
assert_eq!(account.get("source"), Some(&json!("metadata")));
}
}