mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: delegate 客户端替换为 hyper 原生实现,新增代理管理功能
aether-proxy: - 用 hyper-util Client + 自定义 InstrumentedConnector 替换 reqwest delegate 客户端 - 支持 HTTP/HTTPS 自动 TLS,ALPN h2 协商,connect/tls 分阶段计时 - ConnectTiming 通过 hyper extensions 传递,上游响应细分 connect_ms/tls_ms/ttfb_ms - upgrade 命令在非 root 下跳过 systemd restart 并提示手动操作 后端: - 新增 /admin/proxy-nodes/test-url 接口,支持直接测试代理 URL 连通性 - 新增 /admin/proxy-nodes/hmac-key 接口,获取 HMAC Key 供部署使用 - 提取 _test_proxy_connectivity 公共函数,消除 test_node 中的重复代码 - candidate_resolver 在 extra_data 中输出 needs_conversion/provider_api_format - stats_aggregator 小时聚合增加 IntegrityError 冲突重试 前端: - 请求时间线组件展示代理 timing 细分(DNS/连接/TLS/TTFB/上游处理) - 请求时间线增加格式转换分界标记和 conversion badge - ProxyNodes 页面新增代理 URL 测试和 HMAC Key 复制功能 - HardwareTooltip 从 Popover 改为 Tooltip 组件
This commit is contained in:
@@ -216,6 +216,18 @@ async def test_proxy_node(node_id: str, request: Request, db: Session = Depends(
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/hmac-key")
|
||||
async def get_proxy_hmac_key(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = AdminGetProxyHmacKeyAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.post("/test-url")
|
||||
async def test_proxy_url(request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
adapter = AdminTestProxyUrlAdapter()
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.put("/{node_id}/config")
|
||||
async def update_proxy_node_config(
|
||||
node_id: str, request: Request, db: Session = Depends(get_db)
|
||||
@@ -485,3 +497,46 @@ class AdminUpdateProxyNodeConfigAdapter(AdminApiAdapter):
|
||||
"remote_config": node.remote_config,
|
||||
"node": node_to_dict(node),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminGetProxyHmacKeyAdapter(AdminApiAdapter):
|
||||
"""获取 proxy_hmac_key 供管理员复制到 aether-proxy 部署"""
|
||||
|
||||
name: str = "admin_get_proxy_hmac_key"
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any:
|
||||
from src.config.settings import config
|
||||
|
||||
key = config.proxy_hmac_key
|
||||
if not key:
|
||||
raise InvalidRequestException(
|
||||
"PROXY_HMAC_KEY 未配置(也未设置 ENCRYPTION_KEY 用于自动派生)"
|
||||
)
|
||||
return {"proxy_hmac_key": key}
|
||||
|
||||
|
||||
class TestProxyUrlRequest(BaseModel):
|
||||
proxy_url: str = Field(..., min_length=1, max_length=500)
|
||||
username: str | None = Field(None, max_length=255)
|
||||
password: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdminTestProxyUrlAdapter(AdminApiAdapter):
|
||||
"""通过 proxy_url 直接测试代理连通性(无需已注册节点)"""
|
||||
|
||||
name: str = "admin_test_proxy_url"
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any:
|
||||
payload = context.ensure_json_body()
|
||||
try:
|
||||
req = TestProxyUrlRequest.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
|
||||
|
||||
return await ProxyNodeService.test_proxy_url(
|
||||
proxy_url=req.proxy_url,
|
||||
username=req.username,
|
||||
password=req.password,
|
||||
)
|
||||
|
||||
@@ -205,7 +205,10 @@ class CandidateResolver:
|
||||
"status": "skipped",
|
||||
"skip_reason": candidate.skip_reason,
|
||||
"is_cached": candidate.is_cached,
|
||||
"extra_data": {},
|
||||
"extra_data": {
|
||||
"needs_conversion": candidate.needs_conversion,
|
||||
"provider_api_format": candidate.provider_api_format or None,
|
||||
},
|
||||
"required_capabilities": active_capabilities,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
}
|
||||
@@ -235,7 +238,10 @@ class CandidateResolver:
|
||||
"key_id": key.id,
|
||||
"status": "available",
|
||||
"is_cached": candidate.is_cached,
|
||||
"extra_data": {},
|
||||
"extra_data": {
|
||||
"needs_conversion": candidate.needs_conversion,
|
||||
"provider_api_format": candidate.provider_api_format or None,
|
||||
},
|
||||
"required_capabilities": active_capabilities,
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
}
|
||||
|
||||
@@ -91,6 +91,69 @@ def _sanitize_proxy_error(err: Exception) -> str:
|
||||
return re.sub(r"://[^@/]+@", "://***@", str(err))
|
||||
|
||||
|
||||
async def _test_proxy_connectivity(proxy_url: str) -> dict[str, Any]:
|
||||
"""通过代理 URL 测试连通性,返回标准化结果 dict"""
|
||||
import time as _time
|
||||
|
||||
test_url = "https://1.1.1.1/cdn-cgi/trace"
|
||||
start = _time.monotonic()
|
||||
proxy_param = make_proxy_param(proxy_url)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
proxy=proxy_param,
|
||||
timeout=httpx.Timeout(15.0, connect=10.0),
|
||||
) as client:
|
||||
response = await client.get(test_url)
|
||||
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
|
||||
|
||||
exit_ip = None
|
||||
if response.status_code == 200:
|
||||
for line in response.text.splitlines():
|
||||
if line.startswith("ip="):
|
||||
exit_ip = line.split("=", 1)[1].strip()
|
||||
break
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"latency_ms": elapsed_ms,
|
||||
"exit_ip": exit_ip,
|
||||
"error": None,
|
||||
}
|
||||
except httpx.ProxyError as exc:
|
||||
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
|
||||
return {
|
||||
"success": False,
|
||||
"latency_ms": elapsed_ms,
|
||||
"exit_ip": None,
|
||||
"error": f"代理连接失败: {_sanitize_proxy_error(exc)}",
|
||||
}
|
||||
except httpx.ConnectError as exc:
|
||||
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
|
||||
return {
|
||||
"success": False,
|
||||
"latency_ms": elapsed_ms,
|
||||
"exit_ip": None,
|
||||
"error": f"连接失败: {_sanitize_proxy_error(exc)}",
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
|
||||
return {
|
||||
"success": False,
|
||||
"latency_ms": elapsed_ms,
|
||||
"exit_ip": None,
|
||||
"error": "连接超时(15秒)",
|
||||
}
|
||||
except Exception as exc:
|
||||
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
|
||||
return {
|
||||
"success": False,
|
||||
"latency_ms": elapsed_ms,
|
||||
"exit_ip": None,
|
||||
"error": _sanitize_proxy_error(exc),
|
||||
}
|
||||
|
||||
|
||||
def _build_test_proxy_url(node: ProxyNode) -> str:
|
||||
"""为测试连通性构建代理 URL(无需节点在线)"""
|
||||
if node.is_manual:
|
||||
@@ -377,76 +440,25 @@ class ProxyNodeService:
|
||||
@staticmethod
|
||||
async def test_node(db: Session, *, node_id: str) -> dict[str, Any]:
|
||||
"""测试代理节点连通性和延迟"""
|
||||
import time as _time
|
||||
|
||||
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
|
||||
if not node:
|
||||
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
|
||||
|
||||
# 构建代理 URL
|
||||
try:
|
||||
proxy_url = _build_test_proxy_url(node)
|
||||
except Exception as exc:
|
||||
return {"success": False, "latency_ms": None, "exit_ip": None, "error": str(exc)}
|
||||
|
||||
test_url = "https://1.1.1.1/cdn-cgi/trace"
|
||||
start = _time.monotonic()
|
||||
return await _test_proxy_connectivity(proxy_url)
|
||||
|
||||
proxy_param = make_proxy_param(proxy_url)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
proxy=proxy_param,
|
||||
timeout=httpx.Timeout(15.0, connect=10.0),
|
||||
) as client:
|
||||
response = await client.get(test_url)
|
||||
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
|
||||
|
||||
exit_ip = None
|
||||
if response.status_code == 200:
|
||||
for line in response.text.splitlines():
|
||||
if line.startswith("ip="):
|
||||
exit_ip = line.split("=", 1)[1].strip()
|
||||
break
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"latency_ms": elapsed_ms,
|
||||
"exit_ip": exit_ip,
|
||||
"error": None,
|
||||
}
|
||||
except httpx.ProxyError as exc:
|
||||
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
|
||||
return {
|
||||
"success": False,
|
||||
"latency_ms": elapsed_ms,
|
||||
"exit_ip": None,
|
||||
"error": f"代理连接失败: {_sanitize_proxy_error(exc)}",
|
||||
}
|
||||
except httpx.ConnectError as exc:
|
||||
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
|
||||
return {
|
||||
"success": False,
|
||||
"latency_ms": elapsed_ms,
|
||||
"exit_ip": None,
|
||||
"error": f"连接失败: {_sanitize_proxy_error(exc)}",
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
|
||||
return {
|
||||
"success": False,
|
||||
"latency_ms": elapsed_ms,
|
||||
"exit_ip": None,
|
||||
"error": "连接超时(15秒)",
|
||||
}
|
||||
except Exception as exc:
|
||||
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
|
||||
return {
|
||||
"success": False,
|
||||
"latency_ms": elapsed_ms,
|
||||
"exit_ip": None,
|
||||
"error": _sanitize_proxy_error(exc),
|
||||
}
|
||||
@staticmethod
|
||||
async def test_proxy_url(
|
||||
*, proxy_url: str, username: str | None = None, password: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""直接通过 proxy_url 测试代理连通性(无需已注册节点)"""
|
||||
if username:
|
||||
proxy_url = inject_auth_into_proxy_url(proxy_url, username, password)
|
||||
return await _test_proxy_connectivity(proxy_url)
|
||||
|
||||
@staticmethod
|
||||
def update_node_config(
|
||||
|
||||
@@ -12,6 +12,7 @@ from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Float, and_, case, cast, func
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.logger import logger
|
||||
@@ -879,15 +880,23 @@ class StatsAggregatorService:
|
||||
@staticmethod
|
||||
def aggregate_hourly_stats_bundle(db: Session, hour_utc: datetime) -> StatsHourly:
|
||||
"""聚合单小时所有统计(原子提交)"""
|
||||
stats = StatsAggregatorService.aggregate_hourly_stats(db, hour_utc, commit=False)
|
||||
StatsAggregatorService.aggregate_hourly_user_stats(db, hour_utc, commit=False)
|
||||
StatsAggregatorService.aggregate_hourly_model_stats(db, hour_utc, commit=False)
|
||||
StatsAggregatorService.aggregate_hourly_provider_stats(db, hour_utc, commit=False)
|
||||
|
||||
stats.is_complete = True
|
||||
stats.aggregated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return stats
|
||||
def _do_aggregate() -> StatsHourly:
|
||||
stats = StatsAggregatorService.aggregate_hourly_stats(db, hour_utc, commit=False)
|
||||
StatsAggregatorService.aggregate_hourly_user_stats(db, hour_utc, commit=False)
|
||||
StatsAggregatorService.aggregate_hourly_model_stats(db, hour_utc, commit=False)
|
||||
StatsAggregatorService.aggregate_hourly_provider_stats(db, hour_utc, commit=False)
|
||||
stats.is_complete = True
|
||||
stats.aggregated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return stats
|
||||
|
||||
try:
|
||||
return _do_aggregate()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
logger.warning("小时统计聚合冲突,重试更新: {}", hour_utc)
|
||||
return _do_aggregate()
|
||||
|
||||
@staticmethod
|
||||
def update_summary(db: Session) -> StatsSummary:
|
||||
|
||||
Reference in New Issue
Block a user