feat: OAuth 重复账号失效时允许覆盖更新,优化前端分页与刷新体验

- OAuth 去重逻辑改为:已失效的重复账号自动覆盖更新而非拒绝,
  适用于单个导入、批量导入、Kiro 导入等所有入口
- 新增 _update_existing_oauth_key 函数统一处理覆盖更新逻辑
- useSmartPagination 新增 fixedHeight 属性防止翻页时容器高度跳动
- ProviderDetailDrawer OAuth 刷新后仅重载 keys 数据避免整表刷新
This commit is contained in:
fawney19
2026-02-11 10:01:28 +08:00
parent 5cfae4f8f0
commit 11b0026995
3 changed files with 227 additions and 104 deletions

View File

@@ -152,6 +152,7 @@ class ProviderCompleteOAuthResponse(BaseModel):
expires_at: int | None = None
has_refresh_token: bool = False
email: str | None = None
replaced: bool = False
# ==============================================================================
@@ -271,6 +272,35 @@ def _create_oauth_key(
return new_key
def _update_existing_oauth_key(
db: Session,
existing_key: "ProviderAPIKey",
access_token: str,
auth_config: dict[str, Any],
flush_only: bool = False,
proxy: dict[str, Any] | None = None,
) -> "ProviderAPIKey":
"""覆盖更新已失效的 OAuth Key恢复为活跃状态。"""
existing_key.api_key = crypto_service.encrypt(access_token)
existing_key.auth_config = crypto_service.encrypt(json.dumps(auth_config))
existing_key.is_active = True
existing_key.oauth_invalid_at = None
existing_key.oauth_invalid_reason = None
existing_key.health_by_format = {} # type: ignore[assignment]
existing_key.circuit_breaker_by_format = {} # type: ignore[assignment]
existing_key.error_count = 0
existing_key.last_error_at = None
existing_key.last_error_msg = None
if proxy:
existing_key.proxy = proxy
if flush_only:
db.flush()
else:
db.commit()
db.refresh(existing_key)
return existing_key
async def _trigger_auto_fetch_models(key_ids: list[str]) -> None:
"""为启用了 auto_fetch_models 的新建 Key 触发模型获取。"""
if not key_ids:
@@ -317,9 +347,9 @@ def _check_duplicate_oauth_account(
provider_id: str,
auth_config: dict[str, Any],
exclude_key_id: str | None = None,
) -> None:
) -> ProviderAPIKey | None:
"""
检查是否存在重复的 OAuth 账号
检查是否存在重复的 OAuth 账号
通过以下字段判断重复:
- user_id: Codex 等使用用户级别 ID同 team 下不同成员共享 account_id 但 user_id 不同)
@@ -327,14 +357,12 @@ def _check_duplicate_oauth_account(
(同一邮箱可能通过 Social 和 IdC 两种方式登录,视为不同账号)
- email: 其他 OAuth Provider 使用邮箱判断
Args:
db: 数据库 session
provider_id: Provider ID
auth_config: 新账号的 auth_config
exclude_key_id: 排除的 Key ID用于更新场景
Returns:
None: 无重复,可以新建
ProviderAPIKey: 找到已失效的重复账号,调用方应覆盖此 key
Raises:
InvalidRequestException: 如果发现重复账号
InvalidRequestException: 如果发现活跃的重复账号
"""
new_email = auth_config.get("email")
new_user_id = auth_config.get("user_id")
@@ -343,7 +371,7 @@ def _check_duplicate_oauth_account(
# 如果没有可用于识别的字段,跳过检查
if not new_email and not new_user_id:
return
return None
# 查询该 Provider 下所有 OAuth 类型的 Keys
query = db.query(ProviderAPIKey).filter(
@@ -367,40 +395,63 @@ def _check_duplicate_oauth_account(
existing_auth_method = decrypted_config.get("auth_method")
existing_provider_type = decrypted_config.get("provider_type")
is_duplicate = False
# user_id 相同即重复Codex 等,同一 team 下不同成员共享 account_id 但 user_id 不同)
if new_user_id and existing_user_id and new_user_id == existing_user_id:
raise InvalidRequestException(
f"该 OAuth 账号已存在于当前 Provider 中(名称: {existing_key.name}"
)
is_duplicate = True
# email 判断
if new_email and existing_email and new_email == existing_email:
if not is_duplicate and new_email and existing_email and new_email == existing_email:
is_kiro = new_provider_type == "kiro" or existing_provider_type == "kiro"
if is_kiro:
# Kiro: 只有 email + auth_method 都相同才视为重复
# 同一邮箱可能通过 Social 和 IdC 两种方式登录,视为不同账号
if new_auth_method and existing_auth_method:
if new_auth_method.lower() == existing_auth_method.lower():
auth_method_display = (
"Social" if new_auth_method.lower() == "social" else "IdC"
)
raise InvalidRequestException(
f"该 Kiro 账号 ({new_email}, {auth_method_display}) "
f"已存在于当前 Provider 中(名称: {existing_key.name}"
)
# auth_method 不同,不视为重复
if (
new_auth_method
and existing_auth_method
and new_auth_method.lower() == existing_auth_method.lower()
):
is_duplicate = True
else:
# 非 Kiro Provider: 仅 email 相同即视为重复
is_duplicate = True
if is_duplicate:
# 如果已有账号已失效is_active=False允许覆盖
if not existing_key.is_active:
logger.info(
"重复 OAuth 账号已失效将覆盖更新key_id={}, name={}",
existing_key.id,
existing_key.name,
)
return existing_key
# 活跃的重复账号,拒绝添加
is_kiro = new_provider_type == "kiro" or existing_provider_type == "kiro"
if is_kiro and new_email:
auth_method_display = (
"Social" if (new_auth_method or "").lower() == "social" else "IdC"
)
raise InvalidRequestException(
f"该 Kiro 账号 ({new_email}, {auth_method_display}) "
f"已存在于当前 Provider 中(名称: {existing_key.name}"
)
elif new_email:
raise InvalidRequestException(
f"该 OAuth 账号 ({new_email}) 已存在于当前 Provider 中"
f"(名称: {existing_key.name}"
)
else:
raise InvalidRequestException(
f"该 OAuth 账号已存在于当前 Provider 中(名称: {existing_key.name}"
)
except InvalidRequestException:
raise
except Exception:
# 解密失败时跳过该 Key
continue
return None
# ==============================================================================
# Routes
@@ -791,7 +842,9 @@ async def refresh_oauth(
key.oauth_invalid_reason = error_reason
key.is_active = False
db.commit()
logger.warning("Key {} OAuth token 刷新失败,已标记为失效并自动停用: {}", key_id, error_reason)
logger.warning(
"Key {} OAuth token 刷新失败,已标记为失效并自动停用: {}", key_id, error_reason
)
raise InvalidRequestException(f"token refresh 失败: {error_reason}")
@@ -1058,23 +1111,30 @@ async def complete_provider_oauth(
proxy_config=proxy_config,
)
# 检查是否存在重复的 OAuth 账号
_check_duplicate_oauth_account(db, provider_id, auth_config)
# 检查是否存在重复的 OAuth 账号(失效账号允许覆盖)
existing_key = _check_duplicate_oauth_account(db, provider_id, auth_config)
replaced = False
# 确定账号名称
name = (payload.name or "").strip()
if not name:
name = auth_config.get("email") or f"账号_{int(time.time())}"
if existing_key:
new_key = _update_existing_oauth_key(
db, existing_key, access_token, auth_config, proxy=key_proxy
)
replaced = True
else:
# 确定账号名称
name = (payload.name or "").strip()
if not name:
name = auth_config.get("email") or f"账号_{int(time.time())}"
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=auth_config,
api_formats=_get_provider_api_formats(provider),
proxy=key_proxy,
)
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=auth_config,
api_formats=_get_provider_api_formats(provider),
proxy=key_proxy,
)
# 默认开启了 auto_fetch_models触发模型获取
await _trigger_auto_fetch_models([str(new_key.id)])
@@ -1085,6 +1145,7 @@ async def complete_provider_oauth(
expires_at=expires_at,
has_refresh_token=bool(refresh_token),
email=auth_config.get("email"),
replaced=replaced,
)
@@ -1212,6 +1273,7 @@ class BatchImportResultItem(BaseModel):
key_name: str | None = Field(None, description="创建的 Key 名称(成功时)")
auth_method: str | None = Field(None, description="认证类型(成功时)")
error: str | None = Field(None, description="错误信息(失败时)")
replaced: bool = Field(False, description="是否覆盖了已失效的重复账号")
class BatchImportResponse(BaseModel):
@@ -1278,29 +1340,39 @@ async def import_refresh_token(
logger.warning("Kiro Refresh Token 验证失败: {}", e)
raise InvalidRequestException("Kiro Refresh Token 验证失败,请检查凭据是否有效")
# 检查是否存在重复的 Kiro 账号
_check_duplicate_oauth_account(db, provider_id, new_cfg.to_dict())
# 检查是否存在重复的 Kiro 账号(失效账号允许覆盖)
existing_key = _check_duplicate_oauth_account(db, provider_id, new_cfg.to_dict())
replaced = False
# Kiro 确定账号名称(与 Codex/Antigravity 保持一致,使用 email
name = (payload.name or "").strip()
email: str | None = None
if not name:
if not existing_key:
name = (payload.name or "").strip()
if not name:
email = await _fetch_kiro_email(new_cfg.to_dict(), proxy_config=proxy_config)
name = email or f"账号_{int(time.time())}"
else:
email = await _fetch_kiro_email(new_cfg.to_dict(), proxy_config=proxy_config)
name = email or f"账号_{int(time.time())}"
# 将获取到的 email 写回 auth_config确保持久化
if email and not new_cfg.email:
new_cfg.email = email
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=new_cfg.to_dict(),
api_formats=_get_provider_api_formats(provider),
proxy=key_proxy,
)
if existing_key:
new_key = _update_existing_oauth_key(
db, existing_key, access_token, new_cfg.to_dict(), proxy=key_proxy
)
replaced = True
else:
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=new_cfg.to_dict(),
api_formats=_get_provider_api_formats(provider),
proxy=key_proxy,
)
# 默认开启了 auto_fetch_models触发模型获取
await _trigger_auto_fetch_models([str(new_key.id)])
@@ -1311,6 +1383,7 @@ async def import_refresh_token(
expires_at=new_cfg.expires_at or None,
has_refresh_token=bool(new_cfg.refresh_token),
email=email,
replaced=replaced,
)
try:
@@ -1408,23 +1481,30 @@ async def import_refresh_token(
proxy_config=proxy_config,
)
# 检查是否存在重复的 OAuth 账号
_check_duplicate_oauth_account(db, provider_id, auth_config)
# 检查是否存在重复的 OAuth 账号(失效账号允许覆盖)
existing_key = _check_duplicate_oauth_account(db, provider_id, auth_config)
replaced = False
# 确定账号名称
name = (payload.name or "").strip()
if not name:
name = auth_config.get("email") or f"账号_{int(time.time())}"
if existing_key:
new_key = _update_existing_oauth_key(
db, existing_key, access_token, auth_config, proxy=key_proxy
)
replaced = True
else:
# 确定账号名称
name = (payload.name or "").strip()
if not name:
name = auth_config.get("email") or f"账号_{int(time.time())}"
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=auth_config,
api_formats=_get_provider_api_formats(provider),
proxy=key_proxy,
)
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=auth_config,
api_formats=_get_provider_api_formats(provider),
proxy=key_proxy,
)
# 默认开启了 auto_fetch_models触发模型获取
await _trigger_auto_fetch_models([str(new_key.id)])
@@ -1435,6 +1515,7 @@ async def import_refresh_token(
expires_at=expires_at,
has_refresh_token=bool(new_refresh_token),
email=auth_config.get("email"),
replaced=replaced,
)
@@ -1639,9 +1720,9 @@ async def batch_import_oauth(
logger.warning("批量导入: enrich_auth_config 失败 (index={}): {}", idx, e)
# 不中断,继续使用基本 auth_config
# 检查是否存在重复
# 检查是否存在重复(失效账号允许覆盖)
try:
_check_duplicate_oauth_account(db, provider_id, auth_config)
existing_key = _check_duplicate_oauth_account(db, provider_id, auth_config)
except InvalidRequestException as e:
results.append(
BatchImportResultItem(
@@ -1653,25 +1734,38 @@ async def batch_import_oauth(
failed_count += 1
continue
# 生成名称
email = auth_config.get("email")
if email:
name = f"{provider_type}_{email}"
replaced = False
if existing_key:
new_key = _update_existing_oauth_key(
db,
existing_key,
access_token,
auth_config,
flush_only=True,
proxy=key_proxy,
)
name = existing_key.name
replaced = True
else:
name = f"{provider_type}_{int(time.time())}_{idx}"
if len(name) > 100:
name = name[:100]
# 生成名称
email = auth_config.get("email")
if email:
name = f"{provider_type}_{email}"
else:
name = f"{provider_type}_{int(time.time())}_{idx}"
if len(name) > 100:
name = name[:100]
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=auth_config,
api_formats=api_formats,
flush_only=True,
proxy=key_proxy,
)
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=auth_config,
api_formats=api_formats,
flush_only=True,
proxy=key_proxy,
)
results.append(
BatchImportResultItem(
@@ -1679,6 +1773,7 @@ async def batch_import_oauth(
status="success",
key_id=str(new_key.id),
key_name=name,
replaced=replaced,
)
)
success_count += 1
@@ -1781,9 +1876,9 @@ async def _batch_import_kiro_internal(
failed_count += 1
continue
# 检查是否存在重复
# 检查是否存在重复(失效账号允许覆盖)
try:
_check_duplicate_oauth_account(db, provider_id, new_cfg.to_dict())
existing_key = _check_duplicate_oauth_account(db, provider_id, new_cfg.to_dict())
except InvalidRequestException as e:
results.append(
BatchImportResultItem(
@@ -1797,22 +1892,35 @@ async def _batch_import_kiro_internal(
# Kiro 确定账号名称(与 Codex/Antigravity 保持一致,使用 email
email = await _fetch_kiro_email(new_cfg.to_dict(), proxy_config=proxy_config)
name = email or f"账号_{int(time.time())}"
# 将获取到的 email 写回 auth_config确保持久化
if email and not new_cfg.email:
new_cfg.email = email
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=new_cfg.to_dict(),
api_formats=api_formats,
flush_only=True,
proxy=key_proxy,
)
replaced = False
if existing_key:
new_key = _update_existing_oauth_key(
db,
existing_key,
access_token,
new_cfg.to_dict(),
flush_only=True,
proxy=key_proxy,
)
name = existing_key.name
replaced = True
else:
name = email or f"账号_{int(time.time())}"
new_key = _create_oauth_key(
db,
provider_id=provider_id,
name=name,
access_token=access_token,
auth_config=new_cfg.to_dict(),
api_formats=api_formats,
flush_only=True,
proxy=key_proxy,
)
results.append(
BatchImportResultItem(
@@ -1821,6 +1929,7 @@ async def _batch_import_kiro_internal(
key_id=str(new_key.id),
key_name=name,
auth_method=new_cfg.auth_method or "social",
replaced=replaced,
)
)
success_count += 1