refactor(proxy-node): 统一代理解析,全面支持 tunnel 模式

新增 resolve_ops_proxy_config 合并 proxy 和 tunnel_node_id 的解析,
避免各架构重复调用 _resolve_effective_node。所有架构连接器
(anyrouter/nekocode/sub2api/yescode) 和 HTTPClientPool 均适配
tunnel 模式,通过 TunnelTransport 替代传统代理。
This commit is contained in:
fawney19
2026-02-26 12:25:23 +08:00
parent 560345a889
commit 27cef96789
9 changed files with 101 additions and 51 deletions

View File

@@ -204,6 +204,13 @@ class HTTPClientPool:
if not proxy_config:
proxy_config = get_system_proxy_config()
# tunnel 模式检查tunnel 节点走专用的 TunnelTransport 客户端
from src.services.proxy_node.resolver import resolve_delegate_config
delegate_cfg = resolve_delegate_config(proxy_config)
if delegate_cfg and delegate_cfg.get("tunnel"):
return await cls._get_tunnel_client(delegate_cfg["node_id"])
cache_key = compute_proxy_cache_key(proxy_config)
# 无代理时返回默认客户端

View File

@@ -188,7 +188,10 @@ def _parse_session_user_id(cookie_input: str) -> tuple[str | None, str | None]:
async def _get_acw_cookie(
base_url: str, timeout: float = 10, proxy: str | httpx.Proxy | None = None
base_url: str,
timeout: float = 10,
proxy: str | httpx.Proxy | None = None,
tunnel_node_id: str | None = None,
) -> str | None:
"""
获取 acw_sc__v2 Cookie
@@ -199,6 +202,7 @@ async def _get_acw_cookie(
base_url: 目标站点 URL
timeout: 请求超时时间
proxy: 代理地址
tunnel_node_id: tunnel 模式节点 ID优先于 proxy
Returns:
Cookie 字符串 (acw_sc__v2=xxx),如果不需要或获取失败则返回 None
@@ -209,7 +213,11 @@ async def _get_acw_cookie(
"timeout": timeout,
"verify": get_ssl_context(),
}
if proxy:
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=timeout)
elif proxy:
client_kwargs["proxy"] = proxy
logger.debug(f"获取 acw_sc__v2 Cookie 使用代理: {proxy}")
@@ -273,7 +281,9 @@ class AnyrouterConnector(ProviderConnector):
self._user_id, _ = _parse_session_user_id(session_cookie)
# 尝试获取反爬 Cookie使用配置中的代理
self._acw_cookie = await _get_acw_cookie(self.base_url, proxy=self._proxy)
self._acw_cookie = await _get_acw_cookie(
self.base_url, proxy=self._proxy, tunnel_node_id=self._tunnel_node_id
)
self._set_connected()
return True
@@ -406,11 +416,11 @@ class AnyrouterArchitecture(ProviderArchitecture):
Returns:
包含 acw_cookie 的配置
"""
# 从 config 获取代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy
# 从 config 获取代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy_config
proxy = resolve_ops_proxy(config)
acw_cookie = await _get_acw_cookie(base_url, proxy=proxy)
proxy, tunnel_node_id = resolve_ops_proxy_config(config)
acw_cookie = await _get_acw_cookie(base_url, proxy=proxy, tunnel_node_id=tunnel_node_id)
if acw_cookie:
return {"acw_cookie": acw_cookie}
return {}

View File

@@ -50,11 +50,12 @@ class ProviderConnector(ABC):
self._expires_at: datetime | None = None
self._last_error: str | None = None
# 代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy, resolve_ops_tunnel_node_id
# 代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy_config
self._proxy: str | httpx.Proxy | None = resolve_ops_proxy(self.config)
self._tunnel_node_id: str | None = resolve_ops_tunnel_node_id(self.config)
self._proxy: str | httpx.Proxy | None
self._tunnel_node_id: str | None
self._proxy, self._tunnel_node_id = resolve_ops_proxy_config(self.config)
# HTTP 客户端配置
self._timeout = self.config.get("timeout", 30)

View File

@@ -193,10 +193,14 @@ class NekoCodeArchitecture(ProviderArchitecture):
"timeout": 10,
"verify": get_ssl_context(),
}
from src.services.proxy_node.resolver import resolve_ops_proxy
from src.services.proxy_node.resolver import resolve_ops_proxy_config
proxy = resolve_ops_proxy(config)
if proxy:
proxy, tunnel_node_id = resolve_ops_proxy_config(config)
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=10.0)
elif proxy:
client_kwargs["proxy"] = proxy
async with httpx.AsyncClient(**client_kwargs) as client:

View File

