From 95215e84e045602db2370586f6089a7f9f91b33b Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Fri, 18 Sep 2026 08:26:18 +0800 Subject: fix(data): ETF 身份契约与挂载权限修复(159399 QA 回归) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [问题原因] 前端手动录入表单保存的 market 字段是 SH/SZ/BJ 原文,但 worker 的 split_identity 只接受 market:"cn" 并按代码前缀推交易所,导致 market:"SZ" 的 159399 在发起任何网络请求前就报 unsupported_market。 同时 server 为非 root 容器(uid 65534)准备挂载时,完整文件挂载从不 被 chmod,且所有 chmod 失败都被静默吞掉,worker 容器读数据集可能 Permission denied 且不易定位。 [问题根因] 身份契约在 server 持久化层与 worker 适配层不一致;挂载权限处理是 best-effort 静默吞错,缺少最小作用域约束。 [修复方案] worker/data.py: market 原样映射 IDENTITY_EXCHANGES(SH/SZ/BJ), "cn" 保持代码前缀推断(SH=3/6/9 开头,否则 SZ),未知市场仍显式拒绝 绝不猜测;sina 数据源的警告文案改为只陈述可证实事实(无日期参数、 不除权、成交量单位为股且不换算),删除与其它 provider 的 100x 换算 断言。server/src/worker.rs: prepare_mounts 严格化——独立文件挂载仅在 "只读 + /data/ 前缀"范围内 chmod 0644,拒绝符号链接,目录挂载保持 0755/0777,所有 chmod 失败作为错误返回而非吞掉。tests/worker/ test_data.py 新增身份契约用例:SZ/SH 原文、cn 推断不变、SZ 直达 sina provider 的端到端 fetch。 [影响范围] worker 数据获取链路与 server 任务编排;server/target/release 二进制 已含本改动(2026-09-17 构建);pytest 数据模块全绿。 --- tests/worker/test_data.py | 52 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) (limited to 'tests/worker') diff --git a/tests/worker/test_data.py b/tests/worker/test_data.py index d6eeae1..e5c30a3 100644 --- a/tests/worker/test_data.py +++ b/tests/worker/test_data.py @@ -334,3 +334,55 @@ def test_fetch_columns_subset_requested(): assert endpoint == "stock_zh_a_hist" assert all(c in df.columns for c in ("date", "symbol", "open", "close", "volume")) monkeypatch.undo() + + +# ---- identity contract: the server persists instruments with the user-facing +# market field (SH/SZ/BJ) exactly as the frontend manual-entry form sends them +# (159399 QA regression 2026-09-17: market:"SZ" used to fail with +# unsupported_market before any network attempt). Canonical "SH#600000" and +# market:"cn" bare-code inference remain the primary, unchanged contracts. ---- + +def test_split_identity_accepts_bare_code_with_market_sz_matches_device_inference(): + exch, code = data.split_identity({"symbol": "159399", "market": "SZ"}) + assert (exch, code) == ("SZ", "159399") + + +def test_split_identity_accepts_bare_code_with_market_sh(): + exch, code = data.split_identity({"symbol": "600000", "market": "SH"}) + assert (exch, code) == ("SH", "600000") + + +def test_split_identity_accepts_bare_code_with_market_cn_inference_unchanged(): + exch, code = data.split_identity({"symbol": "159399", "market": "cn"}) + assert (exch, code) == ("SZ", "159399") + + +def test_etf_fetch_passes_market_sz_instrument_to_provider(monkeypatch): + """Full fetch_source identity path: market:"SZ" (as stored by the server + from the frontend manual-entry form) must reach the sina provider.""" + captured = {} + real_frame = _sina_synth_frame() + + def fake_em(inst, s, e, f, adj): + raise data.DataError("source_unavailable", "eastmoney down (test)") + + def fake_sina(inst, s, e, f, adj): + captured["instrument"] = inst + return data.FetchResult(real_frame.copy(), "fund_etf_hist_sina", + {"symbol": "sz159399"}, real_frame.copy(), "sina") + + monkeypatch.setattr(data, "_fetch_eastmoney", fake_em) + monkeypatch.setattr(data, "_fetch_sina", fake_sina) + inst = {"symbol": "159399", "market": "SZ", "asset_type": "etf", "name": "国泰自由现金流"} + res, warns = data.fetch_source_with_warnings(inst, "2025-12-31", "2026-09-17", + "daily", "none", + ["open", "high", "low", "close", "volume"], + source="auto") + assert captured["instrument"] is inst + assert res.provider == "sina" + assert any(w.startswith("provider_fallback") for w in warns) + + +def test_split_identity_rejects_unknown_market_prefixes(): + with pytest.raises(data.DataError): + data.split_identity({"symbol": "1", "market": "adlhkj"}) -- cgit v1.2.3