mirror of
https://ghfast.top/https://github.com/aeroxw/easy_tdx_max.git
synced 2026-09-12 23:54:21 +08:00
feat(web-ui): v1.19.7 服务器设置页面(测速+切换通达信服务器)
新增 web UI 第六个页面「服务器设置」,让用户在浏览器上: 1. 列出全部 50+ 候选服务器 2. 一键并发测速(显示延迟和可达性) 3. 点选切换到最快或可用的服务器(热重连,无需重启) 解决不同地区/运营商对通达信各服务器连通性不同的问题。 新增: - AsyncTdxClient.reconnect_to(host) 热切换方法(持锁,关旧建新) - GET/POST /api/v1/server/hosts|test|switch 三个端点 - ServerSettingsView.vue 页面(列表+测速+切换) - 导航栏第六个入口 设计: 先reconnect成功再save_best_host(避免污染config), host校验只允许候选列表内IP,不自动测速避免首屏卡顿。
This commit is contained in:
@@ -880,6 +880,22 @@ class AsyncTdxClient(AsyncHeartbeatMixin):
|
||||
await self._stop_heartbeat()
|
||||
await self._conn.close()
|
||||
|
||||
async def reconnect_to(self, host: str) -> None:
|
||||
"""热切换到新 host:关旧连接 → 换 host → 建新连接。
|
||||
|
||||
用于 web UI 的"服务器设置"页面——用户点选一个 host 后,无需重启
|
||||
服务即可切换。复用 ``_execute_lock`` 保证切换期间没有并发行情请求
|
||||
撞到半开的连接。切换失败抛异常(旧连接已 close,client 处于断开
|
||||
状态,调用方应捕获并提示用户选别的 host)。
|
||||
"""
|
||||
async with self._execute_lock:
|
||||
await self._stop_heartbeat()
|
||||
await self._conn.close()
|
||||
self._host = host
|
||||
self._conn = AsyncTdxConnection(host, self._port, self._timeout)
|
||||
await self._conn.connect()
|
||||
self._start_heartbeat()
|
||||
|
||||
async def __aenter__(self) -> "AsyncTdxClient":
|
||||
await self.connect()
|
||||
return self
|
||||
|
||||
@@ -211,6 +211,7 @@ def _create_app(
|
||||
from easy_tdx.web.routers.mac_quotes import router as mac_quotes_router
|
||||
from easy_tdx.web.routers.market import router as market_router
|
||||
from easy_tdx.web.routers.realtime import router as realtime_router
|
||||
from easy_tdx.web.routers.server import router as server_router
|
||||
from easy_tdx.web.routers.sina import router as sina_router
|
||||
from easy_tdx.web.routers.strategies import router as strategies_router
|
||||
|
||||
@@ -236,6 +237,8 @@ def _create_app(
|
||||
app.include_router(backtest_router, prefix="/api/v1")
|
||||
# 策略库路由(SQLite 持久化,纯数据 CRUD)
|
||||
app.include_router(strategies_router, prefix="/api/v1")
|
||||
# 服务器设置路由(列出/测速/切换 TDX host)
|
||||
app.include_router(server_router, prefix="/api/v1")
|
||||
|
||||
# --- 前端 dist 托管(生产/打包态同源服务,开发态可缺省) ---
|
||||
# 必须在所有 API 路由注册之后:StaticFiles(html=True) 挂在 "/" 会吞掉
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""服务器设置路由:列出/测速/切换标准 TDX 行情服务器。
|
||||
|
||||
让用户在 web UI 上看到候选 host 列表、一键测速、点选切换——解决"有些 IP
|
||||
能连通有些不能"的问题(不同地区/运营商对通达信各服务器连通性不同)。
|
||||
切换是热重连(``reconnect_to``),无需重启服务。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from easy_tdx.config import get_best_host, get_known_hosts, get_port, save_best_host
|
||||
from easy_tdx.transport.sync import ping_all
|
||||
|
||||
router = APIRouter(tags=["server"])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Schemas
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class HostInfo(BaseModel):
|
||||
"""单个 host 的状态信息。"""
|
||||
|
||||
host: str
|
||||
latency_ms: int | None = None # None = 未测速或不可达
|
||||
reachable: bool = False
|
||||
is_current: bool = False
|
||||
|
||||
|
||||
class HostListResponse(BaseModel):
|
||||
"""GET /server/hosts 的响应。"""
|
||||
|
||||
hosts: list[HostInfo]
|
||||
current_host: str
|
||||
total: int
|
||||
|
||||
|
||||
class ServerTestRequest(BaseModel):
|
||||
"""POST /server/test 的请求。"""
|
||||
|
||||
hosts: list[str] | None = None # None = 测全部候选
|
||||
timeout: float = 5.0
|
||||
|
||||
|
||||
class ServerSwitchRequest(BaseModel):
|
||||
"""POST /server/switch 的请求。"""
|
||||
|
||||
host: str
|
||||
|
||||
|
||||
class SwitchResponse(BaseModel):
|
||||
"""POST /server/switch 的响应。"""
|
||||
|
||||
ok: bool
|
||||
host: str
|
||||
message: str
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Routes
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@router.get("/server/hosts", response_model=HostListResponse)
|
||||
async def list_hosts(request: Request) -> HostListResponse:
|
||||
"""列出所有候选 host + 当前正在使用的 host。
|
||||
|
||||
不做测速(避免 50+ host 全 ping 让首屏卡几秒)。前端点"测试全部"按钮
|
||||
后调 ``POST /server/test`` 获取延迟。
|
||||
"""
|
||||
candidates = get_known_hosts()
|
||||
current = _get_current_host(request)
|
||||
|
||||
host_infos = [HostInfo(host=h, is_current=(h == current)) for h in candidates]
|
||||
return HostListResponse(hosts=host_infos, current_host=current, total=len(host_infos))
|
||||
|
||||
|
||||
@router.post("/server/test", response_model=list[HostInfo])
|
||||
async def test_hosts(req: ServerTestRequest, request: Request) -> list[HostInfo]:
|
||||
"""并发 ping 测试 host 列表,返回延迟和可达性。
|
||||
|
||||
用 ``asyncio.to_thread`` 包装同步的 ``ping_all``(它内部用
|
||||
ThreadPoolExecutor 并发),避免阻塞事件循环。
|
||||
"""
|
||||
hosts = req.hosts if req.hosts else get_known_hosts()
|
||||
port = get_port()
|
||||
current = _get_current_host(request)
|
||||
|
||||
# ping_all 是同步阻塞函数,放到线程池跑
|
||||
ranked = await asyncio.to_thread(ping_all, hosts, port, req.timeout)
|
||||
|
||||
# ranked 是 [(host, latency_sec)],已按延迟升序排列,只含可达的
|
||||
reachable_map = {h: round(s * 1000) for h, s in ranked}
|
||||
|
||||
# 按原始 hosts 顺序返回(保持列表稳定),但把可达的排前面
|
||||
result = []
|
||||
for h in hosts:
|
||||
latency_ms = reachable_map.get(h)
|
||||
result.append(
|
||||
HostInfo(
|
||||
host=h,
|
||||
latency_ms=latency_ms,
|
||||
reachable=latency_ms is not None,
|
||||
is_current=(h == current),
|
||||
)
|
||||
)
|
||||
|
||||
# 可达的排前面(按延迟升序),不可达的排后面
|
||||
result.sort(key=lambda x: (x.reachable is False, x.latency_ms or 999999))
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/server/switch", response_model=SwitchResponse)
|
||||
async def switch_host(req: ServerSwitchRequest, request: Request) -> SwitchResponse:
|
||||
"""切换到指定 host(热重连,无需重启服务)。
|
||||
|
||||
顺序:先 reconnect_to 成功 → 再 save_best_host 持久化。
|
||||
如果 reconnect 失败,不 save(避免污染 config,用户可再选别的)。
|
||||
"""
|
||||
candidates = get_known_hosts()
|
||||
if req.host not in candidates:
|
||||
return SwitchResponse(
|
||||
ok=False,
|
||||
host=req.host,
|
||||
message=f"主机 {req.host} 不在候选列表里,无法切换",
|
||||
)
|
||||
|
||||
client = request.app.state.tdx_client
|
||||
if client is None:
|
||||
return SwitchResponse(ok=False, host=req.host, message="TDX 客户端未初始化")
|
||||
|
||||
try:
|
||||
await client.reconnect_to(req.host)
|
||||
except Exception as e:
|
||||
return SwitchResponse(
|
||||
ok=False,
|
||||
host=req.host,
|
||||
message=f"连接 {req.host} 失败:{e}。请选其他服务器。",
|
||||
)
|
||||
|
||||
# 连接成功后才持久化
|
||||
save_best_host(req.host)
|
||||
return SwitchResponse(ok=True, host=req.host, message=f"已切换到 {req.host}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Helpers
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _get_current_host(request: Request) -> str:
|
||||
"""获取当前 TDX 客户端实际连接的 host。"""
|
||||
client = getattr(request.app.state, "tdx_client", None)
|
||||
if client is not None:
|
||||
# AsyncTdxClient._host 是实际连接的 host(reconnect_to 会更新它)
|
||||
return getattr(client, "_host", get_best_host())
|
||||
return get_best_host()
|
||||
Reference in New Issue
Block a user