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