feat: 配置导入导出支持 ProxyNode,号池 key 可用性检查延迟到排序后分页执行

配置导入导出:
- 导出/导入新增 ProxyNode(代理节点)数据
- 导入时自动建立 old_id -> new_id 映射,重映射 Provider/Endpoint/Key 中的 node_id
- 前端预览和结果展示新增代理节点统计

号池调度优化:
- CandidateBuilder 不再逐 key 调用 _check_key_availability,直接收集全部 active key
- 将可用性检查参数打包到 PoolCandidate._deferred_check_params
- PoolManager.select_pool_keys 排序后分页调用 availability_checker,找到足够可用 key 即停止
- 减少大号池场景下不必要的可用性检查开销
This commit is contained in:
fawney19
2026-03-09 13:26:54 +08:00
parent 0046123e22
commit d84c9d4b71
7 changed files with 291 additions and 55 deletions

View File

@@ -973,7 +973,13 @@ class AdminExportConfigAdapter(AdminApiAdapter):
from datetime import datetime, timezone
from src.core.crypto import crypto_service
from src.models.database import GlobalModel, Model, ProviderAPIKey, ProviderEndpoint
from src.models.database import (
GlobalModel,
Model,
ProviderAPIKey,
ProviderEndpoint,
ProxyNode,
)
db = context.db
@@ -1138,11 +1144,34 @@ class AdminExportConfigAdapter(AdminApiAdapter):
}
)
# 导出 ProxyNode手动节点 + 隧道节点,不含运行时状态)
proxy_nodes = db.query(ProxyNode).all()
proxy_nodes_data = []
for node in proxy_nodes:
proxy_nodes_data.append(
{
"id": node.id,
"name": node.name,
"ip": node.ip,
"port": node.port,
"region": node.region,
"is_manual": node.is_manual,
"proxy_url": node.proxy_url,
"proxy_username": node.proxy_username,
"proxy_password": node.proxy_password,
"tunnel_mode": node.tunnel_mode,
"heartbeat_interval": node.heartbeat_interval,
"remote_config": node.remote_config,
"config_version": node.config_version,
}
)
return {
"version": CONFIG_EXPORT_VERSION,
"exported_at": datetime.now(timezone.utc).isoformat(),
"global_models": global_models_data,
"providers": providers_data,
"proxy_nodes": proxy_nodes_data,
"ldap_config": ldap_data,
"oauth_providers": oauth_data,
"system_configs": system_configs_data,
@@ -1155,6 +1184,32 @@ class AdminImportConfigAdapter(AdminApiAdapter):
# Provider Ops 中需要加密的敏感字段
SENSITIVE_CREDENTIALS = SENSITIVE_CREDENTIAL_FIELDS
@staticmethod
def _remap_proxy_node_id(
proxy: dict[str, Any] | None,
node_id_map: dict[str, str],
) -> dict[str, Any] | None:
"""替换 proxy 配置中的 node_id 为新实例的 ID。
- node_id 在映射表中:替换为新 ID
- node_id 不在映射表中(节点未导入):清除 proxy 配置
- 无 node_id手动 URL 模式):原样返回
"""
if not proxy or not isinstance(proxy, dict):
return proxy
old_node_id = proxy.get("node_id")
if not old_node_id or not isinstance(old_node_id, str):
return proxy # 手动 URL 模式,无需映射
new_node_id = node_id_map.get(old_node_id)
if new_node_id is None:
return None # 节点未导入,清除代理配置
remapped = dict(proxy)
remapped["node_id"] = new_node_id
return remapped
@staticmethod
def _extract_import_key_api_formats(
key_data: dict[str, Any], endpoint_formats: set[str]
@@ -1207,7 +1262,13 @@ class AdminImportConfigAdapter(AdminApiAdapter):
from src.core.crypto import crypto_service
from src.core.enums import ProviderBillingType
from src.models.database import GlobalModel, Model, ProviderAPIKey, ProviderEndpoint
from src.models.database import (
GlobalModel,
Model,
ProviderAPIKey,
ProviderEndpoint,
ProxyNode,
)
# 检查请求体大小
if context.raw_body and len(context.raw_body) > MAX_IMPORT_SIZE:
@@ -1227,12 +1288,14 @@ class AdminImportConfigAdapter(AdminApiAdapter):
merge_mode = payload.get("merge_mode", "skip") # skip, overwrite, error
global_models_data = payload.get("global_models", [])
providers_data = payload.get("providers", [])
proxy_nodes_data = payload.get("proxy_nodes", [])
ldap_data = payload.get("ldap_config") # 2.1 新增
oauth_data = payload.get("oauth_providers", []) # 2.1 新增
system_configs_data = payload.get("system_configs", []) # 2.2 新增
stats = {
"global_models": {"created": 0, "updated": 0, "skipped": 0},
"proxy_nodes": {"created": 0, "updated": 0, "skipped": 0},
"providers": {"created": 0, "updated": 0, "skipped": 0},
"endpoints": {"created": 0, "updated": 0, "skipped": 0},
"keys": {"created": 0, "updated": 0, "skipped": 0},
@@ -1298,6 +1361,77 @@ class AdminImportConfigAdapter(AdminApiAdapter):
global_model_map[gm_data["name"]] = new_gm.id
stats["global_models"]["created"] += 1
# 导入 ProxyNodes在 Providers 之前,建立 old_id -> new_id 映射)
proxy_node_id_map: dict[str, str] = {} # old_node_id -> new_node_id
for node_data in proxy_nodes_data:
old_id = node_data.get("id", "")
ip = node_data.get("ip", "")
port = node_data.get("port", 0)
if not ip or not port:
stats["errors"].append(f"跳过无效的代理节点: {node_data.get('name', '?')}")
continue
existing_node = (
db.query(ProxyNode).filter(ProxyNode.ip == ip, ProxyNode.port == port).first()
)
if existing_node:
proxy_node_id_map[old_id] = existing_node.id
if merge_mode == "skip":
stats["proxy_nodes"]["skipped"] += 1
elif merge_mode == "error":
raise InvalidRequestException(
f"代理节点 '{node_data.get('name')}' ({ip}:{port}) 已存在"
)
elif merge_mode == "overwrite":
existing_node.name = node_data.get("name", existing_node.name)
existing_node.region = node_data.get("region", existing_node.region)
existing_node.is_manual = node_data.get(
"is_manual", existing_node.is_manual
)
existing_node.proxy_url = node_data.get(
"proxy_url", existing_node.proxy_url
)
existing_node.proxy_username = node_data.get(
"proxy_username", existing_node.proxy_username
)
existing_node.proxy_password = node_data.get(
"proxy_password", existing_node.proxy_password
)
existing_node.tunnel_mode = node_data.get(
"tunnel_mode", existing_node.tunnel_mode
)
existing_node.remote_config = node_data.get(
"remote_config", existing_node.remote_config
)
existing_node.updated_at = datetime.now(timezone.utc)
stats["proxy_nodes"]["updated"] += 1
else:
from src.models.database import ProxyNodeStatus
is_manual = node_data.get("is_manual", False)
new_node = ProxyNode(
id=str(uuid.uuid4()),
name=node_data.get("name", "Imported Node"),
ip=ip,
port=port,
region=node_data.get("region"),
is_manual=is_manual,
proxy_url=node_data.get("proxy_url"),
proxy_username=node_data.get("proxy_username"),
proxy_password=node_data.get("proxy_password"),
tunnel_mode=node_data.get("tunnel_mode", False),
heartbeat_interval=node_data.get("heartbeat_interval", 0),
remote_config=node_data.get("remote_config"),
config_version=node_data.get("config_version", 0),
status=ProxyNodeStatus.ONLINE if is_manual else ProxyNodeStatus.OFFLINE,
)
db.add(new_node)
db.flush()
proxy_node_id_map[old_id] = new_node.id
stats["proxy_nodes"]["created"] += 1
# 导入 Providers
for prov_data in providers_data:
existing_provider = (
@@ -1348,7 +1482,12 @@ class AdminImportConfigAdapter(AdminApiAdapter):
existing_provider.request_timeout = prov_data.get(
"request_timeout", existing_provider.request_timeout
)
existing_provider.proxy = prov_data.get("proxy", existing_provider.proxy)
if "proxy" in prov_data:
existing_provider.proxy = self._remap_proxy_node_id(
prov_data["proxy"],
proxy_node_id_map,
)
# 未提供 proxy 字段时保留现有配置
# 加密 provider_ops credentials 后再保存
existing_provider.config = self._encrypt_provider_config(
prov_data.get("config"), crypto_service
@@ -1385,7 +1524,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
max_retries=prov_data.get("max_retries"),
stream_first_byte_timeout=prov_data.get("stream_first_byte_timeout"),
request_timeout=prov_data.get("request_timeout"),
proxy=prov_data.get("proxy"),
proxy=self._remap_proxy_node_id(prov_data.get("proxy"), proxy_node_id_map),
config=encrypted_config,
)
db.add(new_provider)
@@ -1428,7 +1567,9 @@ class AdminImportConfigAdapter(AdminApiAdapter):
existing_ep.format_acceptance_config = ep_data.get(
"format_acceptance_config"
)
existing_ep.proxy = ep_data.get("proxy")
existing_ep.proxy = self._remap_proxy_node_id(
ep_data.get("proxy"), proxy_node_id_map
)
sig = parse_signature_key(ep_format)
existing_ep.api_format = sig.key # 使用归一化后的格式
existing_ep.api_family = sig.api_family.value
@@ -1453,7 +1594,9 @@ class AdminImportConfigAdapter(AdminApiAdapter):
custom_path=ep_data.get("custom_path"),
config=ep_data.get("config"),
format_acceptance_config=ep_data.get("format_acceptance_config"),
proxy=ep_data.get("proxy"),
proxy=self._remap_proxy_node_id(
ep_data.get("proxy"), proxy_node_id_map
),
)
db.add(new_ep)
db.flush()
@@ -1521,8 +1664,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
if endpoint_formats and fmt_normalized not in endpoint_formats:
missing_formats.append(fmt_normalized.upper())
continue
# 存储时使用大写格式以保持向后兼容
normalized_formats.append(fmt_normalized.upper())
normalized_formats.append(fmt_normalized)
if missing_formats:
stats["errors"].append(
@@ -1572,7 +1714,7 @@ class AdminImportConfigAdapter(AdminApiAdapter):
model_include_patterns=key_data.get("model_include_patterns"),
model_exclude_patterns=key_data.get("model_exclude_patterns"),
is_active=key_data.get("is_active", True),
proxy=key_data.get("proxy"),
proxy=self._remap_proxy_node_id(key_data.get("proxy"), proxy_node_id_map),
fingerprint=generate_fingerprint(seed=new_key_id),
health_by_format={},
circuit_breaker_by_format={},
@@ -1928,7 +2070,9 @@ class AdminImportConfigAdapter(AdminApiAdapter):
class AdminExportUsersAdapter(AdminApiAdapter):
@staticmethod
def _serialize_api_key(
key: ApiKey, include_is_standalone: bool = False, db: Any = None,
key: ApiKey,
include_is_standalone: bool = False,
db: Any = None,
) -> dict[str, Any]:
"""序列化用户 API Key 为导出格式。"""
from src.core.crypto import crypto_service
@@ -2142,7 +2286,8 @@ class AdminImportUsersAdapter(AdminApiAdapter):
)
wallet_limit_mode = (
str(wallet_payload.get("limit_mode"))
if wallet_payload and wallet_payload.get("limit_mode") in {"finite", "unlimited"}
if wallet_payload
and wallet_payload.get("limit_mode") in {"finite", "unlimited"}
else ("unlimited" if user_data.get("unlimited") else "finite")
)
@@ -2173,7 +2318,9 @@ class AdminImportUsersAdapter(AdminApiAdapter):
if wallet_payload:
wallet.balance = wallet_payload.get("recharge_balance", 0) or 0
wallet.gift_balance = wallet_payload.get("gift_balance", 0) or 0
wallet.total_recharged = wallet_payload.get("total_recharged", 0) or 0
wallet.total_recharged = (
wallet_payload.get("total_recharged", 0) or 0
)
wallet.total_consumed = wallet_payload.get("total_consumed", 0) or 0
wallet.total_refunded = wallet_payload.get("total_refunded", 0) or 0
wallet.total_adjusted = wallet_payload.get("total_adjusted", 0) or 0
@@ -2250,8 +2397,7 @@ class AdminImportUsersAdapter(AdminApiAdapter):
wallet.limit_mode = (
str(wallet_payload.get("limit_mode"))
if wallet_payload
and wallet_payload.get("limit_mode")
in {"finite", "unlimited"}
and wallet_payload.get("limit_mode") in {"finite", "unlimited"}
else ("unlimited" if key_data.get("unlimited") else "finite")
)
if wallet_payload:
@@ -2269,7 +2415,9 @@ class AdminImportUsersAdapter(AdminApiAdapter):
wallet.total_adjusted = (
wallet_payload.get("total_adjusted", 0) or 0
)
wallet.status = wallet_payload.get("status", "active") or "active"
wallet.status = (
wallet_payload.get("status", "active") or "active"
)
wallet.updated_at = datetime.now(timezone.utc)
stats["standalone_keys"]["created"] += 1
elif status == "skipped":