From f28f9cf30874a099f730f7cd15fa651abdf232c3 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Thu, 20 Aug 2026 20:04:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(data):=20bs=5Feod=E6=8C=82=E6=AD=BB?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=E2=80=94=E2=80=94login/logout=E7=BA=B3?= =?UTF-8?q?=E5=85=A5=5Fwith=5Ftimeout=E4=BF=9D=E6=8A=A4=E4=BC=9E+=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E7=B3=BB=E7=BB=9F=E8=BF=90=E8=A1=8C=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E4=B8=89=E8=AF=8D=E4=BF=AE=E6=AD=A3=E3=80=82=E2=91=A0=E6=A0=B9?= =?UTF-8?q?=E5=9B=A0(08-20=2019:26=E5=AE=9E=E9=94=A4,=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E5=BD=A2=E6=80=81=E9=80=90=E5=AD=97=E5=90=BB=E5=90=88):bs.logi?= =?UTF-8?q?n()=E6=89=93=E5=8D=B0success=E5=90=8E=E4=BB=8D=E6=9C=89?= =?UTF-8?q?=E5=90=8E=E7=BB=AD=E5=BE=80=E8=BF=94,baostock=20recv=E4=B8=8D?= =?UTF-8?q?=E9=81=B5=E5=AE=88socket=20default=20timeout,=E4=B8=BB=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E5=8D=A1=E6=AD=BB=E5=9C=A8login=E5=86=85=E9=83=A8recv?= =?UTF-8?q?(50min+=E9=9B=B6=E8=BE=93=E5=87=BA,=E8=BF=9E60s=E8=B6=85?= =?UTF-8?q?=E6=97=B6err=E9=83=BD=E6=B2=A1=E6=9C=89);fetch=E6=97=A9?= =?UTF-8?q?=E5=B7=B2=E5=A5=97=5Fwith=5Ftimeout=E4=BD=86login/logout?= =?UTF-8?q?=E6=98=AF=E8=A3=B8=E8=B0=83=E2=80=94=E2=80=94relogin/login=5Fon?= =?UTF-8?q?ce=E5=85=A8=E9=83=A8=E7=BA=B3=E5=85=A5(login60s/logout30s),?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E6=8C=89=E7=99=BB=E5=BD=95=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=A4=84=E7=90=86=E8=B5=B0=E6=97=A2=E6=9C=89=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E9=93=BE;=E2=91=A1bs=5Ffundamentals.=5Fis=5Frunning=5Ftext:?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E7=B3=BB=E7=BB=9F=E5=AE=9E=E9=99=85=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E3=80=8C=E6=AD=A3=E5=9C=A8=E8=BF=90=E8=A1=8C=E3=80=8D?= =?UTF-8?q?=E4=B8=8D=E5=90=AB=E8=BF=9E=E7=BB=AD=E5=AD=90=E4=B8=B2=E3=80=8C?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E4=B8=AD=E3=80=8D,=E5=8E=9F=E4=B8=A4?= =?UTF-8?q?=E8=AF=8D=E5=88=A4=E5=AE=9A=E5=9C=A8=E4=B8=AD=E6=96=87VPS?= =?UTF-8?q?=E6=81=92False=3D=E5=AE=88=E5=8D=AB=E6=81=92fail-open(=E5=BD=93?= =?UTF-8?q?=E6=99=9A=E8=8B=A5=E4=B8=8D=E7=A6=81=E7=94=A8bs-fund=E5=B0=86?= =?UTF-8?q?=E7=9C=9F=E6=92=9E=E8=BD=A6);=E4=B8=89=E8=AF=8D=E9=83=BD?= =?UTF-8?q?=E8=AE=A4+=E5=9B=9E=E5=BD=92=E9=92=89=E6=B5=8B=E8=AF=95;?= =?UTF-8?q?=E2=91=A2check=5Ftask=5Frunning.py=E9=80=9A=E7=94=A8=E6=8E=A2?= =?UTF-8?q?=E9=92=88(=E4=BF=AE=E5=A4=8D=E7=89=88)=E6=9B=BF=E6=8D=A2VPS?= =?UTF-8?q?=E4=B8=8A=E5=B8=A6=E5=90=8Cbug=E7=9A=84check=5Fbs=5Feod.py;+4?= =?UTF-8?q?=E6=B5=8B=E8=AF=95(=E7=99=BB=E5=BD=95=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E8=BF=94False/relogin=E5=85=A8=E7=A8=8B=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E4=B8=8Draise/login=E4=B8=8Elogout=E5=BF=85=E7=BB=8F=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E4=BC=9E=E5=A5=91=E7=BA=A6/=E6=AD=A3=E5=9C=A8?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E5=88=A4=E5=AE=9A)=20[vps]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/data_platform/bs_eod.py | 9 +++-- scripts/data_platform/bs_fundamentals.py | 8 +++- scripts/data_platform/check_task_running.py | 13 +++++++ tests/data_platform/test_bs_eod_resilience.py | 38 +++++++++++++++++++ tests/data_platform/test_bs_fundamentals.py | 3 ++ 5 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 scripts/data_platform/check_task_running.py diff --git a/scripts/data_platform/bs_eod.py b/scripts/data_platform/bs_eod.py index 26098fd..2af06bb 100644 --- a/scripts/data_platform/bs_eod.py +++ b/scripts/data_platform/bs_eod.py @@ -81,7 +81,10 @@ log = logging.getLogger(__name__) def login_once(): try: - lg = bs.login() + # 2026-08-20 实锤: bs.login() 打印 "login success!" 后仍有后续往返, 服务端 + # hiccup 时主线程可卡死在 login 内部 recv(08-20 19:26 挂死 50min+ 形态: + # logout✓ login success!打印后零输出) —— 与 fetch 同罩 _with_timeout。 + lg = _with_timeout(bs.login, timeout=60) if lg.error_code == "0": return True log.error("login fail: %s %s", lg.error_code, lg.error_msg) @@ -93,14 +96,14 @@ def login_once(): def relogin(): try: - bs.logout() + _with_timeout(bs.logout, timeout=30) except Exception: pass if login_once(): return True time.sleep(2) try: - bs.logout() + _with_timeout(bs.logout, timeout=30) except Exception: pass return login_once() diff --git a/scripts/data_platform/bs_fundamentals.py b/scripts/data_platform/bs_fundamentals.py index c1d8654..7633024 100644 --- a/scripts/data_platform/bs_fundamentals.py +++ b/scripts/data_platform/bs_fundamentals.py @@ -269,8 +269,12 @@ def run_reports(stocks, today, force=False): # ======================== bs_eod 在跑守卫 ======================== def _is_running_text(text): - """schtasks /query 输出判断 Running(schtasks 可能英/中输出, 两词都认).""" - return ("Running" in text) or ("运行中" in text) + """schtasks /query 输出判断 Running(schtasks 可能英/中输出, 各词都认). + + 2026-08-20 实锤: 中文系统输出是「正在运行」, 不含连续子串「运行中」—— + 原两词判定在中文 VPS 恒 False(守卫恒 fail-open), 三词都认才对。 + """ + return any(w in text for w in ("Running", "运行中", "正在运行")) def _bs_eod_running(): diff --git a/scripts/data_platform/check_task_running.py b/scripts/data_platform/check_task_running.py new file mode 100644 index 0000000..5eb96fb --- /dev/null +++ b/scripts/data_platform/check_task_running.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +"""check_task_running.py — schtask 是否在跑(exit 0=在跑, 1=已退出/查无)。 +运维探针(watcher/cron 轮询用)。中文输出「正在运行」不含子串「运行中」——三词都认 +(2026-08-20 假阳性实锤: 两词判定曾致 bs_eod 挂死被误判已退出)。""" +import subprocess +import sys + +task = sys.argv[1] if len(sys.argv) > 1 else 'sanguo-bs-daily' +out = subprocess.run(['schtasks', '/query', '/tn', task, '/fo', 'LIST'], + capture_output=True, timeout=30).stdout.decode('gbk', 'ignore') +running = any(w in out for w in ('Running', '运行中', '正在运行')) +print('%s: %s' % (task, 'RUNNING' if running else 'DONE/NOT-FOUND')) +sys.exit(0 if running else 1) diff --git a/tests/data_platform/test_bs_eod_resilience.py b/tests/data_platform/test_bs_eod_resilience.py index e2514c9..f0efe5d 100644 --- a/tests/data_platform/test_bs_eod_resilience.py +++ b/tests/data_platform/test_bs_eod_resilience.py @@ -511,3 +511,41 @@ def test_main_exits_2_when_all_login_retries_fail(isolated_main_env, monkeypatch with pytest.raises(SystemExit) as ei: bs_eod.main() assert ei.value.code == 2 + + +# ---------- login/logout 超时保护伞 (2026-08-20 19:26 挂死实锤) ---------- +# 形态: 日志停在 "logout success!/login success!" 后零输出 50min+ —— bs.login() +# 打印 success 后仍有后续往返, recv 不遵守 socket default timeout, 主线程卡死 +# 在 login 内部. fetch 早已套 _with_timeout, login/logout 当时是裸调. + +def test_login_once_timeout_returns_false(monkeypatch): + """bs.login 卡死(_with_timeout 抛 TimeoutError) → login_once 返 False 不挂死.""" + monkeypatch.setattr( + bs_eod, "_with_timeout", + MagicMock(side_effect=TimeoutError("login 超过 60s"))) + assert bs_eod.login_once() is False + + +def test_relogin_survives_total_timeout(monkeypatch): + """logout+login 全部超时 → relogin 返 False 全程不 raise(上层跳过该股继续).""" + monkeypatch.setattr( + bs_eod, "_with_timeout", + MagicMock(side_effect=TimeoutError("socket 死"))) + monkeypatch.setattr(bs_eod.time, "sleep", lambda s: None) + assert bs_eod.relogin() is False + + +def test_login_logout_gone_through_timeout_umbrella(monkeypatch): + """契约: bs.login/bs.logout 必须经 _with_timeout 调用(裸调=挂死回归口).""" + ok_login = MagicMock() + ok_login.error_code = "0" + mock_bs = MagicMock() + mock_bs.login.return_value = ok_login + monkeypatch.setattr(bs_eod, "bs", mock_bs) + monkeypatch.setattr( + bs_eod, "_with_timeout", + MagicMock(side_effect=lambda fn, **kw: fn())) + assert bs_eod.relogin() is True + called = [c.args[0] for c in bs_eod._with_timeout.call_args_list] + assert mock_bs.logout in called + assert mock_bs.login in called diff --git a/tests/data_platform/test_bs_fundamentals.py b/tests/data_platform/test_bs_fundamentals.py index 6844310..2b5562a 100644 --- a/tests/data_platform/test_bs_fundamentals.py +++ b/tests/data_platform/test_bs_fundamentals.py @@ -221,6 +221,9 @@ def test_reports_skip_non_sunday_and_second_sunday(tmp_out, small_stocks, def test_is_running_text_variants(): assert bf._is_running_text("Status: Running") is True assert bf._is_running_text("状态: 运行中") is True + # 2026-08-20 实锤回归钉: 中文系统实际输出「正在运行」, 不含连续子串「运行中」 + # —— 两词判定恒 False 曾致守卫 fail-open(且当晚 check 探针误报任务已退出) + assert bf._is_running_text("状态: 正在运行") is True assert bf._is_running_text("Status: Ready") is False assert bf._is_running_text("模式: 就绪") is False