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

@@ -39,6 +39,12 @@ export function useSmartPagination<T>(
return Math.ceil(items.value.length / itemsPerPage.value) return Math.ceil(items.value.length / itemsPerPage.value)
}) })
/** 分页激活时的固定高度px用于防止翻页时容器高度跳动 */
const fixedHeight = computed(() => {
if (!shouldPaginate.value || cachedAvgItemHeight.value <= 0) return undefined
return itemsPerPage.value * cachedAvgItemHeight.value
})
/** 将当前页内的局部索引转换为全局索引 */ /** 将当前页内的局部索引转换为全局索引 */
function getGlobalIndex(localIdx: number): number { function getGlobalIndex(localIdx: number): number {
if (!shouldPaginate.value) return localIdx if (!shouldPaginate.value) return localIdx
@@ -165,6 +171,7 @@ export function useSmartPagination<T>(
totalPages, totalPages,
shouldPaginate, shouldPaginate,
paginatedItems, paginatedItems,
fixedHeight,
getGlobalIndex, getGlobalIndex,
detect, detect,
reset, reset,

View File

@@ -208,6 +208,7 @@
v-if="allKeys.length > 0" v-if="allKeys.length > 0"
ref="keysListRef" ref="keysListRef"
class="divide-y divide-border/40" class="divide-y divide-border/40"
:style="keysFixedHeight ? { minHeight: keysFixedHeight + 'px' } : undefined"
> >
<div <div
v-for="({ key, endpoint }, localIdx) in paginatedKeys" v-for="({ key, endpoint }, localIdx) in paginatedKeys"
@@ -1227,6 +1228,7 @@ const {
totalPages: totalKeyPages, totalPages: totalKeyPages,
shouldPaginate: shouldPaginateKeys, shouldPaginate: shouldPaginateKeys,
paginatedItems: paginatedKeys, paginatedItems: paginatedKeys,
fixedHeight: keysFixedHeight,
getGlobalIndex: getGlobalKeyIndex, getGlobalIndex: getGlobalKeyIndex,
reset: resetKeysPagination, reset: resetKeysPagination,
} = useSmartPagination(allKeys, keysListRef) } = useSmartPagination(allKeys, keysListRef)
@@ -1494,8 +1496,13 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
if (keyInList) { if (keyInList) {
keyInList.oauth_expires_at = result.expires_at keyInList.oauth_expires_at = result.expires_at
} }
// 重新加载 key 数据token 刷新可能补上了 project_id 等信息) // 重新加载 keys 数据,避免整个表格刷新
await loadEndpoints() if (props.providerId) {
const freshKeys = await getProviderKeys(props.providerId).catch(() => null)
if (freshKeys) {
providerKeys.value = freshKeys
}
}
// Antigravitytoken 刷新后可能完成了账号激活,触发配额获取 // Antigravitytoken 刷新后可能完成了账号激活,触发配额获取
// (不 emit('refresh'),避免触发全局 provider 余额刷新) // (不 emit('refresh'),避免触发全局 provider 余额刷新)
void autoRefreshQuotaInBackground() void autoRefreshQuotaInBackground()

View File

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