Files
tick-stock-panel/backend/app/plugins/tdxquant/probe.py
T
shy3130 506d66f1ef fix(desktop): TdxQuant Key 探测打包版失效 — 哨兵重执行 + tdxaidata 入包
- probe: worker 移入主模块, frozen 以「重执行自身 + TDX_PROBE_WORKER 哨兵」
  拉起子进程 (sys.executable -c 被 PyInstaller bootloader 无视, 子进程会
  重启整个桌面应用 → Key 永远验证失败); dev 保持 python -c, 共用 run_worker
- spec: tdxaidata 显式入包 — 后端全部懒导入 Analysis 不跟随; lib/ 跨平台
  原生库按 datas 整目录复制 (collect_all 会让 macholib 解析 Linux ELF 崩)
- desktop: 单实例 _pid_alive Windows 改 OpenProcess+GetExitCodeProcess
  (os.kill(pid,0) 在 Windows 是 TerminateProcess, 会误杀在跑实例);
  main() 哨兵分发探测 worker, 先于单实例/后端/窗口
- 测试: 新增 test_tdxquant_probe.py (8 例); 全量 2692 passed;
  打包产物端到端实测探测 API ok, tdxaidata 真实加载连服
2026-10-13 18:37:29 +08:00

178 lines
6.9 KiB
Python

