feat: Key 删除时自动解除不再匹配的 GlobalModel 关联

- 新增 auto_disassociate_provider_by_key_whitelist 方法检测并解除关联
- 删除 Key 后检查 Provider 剩余 Key 白名单,移除不再匹配的 Model
- 前端删除密钥后并行刷新端点列表、模型列表和模型映射

Closes #102

Co-authored-by: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
fawney19
2026-01-17 22:05:09 +08:00
parent 4fa0201b7e
commit f002f8de03
3 changed files with 183 additions and 8 deletions

View File

@@ -779,7 +779,12 @@ async function confirmDeleteKey() {
try { try {
await deleteEndpointKey(keyId) await deleteEndpointKey(keyId)
showSuccess('密钥已删除') showSuccess('密钥已删除')
await loadEndpoints() // 并行刷新:端点列表、模型列表、模型映射(删除 Key 触发自动解除模型关联)
await Promise.all([
loadEndpoints(),
modelsTabRef.value?.reload(),
modelMappingTabRef.value?.reload()
])
emit('refresh') emit('refresh')
} catch (err: any) { } catch (err: any) {
showError(err.response?.data?.detail || '删除密钥失败', '错误') showError(err.response?.data?.detail || '删除密钥失败', '错误')

View File

@@ -345,6 +345,7 @@ class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
raise NotFoundException(f"Key {self.key_id} 不存在") raise NotFoundException(f"Key {self.key_id} 不存在")
provider_id = key.provider_id provider_id = key.provider_id
deleted_key_allowed_models = key.allowed_models # 保存被删除 Key 的 allowed_models
try: try:
db.delete(key) db.delete(key)
db.commit() db.commit()
@@ -353,8 +354,20 @@ class AdminDeleteEndpointKeyAdapter(AdminApiAdapter):
logger.error(f"删除 Key 失败: ID={self.key_id}, Error={exc}") logger.error(f"删除 Key 失败: ID={self.key_id}, Error={exc}")
raise raise
# 清除 /v1/models 列表缓存 # 触发缓存失效和自动解除关联检查
await invalidate_models_list_cache() # 注意:只有当被删除的 Key 有具体的 allowed_models 时才触发 disassociate
# 如果 allowed_models 为 null允许所有模型则不需要检查解除关联
if provider_id:
from src.services.model.global_model import on_key_allowed_models_changed
await on_key_allowed_models_changed(
db=db,
provider_id=provider_id,
skip_disassociate=deleted_key_allowed_models is None,
)
else:
# 无 provider_id 时仅清除缓存
await invalidate_models_list_cache()
logger.warning(f"[DELETE] 删除 Key: ID={self.key_id}, Provider={provider_id}") logger.warning(f"[DELETE] 删除 Key: ID={self.key_id}, Provider={provider_id}")
return {"message": f"Key {self.key_id} 已删除"} return {"message": f"Key {self.key_id} 已删除"}

View File

@@ -4,7 +4,7 @@ GlobalModel 服务层
提供 GlobalModel 的 CRUD 操作、查询和统计功能 提供 GlobalModel 的 CRUD 操作、查询和统计功能
""" """
from typing import Dict, List, Optional, Set from typing import Dict, List, Optional, Set, cast
from sqlalchemy import and_, func from sqlalchemy import and_, func
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload
@@ -18,19 +18,26 @@ from src.models.pydantic_models import GlobalModelUpdate
async def on_key_allowed_models_changed( async def on_key_allowed_models_changed(
db: Session, db: Session,
provider_id: str, provider_id: str,
allowed_models: List[str], allowed_models: Optional[List[str]] = None,
skip_disassociate: bool = False,
) -> None: ) -> None:
""" """
Key 的 allowed_models 变更后的统一处理 Key 的 allowed_models 变更后的统一处理
包括: 包括:
1. 触发缓存失效(包括 /v1/models 列表缓存) 1. 触发缓存失效(包括 /v1/models 列表缓存)
2. 检查并自动关联匹配的 GlobalModel 2. 检查并自动关联匹配的 GlobalModel(仅当提供 allowed_models 时)
3. 检查并自动解除不再匹配的 GlobalModel 关联(可通过 skip_disassociate 跳过)
Args: Args:
db: 数据库 Session db: 数据库 Session
provider_id: Provider ID provider_id: Provider ID
allowed_models: 更新后的 allowed_models 列表 allowed_models: 更新后的 allowed_models 列表
- 提供非空列表:触发自动关联和解除关联检查
- 提供空列表或 None仅触发解除关联检查用于 Key 删除场景)
skip_disassociate: 是否跳过解除关联检查
- True跳过用于删除 allowed_models 为 null 的 Key 时)
- False执行检查默认
""" """
from src.services.cache.invalidation import get_cache_invalidation_service from src.services.cache.invalidation import get_cache_invalidation_service
@@ -38,7 +45,7 @@ async def on_key_allowed_models_changed(
cache_service = get_cache_invalidation_service() cache_service = get_cache_invalidation_service()
await cache_service.on_key_allowed_models_changed(provider_id) await cache_service.on_key_allowed_models_changed(provider_id)
# 2. 检查并自动关联 GlobalModel # 2. 检查并自动关联 GlobalModel(仅当提供非空 allowed_models 时)
if allowed_models: if allowed_models:
GlobalModelService.auto_associate_provider_by_key_whitelist( GlobalModelService.auto_associate_provider_by_key_whitelist(
db=db, db=db,
@@ -46,6 +53,13 @@ async def on_key_allowed_models_changed(
allowed_models=allowed_models, allowed_models=allowed_models,
) )
# 3. 检查并自动解除不再匹配的 GlobalModel 关联
if not skip_disassociate:
GlobalModelService.auto_disassociate_provider_by_key_whitelist(
db=db,
provider_id=provider_id,
)
class GlobalModelService: class GlobalModelService:
"""GlobalModel 服务""" """GlobalModel 服务"""
@@ -411,7 +425,7 @@ class GlobalModelService:
if not matched: if not matched:
continue continue
# 自动创建关联 # 自动创建关联(逐个处理,允许部分成功)
try: try:
new_model = Model( new_model = Model(
provider_id=provider_id, provider_id=provider_id,
@@ -453,3 +467,146 @@ class GlobalModelService:
db.commit() db.commit()
return results return results
@staticmethod
def auto_disassociate_provider_by_key_whitelist(
db: Session,
provider_id: str,
) -> Dict:
"""
根据 Key 白名单自动解除 Provider 与不再匹配的 GlobalModel 的关联
当 Key 的 allowed_models 更新后调用此方法,检查所有已关联的 GlobalModel
如果其映射规则不再匹配任何 Key 白名单中的模型,则自动删除关联。
注意:只删除通过映射规则自动关联的 Model即 GlobalModel 有 model_mappings 配置的)
Args:
db: 数据库 Session
provider_id: Provider ID
Returns:
Dict: 包含 success 和 errors 列表
"""
from src.core.model_permissions import match_model_with_pattern
from src.models.database import Provider, ProviderAPIKey
results: Dict[str, List[Dict]] = {
"success": [],
"errors": [],
}
# 获取 Provider
provider = db.query(Provider).filter(Provider.id == provider_id).first()
if not provider:
logger.warning(f"Provider {provider_id} not found for auto-disassociation")
return results
# 1. 获取 Provider 下所有活跃 Key 的 allowed_models 并集
keys = (
db.query(ProviderAPIKey)
.filter(
ProviderAPIKey.provider_id == provider_id,
ProviderAPIKey.is_active == True,
)
.all()
)
# 收集所有 Key 的 allowed_models 并集
# 注意allowed_models 为 null 表示允许所有模型,此时不应解除任何关联
all_allowed_models: Set[str] = set()
has_unlimited_key = False # 是否存在允许所有模型的 Key
for key in keys:
if key.allowed_models is None:
# null 表示允许所有模型,直接返回不做任何解除
has_unlimited_key = True
break
if key.allowed_models:
all_allowed_models.update(key.allowed_models)
# 如果存在允许所有模型的 Key不需要解除任何关联
if has_unlimited_key:
return results
# 如果 Provider 无活跃 Key不做任何解除保留现有关联
if not keys:
return results
# 2. 获取 Provider 当前关联的所有 Model带 GlobalModel 信息)
models = (
db.query(Model)
.options(joinedload(Model.global_model))
.filter(Model.provider_id == provider_id)
.all()
)
# 3. 检查每个 Model 是否还能匹配,收集需要删除的 Model
models_to_delete: List[Model] = []
for model in models:
# 跳过没有关联 GlobalModel 的
if not model.global_model_id or not model.global_model:
continue
global_model = cast(GlobalModel, model.global_model)
# 提取映射规则
model_mappings: List[str] = []
config = global_model.config
if config and isinstance(config, dict):
mappings = config.get("model_mappings")
if isinstance(mappings, list):
model_mappings = [m for m in mappings if isinstance(m, str)]
# 如果 GlobalModel 没有 model_mappings跳过说明不是通过映射自动关联的
if not model_mappings:
continue
# 检查是否有映射规则匹配到任一 allowed_models
matched = False
for mapping_pattern in model_mappings:
for allowed_model in all_allowed_models:
if match_model_with_pattern(mapping_pattern, allowed_model):
matched = True
break
if matched:
break
# 如果不再匹配,标记为待删除
if not matched:
models_to_delete.append(model)
# 4. 批量删除不再匹配的 Model全部成功或全部失败
if models_to_delete:
try:
for model in models_to_delete:
global_model = cast(GlobalModel, model.global_model)
db.delete(model)
results["success"].append(
{
"model_id": model.id,
"global_model_id": global_model.id,
"global_model_name": global_model.name,
}
)
logger.info(
f"Auto-disassociated Provider {provider.name} from GlobalModel {global_model.name} "
f"(no matching allowed_models)"
)
db.commit()
except Exception as e:
db.rollback()
logger.error(
f"Failed to auto-disassociate Provider {provider.name}: {e}"
)
# 清空 success记录整体错误
results["success"] = []
results["errors"].append(
{
"provider_id": provider_id,
"error": str(e),
}
)
return results