@@ -99,12 +99,17 @@ class _Sub2ApiTokenMixin:
base_url: str
_timeout: int | float
_proxy: str | httpx.Proxy | None
_tunnel_node_id: str | None
@asynccontextmanager
async def _get_raw_client(self) -> AsyncIterator[httpx.AsyncClient]:
"""获取不带 auth hook 的裸 HTTP 客户端(用于登录/刷新 token"""
transport = None
if self._proxy:
if self._tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
transport = TunnelTransport(self._tunnel_node_id, timeout=self._timeout)
elif self._proxy:
transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
async with httpx.AsyncClient(
base_url=self.base_url,
@@ -474,15 +479,19 @@ class Sub2ApiArchitecture(ProviderArchitecture):
"""
base_url = base_url.rstrip("/")
from src.services.proxy_node.resolver import resolve_ops_proxy
from src.services.proxy_node.resolver import resolve_ops_proxy_config
proxy = resolve_ops_proxy(config)
proxy, tunnel_node_id = resolve_ops_proxy_config(config)
client_kwargs: dict[str, Any] = {
"base_url": base_url,
"timeout": 30.0,
"verify": get_ssl_context(),
}
if proxy:
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=30.0)
elif proxy:
client_kwargs["proxy"] = proxy
email = credentials.get("email", "").strip()

View File

@@ -232,10 +232,10 @@ class YesCodeArchitecture(ProviderArchitecture):
cookie_header = _build_cookie_header(cookie_input)
# 获取代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy
# 获取代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy_config
proxy = resolve_ops_proxy(config)
proxy, tunnel_node_id = resolve_ops_proxy_config(config)
try:
# 构建 client 参数
@@ -244,7 +244,11 @@ class YesCodeArchitecture(ProviderArchitecture):
"timeout": 10.0,
"verify": get_ssl_context(),
}
if proxy:
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=10.0)
elif proxy:
client_kwargs["proxy"] = proxy
# 创建临时 client 获取合并数据

View File

@@ -1034,10 +1034,9 @@ class ProviderOpsService:
)
# 获取代理配置(支持 proxy_node_id、tunnel 模式和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy, resolve_ops_tunnel_node_id
from src.services.proxy_node.resolver import resolve_ops_proxy_config
proxy = resolve_ops_proxy(config)
tunnel_node_id = resolve_ops_tunnel_node_id(config)
proxy, tunnel_node_id = resolve_ops_proxy_config(config)
try:
# 构建 httpx client 参数

View File

@@ -14,6 +14,7 @@ from .resolver import (
make_proxy_param,
resolve_delegate_config,
resolve_ops_proxy,
resolve_ops_proxy_config,
resolve_ops_tunnel_node_id,
resolve_proxy_info,
)
@@ -36,6 +37,7 @@ __all__ = [
"invalidate_system_proxy_cache",
"resolve_delegate_config",
"resolve_ops_proxy",
"resolve_ops_proxy_config",
"resolve_ops_tunnel_node_id",
"resolve_proxy_info",
]

View File

@@ -216,63 +216,67 @@ def _resolve_effective_node(
return None, None
def resolve_ops_proxy(
def resolve_ops_proxy_config(
connector_config: dict[str, Any] | None,
) -> str | httpx.Proxy | None:
) -> tuple[str | httpx.Proxy | None, str | None]:
"""
ops connector.config 中解析代理参数(含系统默认回退)
一次解析 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. 系统默认代理节点
tunnel 模式节点不返回代理 URL由 resolve_ops_tunnel_node_id 处理)。
Args:
connector_config: connector 的 config 字典
Returns:
httpx 可接受的代理参数str 或 httpx.Proxy或 None
(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 # tunnel 模式不使用 proxy URL
return None, node_id
try:
url = build_proxy_url({"node_id": node_id, "enabled": True})
return make_proxy_param(url)
return make_proxy_param(url), None
except Exception as exc:
logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc)
return None
return None, None
# 旧格式:直接返回 proxy URL 字符串
if connector_config:
proxy = connector_config.get("proxy")
if isinstance(proxy, str) and proxy.strip():
return proxy
return proxy, None
return None
return None, None
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 避免重复解析。
"""
解析 ops connector 的 tunnel 节点 ID
如果配置的代理节点是 tunnel 模式且已连接,返回 node_id。
否则返回 None含系统默认代理回退
"""
from .tunnel_transport import is_tunnel_node
node_id, node_info = _resolve_effective_node(connector_config)
if node_id and node_info and is_tunnel_node(node_info):
return node_id
return None
_, tunnel_node_id = resolve_ops_proxy_config(connector_config)
return tunnel_node_id
# ---------------------------------------------------------------------------
@@ -353,6 +357,16 @@ def build_proxy_client_kwargs(
verify = get_ssl_context()
kwargs: dict[str, Any] = {"timeout": timeout, "verify": verify, **extra}
# tunnel 模式优先:当代理节点为 tunnel 模式时,使用 TunnelTransport
delegate_cfg = resolve_delegate_config(proxy_config)
if delegate_cfg and delegate_cfg.get("tunnel"):
from src.services.proxy_node.tunnel_transport import TunnelTransport
timeout_secs = timeout if isinstance(timeout, (int, float)) else 60.0
kwargs["transport"] = TunnelTransport(delegate_cfg["node_id"], timeout=timeout_secs)
return kwargs
proxy_param = resolve_proxy_param(proxy_config)
if proxy_param:
kwargs["proxy"] = proxy_param