mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat(oauth): 允许替换已失效的活跃 OAuth 账号并同步 status_snapshot
- 活跃但 token 已过期或刷新失败的重复账号视为可替换 - 清除失效标记、刷新配额时同步更新 status_snapshot.oauth - oauth_invalid 清除接口同时识别 invalid_at 与 invalid_reason 两种标记 - 批量导入任务状态区分 created_count / replaced_count,前端据此展示新增/替换统计 - 补充重复替换场景的集成测试,用量测试等待超时从 10s 提升到 30s 以适应并行压力
This commit is contained in:
@@ -39,7 +39,13 @@ pub(super) async fn maybe_handle(
|
||||
else {
|
||||
return Ok(Some(not_found_response(format!("Key {key_id} 不存在"))));
|
||||
};
|
||||
if key.oauth_invalid_at_unix_secs.is_none() {
|
||||
let has_invalid_marker = key.oauth_invalid_at_unix_secs.is_some()
|
||||
|| key
|
||||
.oauth_invalid_reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty());
|
||||
if !has_invalid_marker {
|
||||
return Ok(Some(
|
||||
Json(json!({
|
||||
"message": "该 Key 当前无失效标记,无需清除"
|
||||
|
||||
@@ -251,6 +251,8 @@ pub(super) fn build_admin_provider_oauth_batch_task_state(
|
||||
processed: usize,
|
||||
success: usize,
|
||||
failed: usize,
|
||||
created_count: usize,
|
||||
replaced_count: usize,
|
||||
message: Option<&str>,
|
||||
error: Option<&str>,
|
||||
error_samples: Vec<serde_json::Value>,
|
||||
@@ -277,6 +279,8 @@ pub(super) fn build_admin_provider_oauth_batch_task_state(
|
||||
"processed": processed,
|
||||
"success": success,
|
||||
"failed": failed,
|
||||
"created_count": created_count,
|
||||
"replaced_count": replaced_count,
|
||||
"progress_percent": progress_percent,
|
||||
"message": message,
|
||||
"error": error,
|
||||
|
||||
@@ -95,6 +95,8 @@ pub(in super::super) async fn handle_admin_provider_oauth_start_batch_import_tas
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some("任务已提交,等待执行"),
|
||||
None,
|
||||
Vec::new(),
|
||||
@@ -134,6 +136,8 @@ pub(in super::super) async fn handle_admin_provider_oauth_start_batch_import_tas
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some("任务开始执行"),
|
||||
None,
|
||||
Vec::new(),
|
||||
@@ -169,6 +173,16 @@ pub(in super::super) async fn handle_admin_provider_oauth_start_batch_import_tas
|
||||
.take(PROVIDER_OAUTH_BATCH_TASK_MAX_ERROR_SAMPLES)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let replaced_count = outcome
|
||||
.results
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.get("status").and_then(serde_json::Value::as_str) == Some("success")
|
||||
&& item.get("replaced").and_then(serde_json::Value::as_bool)
|
||||
== Some(true)
|
||||
})
|
||||
.count();
|
||||
let created_count = outcome.success.saturating_sub(replaced_count);
|
||||
let message = format!(
|
||||
"导入完成:成功 {},失败 {}",
|
||||
outcome.success, outcome.failed
|
||||
@@ -182,6 +196,8 @@ pub(in super::super) async fn handle_admin_provider_oauth_start_batch_import_tas
|
||||
outcome.total,
|
||||
outcome.success,
|
||||
outcome.failed,
|
||||
created_count,
|
||||
replaced_count,
|
||||
Some(message.as_str()),
|
||||
None,
|
||||
error_samples,
|
||||
@@ -209,6 +225,8 @@ pub(in super::super) async fn handle_admin_provider_oauth_start_batch_import_tas
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some("导入任务执行失败"),
|
||||
Some(error_message.as_str()),
|
||||
Vec::new(),
|
||||
@@ -238,6 +256,8 @@ pub(in super::super) async fn handle_admin_provider_oauth_start_batch_import_tas
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
Some("任务已提交,等待执行"),
|
||||
None,
|
||||
Vec::new(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::handlers::admin::request::AdminAppState;
|
||||
use crate::provider_key_auth::provider_key_is_oauth_managed;
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn normalize_codex_plan_group_for_provider_oauth(
|
||||
plan_type: Option<&serde_json::Value>,
|
||||
@@ -123,6 +124,40 @@ fn is_codex_cross_plan_group_non_duplicate(
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_oauth_invalid_reason_allows_replace(reason: &str) -> bool {
|
||||
reason.lines().map(str::trim).any(|line| {
|
||||
line.starts_with("[OAUTH_EXPIRED] ")
|
||||
|| line.starts_with("[REFRESH_FAILED] ")
|
||||
|| line.contains("Token 无效或已过期")
|
||||
|| line.contains("refresh_token 无效、已过期或已撤销")
|
||||
})
|
||||
}
|
||||
|
||||
fn existing_provider_oauth_key_is_replaceable(existing_key: &StoredProviderCatalogKey) -> bool {
|
||||
if !existing_key.is_active {
|
||||
return true;
|
||||
}
|
||||
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
if existing_key
|
||||
.expires_at_unix_secs
|
||||
.is_some_and(|expires_at| expires_at <= now_unix_secs)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
existing_key
|
||||
.oauth_invalid_reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|reason| !reason.is_empty())
|
||||
.is_some_and(provider_oauth_invalid_reason_allows_replace)
|
||||
}
|
||||
|
||||
pub(crate) async fn find_duplicate_provider_oauth_key(
|
||||
state: &AdminAppState<'_>,
|
||||
provider_id: &str,
|
||||
@@ -210,7 +245,7 @@ pub(crate) async fn find_duplicate_provider_oauth_key(
|
||||
if !is_duplicate {
|
||||
continue;
|
||||
}
|
||||
if !existing_key.is_active {
|
||||
if existing_provider_oauth_key_is_replaceable(&existing_key) {
|
||||
return Ok(Some(existing_key));
|
||||
}
|
||||
let identifier =
|
||||
|
||||
@@ -2,7 +2,9 @@ use crate::handlers::admin::provider::shared::payloads::{
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX, OAUTH_REFRESH_FAILED_PREFIX,
|
||||
};
|
||||
use crate::handlers::admin::request::{AdminAppState, AdminGatewayProviderTransportSnapshot};
|
||||
use crate::handlers::shared::sync_provider_key_quota_status_snapshot;
|
||||
use crate::handlers::shared::{
|
||||
sync_provider_key_oauth_status_snapshot, sync_provider_key_quota_status_snapshot,
|
||||
};
|
||||
use crate::GatewayError;
|
||||
use aether_admin::provider::quota as admin_provider_quota_pure;
|
||||
use aether_contracts::{ExecutionPlan, ExecutionResult, ExecutionTimeouts, ProxySnapshot};
|
||||
@@ -146,6 +148,8 @@ pub(crate) async fn persist_provider_quota_refresh_state(
|
||||
"refresh_api",
|
||||
);
|
||||
}
|
||||
latest_key.status_snapshot =
|
||||
sync_provider_key_oauth_status_snapshot(latest_key.status_snapshot.as_ref(), &latest_key);
|
||||
latest_key.updated_at_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
|
||||
@@ -8,6 +8,12 @@ use aether_crypto::DEVELOPMENT_ENCRYPTION_KEY;
|
||||
use aether_crypto::{decrypt_python_fernet_ciphertext, encrypt_python_fernet_plaintext};
|
||||
use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const OAUTH_ACCOUNT_BLOCK_PREFIX: &str = "[ACCOUNT_BLOCK] ";
|
||||
const OAUTH_EXPIRED_PREFIX: &str = "[OAUTH_EXPIRED] ";
|
||||
const OAUTH_REFRESH_FAILED_PREFIX: &str = "[REFRESH_FAILED] ";
|
||||
const OAUTH_REQUEST_FAILED_PREFIX: &str = "[REQUEST_FAILED] ";
|
||||
|
||||
pub(crate) fn provider_catalog_key_supports_format(
|
||||
key: &StoredProviderCatalogKey,
|
||||
@@ -155,6 +161,159 @@ pub(crate) fn default_provider_key_status_snapshot() -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn default_oauth_status_snapshot_value() -> Value {
|
||||
default_provider_key_status_snapshot()
|
||||
.get("oauth")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| {
|
||||
json!({
|
||||
"code": "none",
|
||||
"label": Value::Null,
|
||||
"reason": Value::Null,
|
||||
"expires_at": Value::Null,
|
||||
"invalid_at": Value::Null,
|
||||
"source": Value::Null,
|
||||
"requires_reauth": false,
|
||||
"expiring_soon": false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn trimmed_oauth_invalid_reason(reason: Option<&str>) -> Option<String> {
|
||||
reason
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn tagged_oauth_invalid_reason(reason: Option<&str>, prefix: &str) -> Option<String> {
|
||||
reason.and_then(|value| {
|
||||
value
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find_map(|line| line.strip_prefix(prefix))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
}
|
||||
|
||||
fn build_provider_key_oauth_status_snapshot(key: &StoredProviderCatalogKey) -> Value {
|
||||
if !key.auth_type.trim().eq_ignore_ascii_case("oauth") {
|
||||
return default_oauth_status_snapshot_value();
|
||||
}
|
||||
|
||||
let now_unix_secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.ok()
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0);
|
||||
let expires_at_unix_secs = key.expires_at_unix_secs;
|
||||
let invalid_at_unix_secs = key.oauth_invalid_at_unix_secs;
|
||||
let invalid_reason = trimmed_oauth_invalid_reason(key.oauth_invalid_reason.as_deref());
|
||||
|
||||
if let Some(reason) =
|
||||
tagged_oauth_invalid_reason(invalid_reason.as_deref(), OAUTH_EXPIRED_PREFIX)
|
||||
{
|
||||
return json!({
|
||||
"code": "invalid",
|
||||
"label": "已失效",
|
||||
"reason": reason,
|
||||
"expires_at": expires_at_unix_secs,
|
||||
"invalid_at": invalid_at_unix_secs,
|
||||
"source": "oauth_invalid",
|
||||
"requires_reauth": true,
|
||||
"expiring_soon": false,
|
||||
});
|
||||
}
|
||||
if let Some(reason) =
|
||||
tagged_oauth_invalid_reason(invalid_reason.as_deref(), OAUTH_REFRESH_FAILED_PREFIX)
|
||||
{
|
||||
return json!({
|
||||
"code": "invalid",
|
||||
"label": "已失效",
|
||||
"reason": reason,
|
||||
"expires_at": expires_at_unix_secs,
|
||||
"invalid_at": invalid_at_unix_secs,
|
||||
"source": "oauth_refresh",
|
||||
"requires_reauth": true,
|
||||
"expiring_soon": false,
|
||||
});
|
||||
}
|
||||
if let Some(reason) =
|
||||
tagged_oauth_invalid_reason(invalid_reason.as_deref(), OAUTH_REQUEST_FAILED_PREFIX)
|
||||
{
|
||||
return json!({
|
||||
"code": "check_failed",
|
||||
"label": "检查失败",
|
||||
"reason": reason,
|
||||
"expires_at": expires_at_unix_secs,
|
||||
"invalid_at": Value::Null,
|
||||
"source": "oauth_request",
|
||||
"requires_reauth": false,
|
||||
"expiring_soon": false,
|
||||
});
|
||||
}
|
||||
if invalid_reason
|
||||
.as_deref()
|
||||
.is_some_and(|reason| !reason.starts_with(OAUTH_ACCOUNT_BLOCK_PREFIX))
|
||||
|| invalid_at_unix_secs.is_some()
|
||||
{
|
||||
return json!({
|
||||
"code": "invalid",
|
||||
"label": "已失效",
|
||||
"reason": invalid_reason,
|
||||
"expires_at": expires_at_unix_secs,
|
||||
"invalid_at": invalid_at_unix_secs,
|
||||
"source": "oauth_invalid",
|
||||
"requires_reauth": true,
|
||||
"expiring_soon": false,
|
||||
});
|
||||
}
|
||||
|
||||
let Some(expires_at_unix_secs) = expires_at_unix_secs else {
|
||||
return default_oauth_status_snapshot_value();
|
||||
};
|
||||
if expires_at_unix_secs <= now_unix_secs {
|
||||
return json!({
|
||||
"code": "expired",
|
||||
"label": "已过期",
|
||||
"reason": "Token 已过期,请重新授权",
|
||||
"expires_at": expires_at_unix_secs,
|
||||
"invalid_at": Value::Null,
|
||||
"source": "expires_at",
|
||||
"requires_reauth": true,
|
||||
"expiring_soon": false,
|
||||
});
|
||||
}
|
||||
|
||||
let expiring_soon = expires_at_unix_secs.saturating_sub(now_unix_secs) < 24 * 60 * 60;
|
||||
json!({
|
||||
"code": if expiring_soon { "expiring" } else { "valid" },
|
||||
"label": if expiring_soon { "即将过期" } else { "有效" },
|
||||
"reason": Value::Null,
|
||||
"expires_at": expires_at_unix_secs,
|
||||
"invalid_at": Value::Null,
|
||||
"source": "expires_at",
|
||||
"requires_reauth": false,
|
||||
"expiring_soon": expiring_soon,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn sync_provider_key_oauth_status_snapshot(
|
||||
status_snapshot: Option<&Value>,
|
||||
key: &StoredProviderCatalogKey,
|
||||
) -> Option<Value> {
|
||||
let mut snapshot = provider_key_status_snapshot_object(status_snapshot)
|
||||
.or_else(|| default_provider_key_status_snapshot().as_object().cloned())
|
||||
.unwrap_or_default();
|
||||
snapshot.insert(
|
||||
"oauth".to_string(),
|
||||
build_provider_key_oauth_status_snapshot(key),
|
||||
);
|
||||
Some(Value::Object(snapshot))
|
||||
}
|
||||
|
||||
fn build_provider_key_account_status_snapshot(
|
||||
key: &StoredProviderCatalogKey,
|
||||
provider_type: &str,
|
||||
|
||||
@@ -23,7 +23,8 @@ pub(crate) use self::catalog::{
|
||||
default_provider_key_status_snapshot, effective_catalog_encryption_key,
|
||||
encrypt_catalog_secret_with_fallbacks, masked_catalog_api_key, parse_catalog_auth_config_json,
|
||||
provider_catalog_key_supports_format, provider_key_health_summary,
|
||||
provider_key_status_snapshot_payload, sync_provider_key_quota_status_snapshot,
|
||||
provider_key_status_snapshot_payload, sync_provider_key_oauth_status_snapshot,
|
||||
sync_provider_key_quota_status_snapshot,
|
||||
};
|
||||
pub(crate) use self::email_templates::{
|
||||
admin_email_template_definition, admin_email_template_html_key,
|
||||
|
||||
Reference in New Issue
Block a user