From 3bc15e620c66ba662d7d2296aefcbd2b4f1dd9bd Mon Sep 17 00:00:00 2001 From: Justin Gu <97915@qq.com> Date: Tue, 7 Jul 2026 20:36:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(config):=20v1.19.4=20MAC=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E4=B8=8D=E5=86=8D=E6=B1=A1=E6=9F=93=E6=A0=87=E5=87=86?= =?UTF-8?q?best=5Fhost=EF=BC=88=E5=8F=96=E4=B8=8D=E5=88=B0=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=A0=B9=E5=9B=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:mac/client.py 的 MacClient.from_best_host 和 AsyncMacClient.from_best_host 调用 save_best_host(best),把选中的 MAC 协议服务器(如 121.36.248.138) 写进了全局 best_host 字段——但这个字段是标准 TDX 协议用的。之后标准 AsyncTdxClient 用 get_best_host 读到这个 MAC host,用标准协议请求 MAC 服务器,返回空 body(偏移 2 剩余 0)。web app lifespan 启动 MAC 客户端 时触发污染。 修复: - config.py 新增独立的 best_mac_host 字段 + get_best_mac_host / save_best_mac_host - mac/client.py 4 处改用新字段(不再碰 save_best_host / get_best_host) - get_best_host 加交叉污染校验:缓存 host 不在标准列表里自动重置 (自动修复已被污染的 config.json,用户无需手动操作) 测试:新增 5 个回归测试(污染检测 + MAC host 隔离),907 全过。 --- CHANGELOG.md | 10 +++++ pyproject.toml | 2 +- src/easy_tdx/config.py | 50 ++++++++++++++++++++++++- src/easy_tdx/mac/client.py | 16 +++++--- tests/unit/test_config.py | 75 +++++++++++++++++++++++++++++++++++++- 5 files changed, 144 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 484f23c..8c44cf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 本文件记录 easy-tdx 的版本变更。格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/)。 +## [1.19.4] — 2026-07-07 + +**修复取不到行情数据的根因:MAC 客户端污染标准协议的 best_host** —— v1.19.3 在实际机器上"所有股票都取不到数据"(K 线响应偏移 2 剩余 0)。根因是 `mac/client.py` 的 `MacClient.from_best_host()` 和 `AsyncMacClient.from_best_host()` 调用了 `save_best_host(best)`——把选中的 **MAC 协议服务器**(如 `121.36.248.138`)写进了全局 `best_host` 字段,但这个字段是**标准 TDX 协议**用的。之后标准 `AsyncTdxClient` 用 `get_best_host()` 读到这个 MAC host,用标准协议请求 MAC 服务器,返回空 body。web app 启动时 lifespan 会启动 MAC 客户端,这就是污染时机。 + +### 修复 + +- **MAC host 不再污染标准 best_host**(`src/easy_tdx/config.py` + `src/easy_tdx/mac/client.py`)—— 新增独立的 `best_mac_host` 字段 + `get_best_mac_host()` / `save_best_mac_host()`。`MacClient` / `AsyncMacClient` 的 `from_best_host()` 和 `__init__` 改用新字段(4 处),不再调 `save_best_host` / `get_best_host`。 +- **best_host 交叉污染校验**(`src/easy_tdx/config.py:get_best_host`)—— 读取时检测缓存 host 是否在标准 host 候选列表(known_hosts + 源码默认)里,不在则自动重置为默认首个并持久化。**这会自动修复已被污染的 config.json**,用户无需手动删配置。 +- **回归测试**(`tests/unit/test_config.py`)—— 新增 `TestBestHostPollutionGuard`(3 个测试:MAC host 被重置 / 合法 host 不重置 / 重置持久化)+ `TestMacHostSeparation`(2 个测试:save_best_mac_host 不碰 best_host / get_best_mac_host 返回独立字段)。更新既有 `test_config_json_host`(host 需在 known_hosts 里才通过校验)。 + ## [1.19.3] — 2026-07-07 **修复 EXE 运行时两个问题:K 线空 body 仍 500 + 前端路由刷新 404** —— v1.19.2 在实际机器上运行日志暴露两个问题:(1) SH600519 等正常股票偶发请求 K 线时,通达信服务器返回 `ret_count>0` 但 body 完全为空(pos=2 剩余 0 字节),v1.18.3 的容错有 `if bars:` 条件——bars 为空时走 `raise` → 500,老人看到"取行情失败"。(2) 用户在 `/optimize`、`/portfolio` 等前端路由页面刷新时,后端 StaticFiles 找不到文件返回 404(SPA fallback 缺失)。 diff --git a/pyproject.toml b/pyproject.toml index c7ae85a..ecbb47f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "easy-tdx" -version = "1.19.3" +version = "1.19.4" description = "通达信 TCP 协议行情数据客户端,支持在线行情、离线数据读取与写入同步" readme = "README.md" requires-python = ">=3.10" diff --git a/src/easy_tdx/config.py b/src/easy_tdx/config.py index b39481f..0521e44 100644 --- a/src/easy_tdx/config.py +++ b/src/easy_tdx/config.py @@ -160,12 +160,29 @@ def _save(data: dict[str, Any]) -> None: def get_best_host() -> str: - """返回当前最佳主机地址。优先级:环境变量 > config.json > 默认列表首个。""" + """返回当前最佳主机地址。优先级:环境变量 > config.json > 默认列表首个。 + + 含交叉污染校验:历史上 ``MacClient.from_best_host`` 曾误调 ``save_best_host`` + 把 MAC 服务器写入 ``best_host``(v1.19.3 "取不到行情" bug)。这里检测到 + 缓存的 host 不在标准 host 候选列表里时,自动重置为默认首个,避免用错协议。 + """ env = os.environ.get("EASY_TDX_HOST") if env: return env cfg = _load() - return cast(str, cfg.get("best_host", _FALLBACK_HOSTS[0])) + cached = cast(str, cfg.get("best_host", _FALLBACK_HOSTS[0])) + + # 交叉污染校验:cached 必须在标准 host 候选列表(known_hosts + 源码默认)里。 + # 如果是 MAC host(121.36.x.x 等),不在标准列表里 → 重置。 + all_std_hosts = set(cfg.get("known_hosts", [])) | set(_FALLBACK_HOSTS) + if cached not in all_std_hosts: + # 被污染了(MAC/ex host 混入),重置为默认首个并持久化 + cfg["best_host"] = _FALLBACK_HOSTS[0] + cfg["best_host_updated_at"] = datetime.now(_SHANGHAI_TZ).isoformat() + _save(cfg) + return _FALLBACK_HOSTS[0] + + return cached def get_known_hosts() -> list[str]: @@ -189,6 +206,20 @@ def get_mac_hosts() -> list[str]: return cast(list[str], cfg.get("mac_hosts", list(_FALLBACK_MAC_HOSTS))) +def get_best_mac_host() -> str: + """返回当前最佳 MAC 协议主机。 + + 与 ``get_best_host``(标准 TDX 协议)分开存:MAC 和标准协议用不同的 + 服务器列表和协议格式,共用同一个字段会导致 MAC 客户端选出的 host + 被标准客户端误用,用标准协议请求 MAC 服务器返回空 body。 + """ + env = os.environ.get("EASY_TDX_MAC_HOST") + if env: + return env + cfg = _load() + return cast(str, cfg.get("best_mac_host", _FALLBACK_MAC_HOSTS[0])) + + def get_ex_hosts() -> list[str]: """返回扩展行情服务器列表。""" cfg = _load() @@ -282,3 +313,18 @@ def save_best_mac_ex_host(host: str) -> None: if "mac_ex_hosts" not in cfg: cfg["mac_ex_hosts"] = list(_FALLBACK_MAC_EX_HOSTS) _save(cfg) + + +def save_best_mac_host(host: str) -> None: + """保存最佳 MAC 协议主机到配置文件(独立于标准 TDX 的 best_host)。 + + MAC 客户端必须用这个,不能复用 ``save_best_host``——否则选出的 MAC + 服务器会污染标准 TDX 协议的 ``best_host``,导致标准客户端用错协议请求 + MAC 服务器,返回空 body(v1.19.3 "取不到行情" bug 的根因)。 + """ + cfg = _load() + cfg["best_mac_host"] = host + cfg["best_mac_host_updated_at"] = datetime.now(_SHANGHAI_TZ).isoformat() + if "mac_hosts" not in cfg: + cfg["mac_hosts"] = list(_FALLBACK_MAC_HOSTS) + _save(cfg) diff --git a/src/easy_tdx/mac/client.py b/src/easy_tdx/mac/client.py index ade8971..c2e7b4a 100644 --- a/src/easy_tdx/mac/client.py +++ b/src/easy_tdx/mac/client.py @@ -16,7 +16,13 @@ from .._df import _apply_bar_time_align_df, _period_to_minutes, _to_df from .._reconnect import _RETRY_DELAYS, AsyncHeartbeatMixin from ..codec.bitmap import Fields, PresetField from ..commands.base import BaseCommand -from ..config import get_best_host, get_mac_hosts, get_port, get_timeout, save_best_host +from ..config import ( + get_best_mac_host, + get_mac_hosts, + get_port, + get_timeout, + save_best_mac_host, +) from ..exceptions import TdxConnectionError from ..transport.async_ import AsyncTdxConnection from ..transport.sync import TdxConnection, ping_mac_all @@ -147,7 +153,7 @@ class MacClient: auto_reconnect: bool = True, heartbeat_interval: float = 15.0, ) -> None: - self._host = host if host is not None else get_best_host() + self._host = host if host is not None else get_best_mac_host() self._port = port if port is not None else get_port() self._timeout = timeout if timeout is not None else get_timeout() self._auto_reconnect = auto_reconnect @@ -180,7 +186,7 @@ class MacClient: timeout = get_timeout() ranked = ping_mac_all(hosts, port, ping_timeout) best = ranked[0][0] if ranked else hosts[0] - save_best_host(best) + save_best_mac_host(best) return cls(best, port, timeout, auto_reconnect, heartbeat_interval) @staticmethod @@ -1156,7 +1162,7 @@ class AsyncMacClient(AsyncHeartbeatMixin): auto_reconnect: bool = True, heartbeat_interval: float = 15.0, ) -> None: - self._host = host if host is not None else get_best_host() + self._host = host if host is not None else get_best_mac_host() self._port = port if port is not None else get_port() self._timeout = timeout if timeout is not None else get_timeout() self._auto_reconnect = auto_reconnect @@ -1191,7 +1197,7 @@ class AsyncMacClient(AsyncHeartbeatMixin): timeout = get_timeout() ranked = ping_mac_all(hosts, port, ping_timeout) best = ranked[0][0] if ranked else hosts[0] - save_best_host(best) + save_best_mac_host(best) return cls(best, port, timeout, auto_reconnect, heartbeat_interval) @staticmethod diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 8b45c56..335b7f0 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -72,8 +72,17 @@ class TestConfigReadWrite: assert cfg.get_known_hosts() == list(cfg._FALLBACK_HOSTS) def test_config_json_host(self, isolated_config: Path) -> None: + # best_host 必须在 known_hosts 里才能通过污染校验(v1.19.4 新增)。 + # 用 known_hosts 里的一个 host,确保不被重置。 (isolated_config / "config.json").write_text( - json.dumps({"best_host": "203.0.0.1", "port": 7709, "timeout": 12.0}), + json.dumps( + { + "best_host": "203.0.0.1", + "known_hosts": ["203.0.0.1"], + "port": 7709, + "timeout": 12.0, + } + ), "utf-8", ) assert cfg.get_best_host() == "203.0.0.1" @@ -126,3 +135,67 @@ class TestSaveBestHost: cfg.save_best_host("x.host") assert not (isolated_config / "config.json.tmp").exists() assert (isolated_config / "config.json").exists() + + +# --------------------------------------------------------------------------- # +# best_host 交叉污染校验(v1.19.4 回归守卫) +# --------------------------------------------------------------------------- # + + +class TestBestHostPollutionGuard: + """MacClient.from_best_host 曾误调 save_best_host 把 MAC 服务器写入 + best_host,导致标准 TdxClient 用错协议请求 MAC 服务器返回空 body。 + get_best_host 现在含校验:缓存 host 不在标准列表里时自动重置。 + """ + + def test_mac_host_in_best_host_gets_reset( + self, isolated_config: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """best_host 被污染成 MAC host(不在标准列表)→ 自动重置。""" + polluted = { + "best_host": "121.36.248.138", # MAC host + "known_hosts": ["180.153.18.170", "115.238.56.198"], + } + (isolated_config / "config.json").write_text(json.dumps(polluted), "utf-8") + monkeypatch.delenv("EASY_TDX_HOST", raising=False) + + result = cfg.get_best_host() + assert result != "121.36.248.138", "MAC host 应被重置" + assert result in polluted["known_hosts"] or result in cfg._FALLBACK_HOSTS + + def test_valid_host_not_reset( + self, isolated_config: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """best_host 是合法标准 host → 不重置。""" + (isolated_config / "config.json").write_text( + json.dumps({"best_host": "180.153.18.170", "known_hosts": ["180.153.18.170"]}), + "utf-8", + ) + monkeypatch.delenv("EASY_TDX_HOST", raising=False) + assert cfg.get_best_host() == "180.153.18.170" + + def test_reset_persists_to_config(self, isolated_config: Path) -> None: + """重置后的 host 应写回 config.json,下次读不需再校验。""" + (isolated_config / "config.json").write_text( + json.dumps({"best_host": "121.36.248.138"}), "utf-8" + ) + cfg.get_best_host() # 触发重置 + data = json.loads((isolated_config / "config.json").read_text("utf-8")) + assert data["best_host"] != "121.36.248.138" + + +class TestMacHostSeparation: + """MAC 协议的 best host 应独立于标准 TDX 的 best_host。""" + + def test_save_best_mac_host_does_not_touch_best_host(self, isolated_config: Path) -> None: + """save_best_mac_host 不应修改 best_host 字段。""" + cfg.save_best_host("180.153.18.170") + cfg.save_best_mac_host("121.36.248.138") + data = json.loads((isolated_config / "config.json").read_text("utf-8")) + assert data["best_host"] == "180.153.18.170" + assert data["best_mac_host"] == "121.36.248.138" + + def test_get_best_mac_host_returns_mac_host(self, isolated_config: Path) -> None: + """get_best_mac_host 返回的是 MAC host 字段,不是标准 host。""" + cfg.save_best_mac_host("123.60.47.136") + assert cfg.get_best_mac_host() == "123.60.47.136"