feat(proxy): 节点状态简化、连接事件记录、可靠性指标与批量删除

- 移除 UNHEALTHY 中间状态,节点状态简化为 ONLINE/OFFLINE
- 新增 proxy_node_events 表记录 tunnel 连接/断开/错误事件
- 新增 failed_requests/dns_failures/stream_errors 可靠性指标(增量累加)
- tunnel 重连改为固定 1s 延迟,移除指数退避逻辑
- resolver/service 改为以 TunnelManager 内存状态判断节点可用性,避免 DB 竞态
- 修正 Claude cache_control 字段格式,使用 ttl 字段控制缓存时长
- 移除前端手动勾选 capability 的 UI,改为从价格配置自动推断
- 新增全局模型批量删除 API,替换前端并行单个删除
This commit is contained in:
fawney19
2026-02-28 13:52:32 +08:00
parent ecb16d345a
commit 54530faf03
25 changed files with 586 additions and 225 deletions

View File

@@ -224,15 +224,32 @@ _VALID_CACHE_TTL_TARGETS = {"ephemeral", "1h"}
def _override_cache_control_in_blocks(blocks: list[Any], target: str) -> int:
"""Override cache_control in a list of content blocks. Returns count of overrides."""
"""Override cache_control TTL in a list of content blocks. Returns count of overrides.
According to Anthropic API docs, cache_control format is:
{"type": "ephemeral", "ttl": "5m" | "1h"}
``type`` is always "ephemeral"; the ``ttl`` field controls the actual duration.
When ttl is absent, the default is 5m (ephemeral).
"""
count = 0
for block in blocks:
if not isinstance(block, dict):
continue
cc = block.get("cache_control")
if isinstance(cc, dict):
if cc.get("type") != target:
cc["type"] = target
if not isinstance(cc, dict):
continue
# Ensure type is always "ephemeral"
if cc.get("type") != "ephemeral":
cc["type"] = "ephemeral"
if target == "ephemeral":
# Target is 5m (default) -- remove explicit ttl so it falls back to default
if "ttl" in cc:
del cc["ttl"]
count += 1
else:
# Target is "1h" -- set ttl explicitly
if cc.get("ttl") != target:
cc["ttl"] = target
count += 1
return count

View File

