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:
fawney19
2026-04-20 22:59:02 +08:00
parent cf7d129595
commit c5c56ff92f
15 changed files with 965 additions and 44 deletions

View File

@@ -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 当前无失效标记,无需清除"

View File

@@ -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,

View File

@@ -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(),

View File

@@ -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 =

View File

@@ -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()

View File

@@ -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,

View File

@@ -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,