"""候选 Key 探测 — 独立子进程「格式 + 连通性」探测。
原生 DLL 每进程加载一次且绑定一个 token: 主进程可能已用旧 Key 加载,
候选 Key 无法在进程内替换, 因此探测必须另起进程(临时 lib 目录 +
TDX_AI_DATA_LIB 指向), 结论以 JSON 回传。
可靠性设计(实测教训):
- DLL 原生握手固定 ~30s(同 IP 并发会话疑似服务端节流), 超时必须 >45s;
- 子进程可能在原生层直接死亡(段错误级, 无任何输出) — 每阶段把进度写入
父进程指定的进度文件, 父进程据此定位死点并附退出码, 不再返回"无输出";
- TDX 会话按 IP 复用: 同 IP 有活跃好 Key 会话时(常驻后端必然如此),
错误 Key 的日K也能返回 ⇒ "无效 Key 拒绝"不可靠。本探测只验证
格式 + 连通性, Key 真伪由首次实拉(test_dataset / 日常轮询)暴露。
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
logger = logging.getLogger(__name__)
_PROBE_ENV_KEY = "TDX_PROBE_CANDIDATE_KEY"
_PROBE_ENV_TMP = "TDX_PROBE_TMP_DIR"
# frozen 版哨兵: 桌面版没有可用的 python 解释器 (sys.executable 是应用本身,
# -c 会被 PyInstaller bootloader 无视而重启整个桌面应用), 因此以
# 「重执行自身 + 本哨兵」拉起 worker, 由 desktop.main 最先分发到 run_worker
_PROBE_ENV_WORKER = "TDX_PROBE_WORKER"
# dev 模式子进程入口: 直接调用本模块的 run_worker, 与 frozen 哨兵共用同一份实现
_SCRIPT = "from app.plugins.tdxquant.probe import run_worker; run_worker()"
def run_worker() -> int:
"""探测 worker — 必须在独立子进程内执行 (见 run 的拉起方式)。
从环境变量读候选 Key 与进度目录, 各阶段进度写入 progress.json,
结论以单行 JSON 打到 stdout 供父进程解析。复用 client.materialize_lib
保持与主进程同一套物化/写 token 逻辑。
"""
key = os.environ.pop(_PROBE_ENV_KEY, "")
tmp = Path(os.environ.pop(_PROBE_ENV_TMP, ""))
os.environ.pop(_PROBE_ENV_WORKER, None)
progress = tmp / "progress.json" if str(tmp) else None
def stage(name: str, **extra) -> None:
if progress is None:
return
try:
progress.write_text(json.dumps({"stage": name, **extra}), encoding="utf-8")
except Exception: # noqa: BLE001
pass
try:
stage("imports")
from app.plugins.tdxquant import client as tc
stage("materialize")
dll = tc.materialize_lib(key, tmp)
os.environ[tc.LIB_ENV] = str(dll)
stage("load_dll")
from tdxaidata.tdxaidata import _NativeTdxAiData
native = _NativeTdxAiData.get_instance()
# 连通性判定: 快照端点不依赖 token 鉴权, 拿到数据即证明
# 服务器可达 + DLL 正常。轮询容忍 DLL 冷启动的未就绪窗口。
stage("start")
reachable = False
for _ in range(3):
try:
if native.get_market_snapshot_batch("600519.SH", "Now"):
reachable = True
break
except Exception: # noqa: BLE001
pass
time.sleep(1.2)
if not reachable:
stage("done", ok=False)
print(json.dumps({"ok": False, "reason": "行情服务器连接失败(网络不可达)"}))
return 0
# 日K仅作辅助信号(会话按 IP 复用时错误 Key 也能通过, 不能作拒绝依据)
stage("auth")
aux = ""
try:
raw = native.get_his_dats(
"600519.SH", "", "2026-01-01 00:00:00", "1d", "none", 3
)
from tdxaidata import tqs
v = tqs._clean(raw)
aux = "his_ok" if isinstance(v, dict) and v.get("Date") else "his_empty"
except Exception: # noqa: BLE001
aux = "his_exc"
stage("done", ok=True)
print(json.dumps({"ok": True, "reason": "", "aux": aux}))
except Exception as exc: # noqa: BLE001
stage("exception", reason=str(exc)[:300])
print(json.dumps({"ok": False, "reason": str(exc)[:300]}))
return 0
def _read_stage(tmp_dir: str) -> str:
try:
data = json.loads((Path(tmp_dir) / "progress.json").read_text(encoding="utf-8"))
except Exception: # noqa: BLE001 — 进度文件缺失/损坏按未知处理
return "未知(无进度文件)"
stage = str(data.get("stage", "?"))
if stage == "exception":
return f"异常: {str(data.get('reason', ''))[:150]}"
return stage
def _tail(text: str, limit: int = 150) -> str:
lines = (text or "").strip().splitlines()
return lines[-1][:limit] if lines else "(空)"
def run(api_key: str, timeout_s: float = 75.0) -> tuple[bool, str]:
"""子进程探测候选 Key: 格式由调用方把关, 这里验证连通性(快照可达)。"""
if not api_key:
return False, "Key 为空"
backend_root = Path(__file__).resolve().parents[3]
tmp_dir = tempfile.mkdtemp(prefix="tdx_probe_")
env = {
**os.environ,
_PROBE_ENV_KEY: api_key,
_PROBE_ENV_TMP: tmp_dir,
"PYTHONPATH": str(backend_root),
}
if getattr(sys, "frozen", False):
# 打包版 sys.executable 是应用本身, `-c` 会被 bootloader 无视并重启
# 整个桌面应用 — 必须裸重执行自身, 由 desktop.main 的哨兵分发 worker
cmd = [sys.executable]
env[_PROBE_ENV_WORKER] = "1"
else:
cmd = [sys.executable, "-c", _SCRIPT]
try:
try:
proc = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout_s,
cwd=str(backend_root),
env=env,
)
except subprocess.TimeoutExpired:
stage = _read_stage(tmp_dir)
return False, f"探测超时({timeout_s:.0f}s, 停在阶段[{stage}]) — 原生握手约需 30s, 请检查网络后重试"
for line in reversed((proc.stdout or "").splitlines()):
line = line.strip()
if line.startswith("{"):
try:
payload = json.loads(line)
except json.JSONDecodeError:
break
if payload.get("ok"):
return True, "ok(连通正常; Key 有效性以首次拉取为准)"
return False, f"探测失败: {payload.get('reason', '未知原因')}"
# 无 JSON 结论: 原生层直接死亡(无输出)或入口即崩 — 用进度文件定位死点
stage = _read_stage(tmp_dir)
return False, (
f"探测失败: 子进程死在阶段[{stage}], exit={proc.returncode}, "
f"stderr={_tail(proc.stderr)}"
)
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)