mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
815 lines
28 KiB
Python
815 lines
28 KiB
Python
"""
|
||
代理解析服务
|
||
|
||
集中管理代理 URL 构建、节点信息缓存、系统默认代理回退、代理信息追踪等逻辑。
|
||
供 HTTPClientPool、Handler、Provider Ops 等模块调用。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import gzip
|
||
import hashlib
|
||
import json
|
||
import threading
|
||
import time
|
||
from typing import Any
|
||
from urllib.parse import quote, urlparse
|
||
|
||
import httpx
|
||
|
||
from src.core.exceptions import ProxyNodeUnavailableError
|
||
from src.core.http_compression import is_gzip_content_encoding
|
||
from src.core.logger import logger
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ProxyNode 信息缓存(降低高频 DB 查询开销)
|
||
# ---------------------------------------------------------------------------
|
||
_proxy_node_cache: dict[str, tuple[dict[str, Any] | None, float]] = {}
|
||
_proxy_node_cache_lock = threading.Lock()
|
||
_PROXY_NODE_CACHE_TTL_SECONDS = 3.0
|
||
_PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS = 5.0 # 不可用节点使用更短的 TTL,加速恢复感知
|
||
_PROXY_NODE_CACHE_MAX_SIZE = 256
|
||
|
||
# payload 超过此阈值时 build_*_kwargs_async 才走 to_thread,
|
||
# 避免小 payload 承担不必要的线程调度开销
|
||
_ASYNC_PAYLOAD_THRESHOLD = 64 * 1024
|
||
|
||
|
||
def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
|
||
"""
|
||
读取 ProxyNode 信息(带内存 TTL 缓存)
|
||
|
||
通过 asyncio.to_thread 在工作线程中运行,使用 _proxy_node_cache_lock 保护
|
||
缓存的并发读写安全。
|
||
|
||
Returns:
|
||
aether-proxy 节点: {"ip": str, "port": int, "name": str, ...}
|
||
手动节点: {"is_manual": True, "name": str, "proxy_url": str, ...}
|
||
不存在/非在线: None
|
||
"""
|
||
now = time.time()
|
||
|
||
# 快速路径:缓存命中
|
||
with _proxy_node_cache_lock:
|
||
cached = _proxy_node_cache.get(node_id)
|
||
if cached:
|
||
value, expires_at = cached
|
||
if now < expires_at:
|
||
return value
|
||
|
||
# 缓存未命中,查询 DB
|
||
from src.database import create_session
|
||
from src.models.database import ProxyNode, ProxyNodeStatus
|
||
|
||
db = create_session()
|
||
try:
|
||
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
|
||
if not node:
|
||
result: dict[str, Any] | None = None
|
||
ttl = _PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS
|
||
elif node.tunnel_mode and not node.is_manual:
|
||
if node.status != ProxyNodeStatus.ONLINE or not bool(node.tunnel_connected):
|
||
result = None
|
||
ttl = _PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS
|
||
else:
|
||
result = {
|
||
"name": node.name,
|
||
"ip": node.ip,
|
||
"port": node.port,
|
||
"tunnel_mode": True,
|
||
"tunnel_connected": True,
|
||
}
|
||
ttl = _PROXY_NODE_CACHE_TTL_SECONDS
|
||
elif node.status != ProxyNodeStatus.ONLINE:
|
||
result = None
|
||
ttl = _PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS
|
||
elif node.is_manual:
|
||
result = {
|
||
"is_manual": True,
|
||
"name": node.name,
|
||
"proxy_url": node.proxy_url,
|
||
"username": node.proxy_username,
|
||
"password": node.proxy_password,
|
||
}
|
||
ttl = _PROXY_NODE_CACHE_TTL_SECONDS
|
||
else:
|
||
result = {
|
||
"name": node.name,
|
||
"ip": node.ip,
|
||
"port": node.port,
|
||
"tunnel_mode": bool(node.tunnel_mode),
|
||
"tunnel_connected": bool(node.tunnel_connected),
|
||
}
|
||
ttl = _PROXY_NODE_CACHE_TTL_SECONDS
|
||
finally:
|
||
db.close()
|
||
|
||
# 写回缓存(持锁做淘汰+写入,使用新时间戳以排除 DB 查询耗时)
|
||
write_now = time.time()
|
||
with _proxy_node_cache_lock:
|
||
if len(_proxy_node_cache) >= _PROXY_NODE_CACHE_MAX_SIZE:
|
||
evict_count = _PROXY_NODE_CACHE_MAX_SIZE // 4
|
||
sorted_keys = sorted(_proxy_node_cache, key=lambda k: _proxy_node_cache[k][1])
|
||
for k in sorted_keys[:evict_count]:
|
||
del _proxy_node_cache[k]
|
||
_proxy_node_cache[node_id] = (result, write_now + ttl)
|
||
|
||
return result
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 系统默认代理
|
||
# ---------------------------------------------------------------------------
|
||
_system_proxy_cache: tuple[dict[str, Any] | None, float] | None = None
|
||
_system_proxy_cache_lock = threading.Lock()
|
||
_SYSTEM_PROXY_CACHE_TTL = 60.0
|
||
|
||
|
||
def invalidate_proxy_node_cache(node_id: str) -> None:
|
||
"""主动清除指定节点的信息缓存(tunnel 断开时调用,避免使用过期的连接状态)"""
|
||
with _proxy_node_cache_lock:
|
||
_proxy_node_cache.pop(node_id, None)
|
||
|
||
|
||
def invalidate_system_proxy_cache() -> None:
|
||
"""手动失效系统代理缓存(在删除节点等操作后调用)"""
|
||
global _system_proxy_cache
|
||
with _system_proxy_cache_lock:
|
||
_system_proxy_cache = None
|
||
|
||
|
||
def get_system_proxy_config() -> dict[str, Any] | None:
|
||
"""
|
||
获取系统默认代理配置(带 TTL 缓存)
|
||
|
||
从 system_configs 表中读取 system_proxy_node_id。
|
||
返回 {"node_id": "...", "enabled": True} 或 None。
|
||
"""
|
||
global _system_proxy_cache
|
||
now = time.time()
|
||
|
||
# 快速路径:缓存命中
|
||
with _system_proxy_cache_lock:
|
||
if _system_proxy_cache:
|
||
value, expires_at = _system_proxy_cache
|
||
if now < expires_at:
|
||
return value
|
||
|
||
from src.database import create_session
|
||
from src.services.system.config import SystemConfigService
|
||
|
||
db = create_session()
|
||
try:
|
||
node_id = SystemConfigService.get_config(db, "system_proxy_node_id")
|
||
if node_id and isinstance(node_id, str) and node_id.strip():
|
||
result: dict[str, Any] | None = {"node_id": node_id.strip(), "enabled": True}
|
||
else:
|
||
result = None
|
||
except Exception as exc:
|
||
logger.warning("获取系统默认代理配置失败: {}", exc)
|
||
result = None
|
||
finally:
|
||
db.close()
|
||
|
||
with _system_proxy_cache_lock:
|
||
_system_proxy_cache = (result, now + _SYSTEM_PROXY_CACHE_TTL)
|
||
return result
|
||
|
||
|
||
async def get_system_proxy_config_async() -> dict[str, Any] | None:
|
||
"""异步读取系统默认代理配置,避免在事件循环中执行同步 DB 查询。"""
|
||
return await asyncio.to_thread(get_system_proxy_config)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 代理 URL 认证注入
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def inject_auth_into_proxy_url(proxy_url: str, username: str, password: str | None = None) -> str:
|
||
"""将用户名密码注入代理 URL(URL 编码处理特殊字符)"""
|
||
parsed = urlparse(proxy_url)
|
||
encoded_username = quote(username, safe="")
|
||
encoded_password = quote(password, safe="") if password else ""
|
||
host_part = parsed.hostname or "localhost"
|
||
if parsed.port:
|
||
host_part = f"{host_part}:{parsed.port}"
|
||
if encoded_password:
|
||
auth_url = f"{parsed.scheme}://{encoded_username}:{encoded_password}@{host_part}"
|
||
else:
|
||
auth_url = f"{parsed.scheme}://{encoded_username}@{host_part}"
|
||
if parsed.path:
|
||
auth_url += parsed.path
|
||
return auth_url
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TLS 代理参数
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def make_proxy_param(proxy_url: str | None) -> str | httpx.Proxy | None:
|
||
"""
|
||
根据代理 URL 返回 httpx 可接受的 proxy 参数。
|
||
|
||
对于 https:// scheme 的代理 URL(TLS aether-proxy 节点),返回 httpx.Proxy
|
||
并附带 proxy_ssl_context(CERT_NONE,因为使用自签名证书)。
|
||
其他情况返回普通 URL 字符串。
|
||
"""
|
||
if not proxy_url:
|
||
return None
|
||
|
||
# https:// 代理需要 ssl_context(自签名证书场景)
|
||
if proxy_url.startswith("https://"):
|
||
from src.utils.ssl_utils import get_proxy_ssl_context
|
||
|
||
return httpx.Proxy(url=proxy_url, ssl_context=get_proxy_ssl_context())
|
||
|
||
return proxy_url
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ops connector 代理解析
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _resolve_effective_node(
|
||
connector_config: dict[str, Any] | None,
|
||
) -> tuple[str | None, dict[str, Any] | None]:
|
||
"""
|
||
从 connector_config 或系统默认代理中解析有效的 proxy_node_id 及其信息。
|
||
|
||
Returns:
|
||
(node_id, node_info) 或 (None, None)
|
||
"""
|
||
if connector_config:
|
||
node_id = connector_config.get("proxy_node_id")
|
||
if isinstance(node_id, str) and node_id.strip():
|
||
nid = node_id.strip()
|
||
return nid, _get_proxy_node_info(nid)
|
||
|
||
# 回退:系统默认代理
|
||
system_proxy = get_system_proxy_config()
|
||
if system_proxy:
|
||
node_id_sys = system_proxy.get("node_id")
|
||
if isinstance(node_id_sys, str) and node_id_sys.strip():
|
||
nid = node_id_sys.strip()
|
||
return nid, _get_proxy_node_info(nid)
|
||
|
||
return None, None
|
||
|
||
|
||
def resolve_ops_proxy_config(
|
||
connector_config: dict[str, Any] | None,
|
||
) -> tuple[str | httpx.Proxy | None, str | None]:
|
||
"""
|
||
一次解析 ops connector 的代理参数和 tunnel 节点 ID
|
||
|
||
合并 resolve_ops_proxy + resolve_ops_tunnel_node_id,避免重复调用
|
||
_resolve_effective_node。两个返回值互斥:tunnel 模式时 proxy 为 None,
|
||
非 tunnel 模式时 tunnel_node_id 为 None。
|
||
|
||
优先级:
|
||
1. connector_config.proxy_node_id(新格式)
|
||
2. connector_config.proxy(旧格式 URL 字符串)
|
||
3. 系统默认代理节点
|
||
|
||
Returns:
|
||
(proxy, tunnel_node_id)
|
||
"""
|
||
from .tunnel_transport import is_tunnel_node
|
||
|
||
node_id, node_info = _resolve_effective_node(connector_config)
|
||
if node_id and node_info:
|
||
if is_tunnel_node(node_info):
|
||
return None, node_id
|
||
try:
|
||
url = build_proxy_url({"node_id": node_id, "enabled": True})
|
||
return make_proxy_param(url), None
|
||
except Exception as exc:
|
||
logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc)
|
||
return None, None
|
||
|
||
# 旧格式:直接返回 proxy URL 字符串
|
||
if connector_config:
|
||
proxy = connector_config.get("proxy")
|
||
if isinstance(proxy, str) and proxy.strip():
|
||
return proxy, None
|
||
|
||
return None, None
|
||
|
||
|
||
async def resolve_ops_proxy_config_async(
|
||
connector_config: dict[str, Any] | None,
|
||
) -> tuple[str | httpx.Proxy | None, str | None]:
|
||
"""异步解析 ops connector 代理配置,避免同步 DB 查询阻塞事件循环。"""
|
||
return await asyncio.to_thread(resolve_ops_proxy_config, connector_config)
|
||
|
||
|
||
def resolve_ops_proxy(
|
||
connector_config: dict[str, Any] | None,
|
||
) -> str | httpx.Proxy | None:
|
||
"""从 ops connector.config 中解析代理参数(含系统默认回退)
|
||
|
||
tunnel 模式节点不返回代理 URL。
|
||
如需同时获取 tunnel_node_id,请使用 resolve_ops_proxy_config 避免重复解析。
|
||
"""
|
||
proxy, _ = resolve_ops_proxy_config(connector_config)
|
||
return proxy
|
||
|
||
|
||
def resolve_ops_tunnel_node_id(
|
||
connector_config: dict[str, Any] | None,
|
||
) -> str | None:
|
||
"""解析 ops connector 的 tunnel 节点 ID
|
||
|
||
如需同时获取 proxy,请使用 resolve_ops_proxy_config 避免重复解析。
|
||
"""
|
||
_, tunnel_node_id = resolve_ops_proxy_config(connector_config)
|
||
return tunnel_node_id
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Key 级别代理优先解析
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def resolve_effective_proxy(
|
||
provider_proxy: dict[str, Any] | None,
|
||
key_proxy: dict[str, Any] | None = None,
|
||
) -> dict[str, Any] | None:
|
||
"""
|
||
解析有效的代理配置,Key 级别代理优先于 Provider 级别代理。
|
||
|
||
Args:
|
||
provider_proxy: Provider 级别代理配置
|
||
key_proxy: Key 级别代理配置(可选),非 None 且 enabled 时覆盖 Provider 级别
|
||
|
||
Returns:
|
||
有效的代理配置字典,或 None(无代理)
|
||
"""
|
||
if key_proxy and key_proxy.get("enabled", True):
|
||
return key_proxy
|
||
return provider_proxy
|
||
|
||
|
||
def resolve_proxy_param(
|
||
proxy_config: dict[str, Any] | None = None,
|
||
) -> str | httpx.Proxy | None:
|
||
"""
|
||
将代理配置解析为 httpx 可接受的代理参数(含系统默认回退)
|
||
|
||
优先级:proxy_config -> 系统默认代理 -> None(直连)
|
||
|
||
Args:
|
||
proxy_config: 代理配置字典(通常来自 resolve_effective_proxy 的返回值)
|
||
|
||
Returns:
|
||
httpx 可接受的 proxy 参数,或 None
|
||
"""
|
||
url = build_proxy_url(proxy_config) if proxy_config else None
|
||
if not url:
|
||
sys_proxy = get_system_proxy_config()
|
||
if sys_proxy:
|
||
try:
|
||
url = build_proxy_url(sys_proxy)
|
||
except Exception as exc:
|
||
logger.warning("resolve_proxy_param: 构建系统默认代理 URL 失败: {}", exc)
|
||
url = None
|
||
return make_proxy_param(url)
|
||
|
||
|
||
def build_proxy_client_kwargs(
|
||
proxy_config: dict[str, Any] | None = None,
|
||
*,
|
||
timeout: float = 30.0,
|
||
verify: Any | None = None,
|
||
**extra: Any,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
构建包含代理配置的 httpx.AsyncClient 初始化参数。
|
||
|
||
将 resolve_proxy_param + dict 构建 + 条件 proxy 赋值合并为一步,
|
||
减少调用方的样板代码。
|
||
|
||
Args:
|
||
proxy_config: 代理配置字典(通常来自 resolve_effective_proxy)
|
||
timeout: 请求超时(秒)
|
||
verify: SSL 验证参数,None 时自动使用 get_ssl_context()
|
||
**extra: 其他 httpx.AsyncClient 参数(如 follow_redirects)
|
||
|
||
Returns:
|
||
可直接解包传给 httpx.AsyncClient 的参数字典
|
||
"""
|
||
if verify is None:
|
||
from src.utils.ssl_utils import get_ssl_context
|
||
|
||
verify = get_ssl_context()
|
||
|
||
kwargs: dict[str, Any] = {"timeout": timeout, "verify": verify, **extra}
|
||
|
||
# tunnel 模式优先:当代理节点为 tunnel 模式时,使用 tunnel transport 工厂
|
||
delegate_cfg = resolve_delegate_config(proxy_config)
|
||
if delegate_cfg and delegate_cfg.get("tunnel"):
|
||
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
|
||
|
||
timeout_secs = timeout if isinstance(timeout, (int, float)) else 60.0
|
||
kwargs["transport"] = create_tunnel_transport(delegate_cfg["node_id"], timeout=timeout_secs)
|
||
return kwargs
|
||
|
||
proxy_param = resolve_proxy_param(proxy_config)
|
||
if proxy_param:
|
||
kwargs["proxy"] = proxy_param
|
||
return kwargs
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 代理 URL 构建
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
|
||
"""
|
||
根据代理配置构建完整的代理 URL
|
||
|
||
Args:
|
||
proxy_config: 代理配置字典,支持两种模式:
|
||
- 手动 URL 模式: {url, username, password, enabled}
|
||
- ProxyNode 模式: {node_id, enabled}
|
||
|
||
Returns:
|
||
完整的代理 URL,如 socks5://user:pass@host:port
|
||
如果 enabled=False 或无配置,返回 None
|
||
"""
|
||
if not proxy_config:
|
||
return None
|
||
|
||
# 检查 enabled 字段,默认为 True(兼容旧数据)
|
||
if not proxy_config.get("enabled", True):
|
||
return None
|
||
|
||
# ProxyNode 模式(aether-proxy 或手动节点)
|
||
node_id = proxy_config.get("node_id")
|
||
if isinstance(node_id, str) and node_id.strip():
|
||
node_id = node_id.strip()
|
||
node_info = _get_proxy_node_info(node_id)
|
||
if not node_info:
|
||
logger.warning("代理节点不可用(离线或不存在): node_id={}", node_id)
|
||
message = f"代理节点 {node_id} 不可用"
|
||
raise ProxyNodeUnavailableError(message, node_id=node_id)
|
||
|
||
# 手动节点:直接使用存储的代理 URL(含认证信息)
|
||
if node_info.get("is_manual"):
|
||
manual_url = node_info.get("proxy_url")
|
||
if not manual_url:
|
||
raise ProxyNodeUnavailableError(
|
||
f"手动代理节点 {node_id} 缺少 proxy_url", node_id=node_id
|
||
)
|
||
username = node_info.get("username")
|
||
password = node_info.get("password")
|
||
if username:
|
||
return inject_auth_into_proxy_url(manual_url, username, password)
|
||
return manual_url
|
||
|
||
# tunnel 模式节点:不构建 proxy URL(通过 TunnelTransport 处理)
|
||
from .tunnel_transport import is_tunnel_node
|
||
|
||
if is_tunnel_node(node_info):
|
||
return None
|
||
|
||
# aether-proxy 节点均为 tunnel 模式,不应走到这里
|
||
logger.warning("非 tunnel 模式的 aether-proxy 节点不再支持: node_id={}", node_id)
|
||
return None
|
||
|
||
proxy_url: str | None = proxy_config.get("url")
|
||
if not proxy_url:
|
||
return None
|
||
|
||
username = proxy_config.get("username")
|
||
password = proxy_config.get("password")
|
||
|
||
# 只要有用户名就添加认证信息(密码可以为空)
|
||
if username:
|
||
return inject_auth_into_proxy_url(proxy_url, username, password)
|
||
|
||
return proxy_url
|
||
|
||
|
||
async def build_proxy_url_async(proxy_config: dict[str, Any] | None) -> str | None:
|
||
"""异步构建代理 URL,避免 ProxyNode 查询阻塞事件循环。"""
|
||
if not proxy_config:
|
||
return None
|
||
return await asyncio.to_thread(build_proxy_url, proxy_config)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 代理信息追踪(日志/usage)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def resolve_proxy_info(proxy_config: dict[str, Any] | None) -> dict[str, Any] | None:
|
||
"""
|
||
解析代理配置的摘要信息(用于日志和 usage 记录)
|
||
|
||
不构建实际的代理 URL,仅返回可读的代理标识信息。
|
||
|
||
Returns:
|
||
{"node_id": "xxx", "node_name": "proxy-01", "source": "provider"} 或
|
||
{"url": "socks5://host:port", "source": "provider"} 或
|
||
{"node_id": "xxx", "node_name": "...", "source": "system"} 或
|
||
None (直连)
|
||
"""
|
||
source = "provider"
|
||
effective_config = proxy_config
|
||
|
||
# 无 provider 级代理时,尝试系统默认代理
|
||
if not effective_config or not effective_config.get("enabled", True):
|
||
effective_config = get_system_proxy_config()
|
||
source = "system"
|
||
|
||
if not effective_config or not effective_config.get("enabled", True):
|
||
return None
|
||
|
||
# ProxyNode 模式
|
||
node_id = effective_config.get("node_id")
|
||
if isinstance(node_id, str) and node_id.strip():
|
||
node_id = node_id.strip()
|
||
node_info = _get_proxy_node_info(node_id)
|
||
node_name = node_info.get("name", "unknown") if node_info else "offline"
|
||
info: dict[str, Any] = {"node_id": node_id, "node_name": node_name, "source": source}
|
||
if node_info and node_info.get("is_manual"):
|
||
info["is_manual"] = True
|
||
return info
|
||
|
||
# 旧格式 URL 模式
|
||
proxy_url = effective_config.get("url")
|
||
if proxy_url:
|
||
# 脱敏:只保留 scheme + host + port
|
||
try:
|
||
parsed = urlparse(proxy_url)
|
||
host_part = parsed.hostname or "unknown"
|
||
if parsed.port:
|
||
host_part = f"{host_part}:{parsed.port}"
|
||
safe_url = f"{parsed.scheme}://{host_part}"
|
||
except Exception:
|
||
safe_url = "unknown"
|
||
return {"url": safe_url, "source": source}
|
||
|
||
return None
|
||
|
||
|
||
async def resolve_proxy_info_async(proxy_config: dict[str, Any] | None) -> dict[str, Any] | None:
|
||
"""异步解析代理摘要信息,避免事件循环被同步代理解析阻塞。"""
|
||
return await asyncio.to_thread(resolve_proxy_info, proxy_config)
|
||
|
||
|
||
def get_proxy_label(proxy_info: dict[str, Any] | None) -> str:
|
||
"""从 proxy_info 中提取简短的代理标签(用于日志)"""
|
||
if not proxy_info:
|
||
return "direct"
|
||
return proxy_info.get("node_name") or proxy_info.get("url") or "unknown"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 代理缓存键计算(供 HTTPClientPool 使用)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
|
||
"""
|
||
计算代理配置的缓存键
|
||
|
||
Args:
|
||
proxy_config: 代理配置字典
|
||
|
||
Returns:
|
||
缓存键字符串,无代理时返回 "__no_proxy__"
|
||
"""
|
||
if not proxy_config:
|
||
return "__no_proxy__"
|
||
|
||
# enabled=False 时视为无代理(兼容旧数据)
|
||
if not proxy_config.get("enabled", True):
|
||
return "__no_proxy__"
|
||
|
||
# ProxyNode 模式:基于 node_id 缓存
|
||
node_id = proxy_config.get("node_id")
|
||
if isinstance(node_id, str) and node_id.strip():
|
||
return f"proxy_node:{node_id.strip()}"
|
||
|
||
# 构建代理 URL 作为缓存键的基础
|
||
proxy_url = build_proxy_url(proxy_config)
|
||
if not proxy_url:
|
||
return "__no_proxy__"
|
||
|
||
# 使用 MD5 哈希来避免过长的键名
|
||
return f"proxy:{hashlib.md5(proxy_url.encode()).hexdigest()[:16]}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Tunnel 代理配置解析
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def resolve_delegate_config(proxy_config: dict[str, Any] | None) -> dict[str, Any] | None:
|
||
"""
|
||
解析 tunnel 代理配置(仅 aether-proxy tunnel 节点支持)
|
||
|
||
无特定代理时自动回退到系统默认代理。
|
||
tunnel 模式节点返回 {"tunnel": True, "node_id": str},
|
||
调用方应使用 TunnelTransport。
|
||
|
||
Returns:
|
||
{"tunnel": True, "node_id": str} 或 None
|
||
"""
|
||
effective_config = proxy_config
|
||
|
||
if not effective_config or not effective_config.get("enabled", True):
|
||
effective_config = get_system_proxy_config()
|
||
|
||
if not effective_config or not effective_config.get("enabled", True):
|
||
return None
|
||
|
||
node_id = effective_config.get("node_id")
|
||
if not isinstance(node_id, str) or not node_id.strip():
|
||
return None
|
||
|
||
node_id = node_id.strip()
|
||
node_info = _get_proxy_node_info(node_id)
|
||
if not node_info or node_info.get("is_manual"):
|
||
return None
|
||
|
||
from .tunnel_transport import is_tunnel_node
|
||
|
||
if is_tunnel_node(node_info):
|
||
return {"tunnel": True, "node_id": node_id}
|
||
|
||
return None
|
||
|
||
|
||
async def resolve_delegate_config_async(
|
||
proxy_config: dict[str, Any] | None,
|
||
) -> dict[str, Any] | None:
|
||
"""异步解析 tunnel 代理配置,避免同步 DB 查询阻塞事件循环。"""
|
||
return await asyncio.to_thread(resolve_delegate_config, proxy_config)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 统一上游请求参数构建
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _maybe_compress_payload(
|
||
payload: Any,
|
||
headers: dict[str, str],
|
||
client_content_encoding: str | None = None,
|
||
) -> tuple[bytes, dict[str, str]]:
|
||
"""
|
||
将 payload 序列化为 JSON bytes,并按客户端请求行为决定是否 gzip 压缩。
|
||
|
||
NOTE: 使用紧凑分隔符 ``(",", ":")`` 序列化(无空格),相比 httpx ``json=``
|
||
参数的默认 ``json.dumps``(带空格分隔符)体积更小,所有上游 API 均兼容。
|
||
|
||
Returns:
|
||
(body_bytes, updated_headers)
|
||
"""
|
||
json_bytes = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||
normalized_headers = {k: v for k, v in headers.items() if k.lower() != "content-encoding"}
|
||
|
||
if is_gzip_content_encoding(client_content_encoding):
|
||
compressed = gzip.compress(json_bytes, compresslevel=6)
|
||
normalized_headers = {**normalized_headers, "Content-Encoding": "gzip"}
|
||
return compressed, normalized_headers
|
||
|
||
return json_bytes, normalized_headers
|
||
|
||
|
||
async def _maybe_compress_payload_async(
|
||
payload: Any,
|
||
headers: dict[str, str],
|
||
client_content_encoding: str | None = None,
|
||
) -> tuple[bytes, dict[str, str]]:
|
||
"""异步版 _maybe_compress_payload,仅在 payload 超过阈值时走线程池。"""
|
||
json_bytes = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||
normalized_headers = {k: v for k, v in headers.items() if k.lower() != "content-encoding"}
|
||
|
||
if is_gzip_content_encoding(client_content_encoding):
|
||
if len(json_bytes) >= _ASYNC_PAYLOAD_THRESHOLD:
|
||
compressed = await asyncio.to_thread(gzip.compress, json_bytes, 6)
|
||
else:
|
||
compressed = gzip.compress(json_bytes, compresslevel=6)
|
||
return compressed, {**normalized_headers, "Content-Encoding": "gzip"}
|
||
|
||
return json_bytes, normalized_headers
|
||
|
||
|
||
def build_post_kwargs(
|
||
_delegate_cfg: dict[str, Any] | None = None,
|
||
*,
|
||
url: str,
|
||
headers: dict[str, str],
|
||
payload: Any,
|
||
timeout: float,
|
||
client_content_encoding: str | None = None,
|
||
refresh_auth: bool = False,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
构建上游 POST 请求的 httpx kwargs
|
||
|
||
返回的 dict 可直接传给 ``http_client.post(**kwargs)``。
|
||
|
||
``_delegate_cfg`` 和 ``refresh_auth`` 已废弃(tunnel 模式下认证由 transport 层处理),
|
||
保留仅为兼容现有调用方签名。
|
||
"""
|
||
content, final_headers = _maybe_compress_payload(
|
||
payload,
|
||
headers,
|
||
client_content_encoding=client_content_encoding,
|
||
)
|
||
return {
|
||
"url": url,
|
||
"content": content,
|
||
"headers": final_headers,
|
||
"timeout": httpx.Timeout(timeout),
|
||
}
|
||
|
||
|
||
async def build_post_kwargs_async(
|
||
_delegate_cfg: dict[str, Any] | None = None,
|
||
*,
|
||
url: str,
|
||
headers: dict[str, str],
|
||
payload: Any,
|
||
timeout: float,
|
||
client_content_encoding: str | None = None,
|
||
refresh_auth: bool = False,
|
||
) -> dict[str, Any]:
|
||
"""构建 POST kwargs,大 payload 的序列化/压缩走线程池避免阻塞事件循环。"""
|
||
content, final_headers = await _maybe_compress_payload_async(
|
||
payload, headers, client_content_encoding
|
||
)
|
||
return {
|
||
"url": url,
|
||
"content": content,
|
||
"headers": final_headers,
|
||
"timeout": httpx.Timeout(timeout),
|
||
}
|
||
|
||
|
||
def build_stream_kwargs(
|
||
_delegate_cfg: dict[str, Any] | None = None,
|
||
*,
|
||
url: str,
|
||
headers: dict[str, str],
|
||
payload: Any,
|
||
timeout: float | None = None,
|
||
client_content_encoding: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
构建上游 stream 请求的 httpx kwargs
|
||
|
||
返回的 dict 可直接传给 ``http_client.stream(**kwargs)``。
|
||
当 ``timeout`` 为 None 时由外层 asyncio.wait_for 控制超时。
|
||
|
||
``_delegate_cfg`` 已废弃,保留仅为兼容现有调用方签名。
|
||
"""
|
||
content, final_headers = _maybe_compress_payload(
|
||
payload,
|
||
headers,
|
||
client_content_encoding=client_content_encoding,
|
||
)
|
||
kwargs: dict[str, Any] = {
|
||
"method": "POST",
|
||
"url": url,
|
||
"content": content,
|
||
"headers": final_headers,
|
||
}
|
||
if timeout is not None:
|
||
kwargs["timeout"] = httpx.Timeout(timeout)
|
||
return kwargs
|
||
|
||
|
||
async def build_stream_kwargs_async(
|
||
_delegate_cfg: dict[str, Any] | None = None,
|
||
*,
|
||
url: str,
|
||
headers: dict[str, str],
|
||
payload: Any,
|
||
timeout: float | None = None,
|
||
client_content_encoding: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""构建 stream kwargs,大 payload 的序列化/压缩走线程池避免阻塞事件循环。"""
|
||
content, final_headers = await _maybe_compress_payload_async(
|
||
payload, headers, client_content_encoding
|
||
)
|
||
kwargs: dict[str, Any] = {
|
||
"method": "POST",
|
||
"url": url,
|
||
"content": content,
|
||
"headers": final_headers,
|
||
}
|
||
if timeout is not None:
|
||
kwargs["timeout"] = httpx.Timeout(timeout)
|
||
return kwargs
|