@@ -2,9 +2,9 @@
ProxyNode 心跳检测调度器
定期检查 proxy_nodes 的 tunnel 连接状态,更新节点状态:
- tunnel 实际连接中 -> ONLINE(包括从 OFFLINE 恢复的情况)
- tunnel 刚断开 (<60s) -> UNHEALTHY缓冲期避免正在进行的请求被立即切走
- tunnel 断开超过 60s -> OFFLINE
- tunnel 实际连接中 -> ONLINE
- tunnel 未连接 -> OFFLINE
以 TunnelManager 内存中的实际连接状态为准。
"""
from __future__ import annotations
@@ -17,12 +17,18 @@ from src.database import create_session
from src.models.database import ProxyNode, ProxyNodeStatus
from src.services.system.scheduler import get_scheduler
# 事件保留天数
_EVENT_RETENTION_DAYS = 30
# 每隔多少次心跳检测执行一次事件清理15s * 240 = 1h
_EVENT_CLEANUP_INTERVAL = 240
class ProxyNodeHealthScheduler:
"""代理节点心跳检测调度器"""
def __init__(self) -> None:
self.running = False
self._check_count = 0
async def start(self) -> Any:
if self.running:
@@ -51,6 +57,9 @@ class ProxyNodeHealthScheduler:
async def _scheduled_check(self) -> None:
await self._check_heartbeats()
self._check_count = (self._check_count + 1) % _EVENT_CLEANUP_INTERVAL
if self._check_count == 0:
await self._cleanup_old_events()
async def _check_heartbeats(self) -> None:
from src.services.proxy_node.tunnel_manager import get_tunnel_manager
@@ -87,16 +96,9 @@ class ProxyNodeHealthScheduler:
node.tunnel_connected_at = now
changed += 1
if actually_connected:
new_status = ProxyNodeStatus.ONLINE
elif node.tunnel_connected_at:
# tunnel 刚断开:给 60s 缓冲期标记为 UNHEALTHY
elapsed = (now - node.tunnel_connected_at).total_seconds()
new_status = (
ProxyNodeStatus.UNHEALTHY if elapsed < 60 else ProxyNodeStatus.OFFLINE
)
else:
new_status = ProxyNodeStatus.OFFLINE
new_status = (
ProxyNodeStatus.ONLINE if actually_connected else ProxyNodeStatus.OFFLINE
)
if node.status != new_status:
node.status = new_status
@@ -115,6 +117,42 @@ class ProxyNodeHealthScheduler:
finally:
db.close()
async def _cleanup_old_events(self) -> None:
"""清理超过保留期的连接事件记录(在线程池中执行,避免阻塞事件循环)"""
import asyncio
def _sync_cleanup() -> None:
from datetime import timedelta
from src.models.database import ProxyNodeEvent
db = create_session()
try:
cutoff = datetime.now(timezone.utc) - timedelta(days=_EVENT_RETENTION_DAYS)
deleted = (
db.query(ProxyNodeEvent)
.filter(ProxyNodeEvent.created_at < cutoff)
.delete(synchronize_session=False)
)
if deleted:
db.commit()
logger.info(
"清理 {} 条过期代理节点事件 (>{} 天)", deleted, _EVENT_RETENTION_DAYS
)
except Exception as e:
try:
db.rollback()
except Exception:
pass
logger.warning("清理代理节点事件失败: {}", e)
finally:
db.close()
try:
await asyncio.to_thread(_sync_cleanup)
except Exception as e:
logger.warning("清理代理节点事件线程执行失败: {}", e)
_proxy_node_health_scheduler: ProxyNodeHealthScheduler | None = None

View File

@@ -60,17 +60,39 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
db = create_session()
try:
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node or node.status != ProxyNodeStatus.ONLINE:
if not node:
_proxy_node_cache[node_id] = (None, now + _PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS)
return None
# tunnel 模式节点必须 tunnel 已连接才可用
if node.tunnel_mode and not node.tunnel_connected:
# tunnel 模式节点:以 TunnelManager 内存中的实际连接状态为准,
# 而非依赖 DB 的 status/tunnel_connected 字段(可能因竞态不同步)。
if node.tunnel_mode and not node.is_manual:
from src.services.proxy_node.tunnel_manager import get_tunnel_manager
manager = get_tunnel_manager()
if not manager.has_tunnel(node_id):
_proxy_node_cache[node_id] = (
None,
now + _PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS,
)
return None
value: dict[str, Any] = {
"name": node.name,
"ip": node.ip,
"port": node.port,
"tunnel_mode": True,
"tunnel_connected": True,
}
_proxy_node_cache[node_id] = (value, now + _PROXY_NODE_CACHE_TTL_SECONDS)
return value
# 手动节点 / 非 tunnel 节点:仍依赖 DB status
if node.status != ProxyNodeStatus.ONLINE:
_proxy_node_cache[node_id] = (None, now + _PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS)
return None
if node.is_manual:
value: dict[str, Any] = {
value = {
"is_manual": True,
"name": node.name,
"proxy_url": node.proxy_url,

View File

@@ -58,6 +58,9 @@ def node_to_dict(node: ProxyNode) -> dict[str, Any]:
"active_connections": node.active_connections,
"total_requests": node.total_requests,
"avg_latency_ms": node.avg_latency_ms,
"failed_requests": node.failed_requests,
"dns_failures": node.dns_failures,
"stream_errors": node.stream_errors,
"hardware_info": node.hardware_info,
"estimated_max_concurrency": node.estimated_max_concurrency,
"remote_config": node.remote_config,
@@ -283,7 +286,7 @@ class ProxyNodeService:
port=port,
region=region,
# 新节点:等 tunnel 连接后才上线
status=ProxyNodeStatus.UNHEALTHY,
status=ProxyNodeStatus.OFFLINE,
registered_by=registered_by,
last_heartbeat_at=now,
heartbeat_interval=heartbeat_interval,
@@ -311,8 +314,16 @@ class ProxyNodeService:
active_connections: int | None = None,
total_requests: int | None = None,
avg_latency_ms: float | None = None,
failed_requests: int | None = None,
dns_failures: int | None = None,
stream_errors: int | None = None,
) -> ProxyNode:
"""处理节点心跳(仅 tunnel 模式节点,更新指标并修正状态不一致)"""
"""处理节点心跳(仅 tunnel 模式节点,更新指标并修正状态不一致)
注意: total_requests / failed_requests / dns_failures / stream_errors
来自 Rust 端的区间增量swap(0) 后上报),需要累加到 DB 而非覆盖。
active_connections 和 avg_latency_ms 是实时快照,直接覆盖。
"""
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
@@ -332,13 +343,23 @@ class ProxyNodeService:
node.last_heartbeat_at = now
if heartbeat_interval is not None:
node.heartbeat_interval = heartbeat_interval
# 实时快照指标 -- 直接覆盖
if active_connections is not None:
node.active_connections = active_connections
if total_requests is not None:
node.total_requests = total_requests
if avg_latency_ms is not None:
node.avg_latency_ms = avg_latency_ms
# 区间增量指标 -- 累加到累计值
if total_requests is not None and total_requests > 0:
node.total_requests = (node.total_requests or 0) + total_requests
if failed_requests is not None and failed_requests > 0:
node.failed_requests = (node.failed_requests or 0) + failed_requests
if dns_failures is not None and dns_failures > 0:
node.dns_failures = (node.dns_failures or 0) + dns_failures
if stream_errors is not None and stream_errors > 0:
node.stream_errors = (node.stream_errors or 0) + stream_errors
db.commit()
db.refresh(node)
return node
@@ -530,7 +551,12 @@ class ProxyNodeService:
# tunnel 节点:通过 WebSocket tunnel 测试
if not node.is_manual:
if not node.tunnel_connected:
# 以 TunnelManager 内存中的实际连接状态为准(与 health_scheduler 一致),
# 而非仅依赖 DB 的 tunnel_connected 字段,避免竞态导致误判。
from src.services.proxy_node.tunnel_manager import get_tunnel_manager
manager = get_tunnel_manager()
if not manager.has_tunnel(node.id):
return {
"success": False,
"latency_ms": None,

View File

@@ -402,6 +402,9 @@ class TunnelManager:
active_connections=data.get("active_connections"),
total_requests=data.get("total_requests"),
avg_latency_ms=data.get("avg_latency_ms"),
failed_requests=data.get("failed_requests"),
dns_failures=data.get("dns_failures"),
stream_errors=data.get("stream_errors"),
)
result: dict[str, Any] = {}
if node.remote_config: