mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
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:
@@ -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));
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user