fix(HEL-494): 网站市场客户端改为纯中枢 Facade,并迁移 iFinD 凭据到中枢
生产 gateway 不再读取 Tushare token 或实例化 TushareProvider/TushareClient;问财凭据经带鉴权的中枢接口加密入库,避免发版后 iFinD 未配置。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
100752f43c
commit
8a7d1f3698
@@ -35,21 +35,23 @@ class DataGatewayTests(unittest.TestCase):
|
||||
with self.assertRaises(DataPolicyError):
|
||||
policy.assert_allowed("market.level2", "unresolved", "display")
|
||||
|
||||
def test_gateway_uses_live_token_supplier_and_hub_proxies(self) -> None:
|
||||
token = {"value": "first"}
|
||||
def test_gateway_uses_hub_facade_and_proxies(self) -> None:
|
||||
gateway = build_data_gateway(
|
||||
{"ifind_refresh_token": "refresh", "ifind_access_token": "access"},
|
||||
lambda: token["value"],
|
||||
lambda: "must-not-be-used",
|
||||
)
|
||||
self.assertEqual(gateway.tushare().token, "first")
|
||||
token["value"] = "second"
|
||||
self.assertEqual(gateway.tushare().token, "second")
|
||||
client = gateway.tushare()
|
||||
self.assertEqual(client.token, "datahub")
|
||||
self.assertIsNone(client.realtime_aggregator)
|
||||
self.assertFalse(hasattr(client, "_legacy"))
|
||||
self.assertIs(gateway.ifind, gateway.ifind_provider.client)
|
||||
self.assertIs(gateway.chart_data.datahub, gateway.datahub)
|
||||
self.assertIsNone(gateway.chart_data.ifind)
|
||||
from backend.data.datahub.bridge import DatahubAwareTushareClient
|
||||
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
||||
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
||||
|
||||
self.assertIsInstance(client, DatahubAwareTushareClient)
|
||||
self.assertIsInstance(gateway.ifind, HubIfindProxy)
|
||||
self.assertIsInstance(gateway.realtime_observer, HubRealtimeProxy)
|
||||
|
||||
@@ -70,7 +72,6 @@ class DataGatewayTests(unittest.TestCase):
|
||||
"IfindProvider": {"backend/data/gateway.py"},
|
||||
"MarketChartClient": {"backend/data/gateway.py"},
|
||||
"TushareClient": {"backend/features/market/service.py"},
|
||||
"TushareProvider": {"backend/data/gateway.py"},
|
||||
"DatahubClient": {"backend/data/gateway.py"},
|
||||
"DatahubAwareTushareClient": {"backend/data/gateway.py"},
|
||||
"DatahubBridge": {"backend/data/gateway.py"},
|
||||
@@ -82,6 +83,7 @@ class DataGatewayTests(unittest.TestCase):
|
||||
"IfindHttpClient": set(),
|
||||
"EastmoneyChartClient": set(),
|
||||
"WebRealtimeAggregator": set(),
|
||||
"TushareProvider": set(),
|
||||
}
|
||||
found_forbidden = {name: set() for name in forbidden}
|
||||
for path in (root / "backend").rglob("*.py"):
|
||||
|
||||
@@ -546,7 +546,7 @@ class DatahubBridgeTests(unittest.TestCase):
|
||||
self.assertEqual(chart[-1]["trade_date"], "2024-09-02")
|
||||
self.assertEqual(chart[-1]["close"], 10.4)
|
||||
|
||||
def test_gateway_tushare_assembly_binds_hooks_on_inner_client(self) -> None:
|
||||
def test_gateway_tushare_facade_has_no_legacy_client(self) -> None:
|
||||
quotes = [
|
||||
{
|
||||
"ts_code": f"{index:06d}.SZ",
|
||||
@@ -574,21 +574,20 @@ class DatahubBridgeTests(unittest.TestCase):
|
||||
)
|
||||
gateway.datahub.client = hub_client
|
||||
wrapped = gateway.tushare()
|
||||
inner = wrapped._legacy
|
||||
self.assertTrue(callable(getattr(inner, "try_market_quotes", None)))
|
||||
self.assertTrue(callable(getattr(inner, "try_index_quotes", None)))
|
||||
self.assertTrue(callable(getattr(inner, "record_datahub_legacy", None)))
|
||||
self.assertIs(inner.query.__self__, wrapped)
|
||||
self.assertEqual(inner.query.__func__, wrapped.query.__func__)
|
||||
self.assertFalse(hasattr(type(inner), "try_market_quotes"))
|
||||
rows = inner.try_market_quotes("20240902")
|
||||
self.assertFalse(hasattr(wrapped, "_legacy"))
|
||||
self.assertIsNone(getattr(type(wrapped), "__getattr__", None))
|
||||
self.assertTrue(callable(getattr(type(wrapped), "try_market_quotes", None)))
|
||||
self.assertTrue(callable(getattr(type(wrapped), "try_index_quotes", None)))
|
||||
self.assertTrue(callable(getattr(type(wrapped), "record_datahub_legacy", None)))
|
||||
self.assertTrue(callable(getattr(type(wrapped), "dashboard", None)))
|
||||
rows = wrapped.try_market_quotes("20240902")
|
||||
self.assertGreaterEqual(len(rows or []), 200)
|
||||
self.assertIn("/v1/quotes/latest", hub_client.paths)
|
||||
hub_client.response = DatahubResponse(
|
||||
data=[dict(HUB_DAILY)],
|
||||
meta={"stale": False, "staleness_seconds": 0, "source": "tushare:daily"},
|
||||
)
|
||||
daily = inner.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
||||
daily = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
||||
self.assertEqual(daily[0]["amount"], 2000.0)
|
||||
self.assertIn("/v1/bars/daily", hub_client.paths)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -143,6 +144,26 @@ def hub_payload(request) -> dict:
|
||||
],
|
||||
"meta": {"stale": False, "staleness_seconds": 0, "source": "tencent"},
|
||||
}
|
||||
if path == "/v1/auction":
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"data": [
|
||||
{
|
||||
"ts_code": "600000.SH",
|
||||
"trade_date": "20240902",
|
||||
"close": 10.2,
|
||||
"vol": 1000.0,
|
||||
"amount": 2000.0,
|
||||
}
|
||||
],
|
||||
"meta": {"stale": False, "staleness_seconds": 0, "source": "datahub"},
|
||||
}
|
||||
if path == "/v1/credentials/ifind":
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"data": {"configured": True, "access_ready": True, "access_expires_at": ""},
|
||||
"meta": {"source": "ifind"},
|
||||
}
|
||||
if path == "/v1/intraday/points":
|
||||
return {
|
||||
"schema_version": 1,
|
||||
@@ -184,7 +205,7 @@ def hub_payload(request) -> dict:
|
||||
],
|
||||
"meta": {"source": "ifind"},
|
||||
}
|
||||
if api_name in {"daily", "rt_k"}:
|
||||
if api_name in {"daily", "rt_k", "stk_auction"}:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"data": [{"ts_code": "600000.SH", "trade_date": "20240902", "close": 10.2, "amount": 2000.0}],
|
||||
@@ -254,10 +275,22 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
|
||||
self.assertNotIn("IfindHttpClient", source)
|
||||
self.assertNotIn("EastmoneyChartClient", source)
|
||||
self.assertNotIn("WebRealtimeAggregator", source)
|
||||
self.assertNotIn("TushareProvider", source)
|
||||
self.assertIsNone(re.search(r"(?<![A-Za-z])TushareClient\(", source))
|
||||
self.assertIn("HubIfindProxy", source)
|
||||
self.assertIn("HubRealtimeProxy", source)
|
||||
self.assertIn("legacy.realtime_aggregator = None", source)
|
||||
self.assertIn("DatahubAwareTushareClient", source)
|
||||
facade = (ROOT / "backend" / "data" / "datahub" / "bridge.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(facade)
|
||||
cls = next(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.ClassDef) and node.name == "DatahubAwareTushareClient"
|
||||
)
|
||||
methods = {item.name for item in cls.body if isinstance(item, ast.FunctionDef)}
|
||||
self.assertNotIn("__getattr__", methods)
|
||||
self.assertIn("query", methods)
|
||||
self.assertTrue(any(base.id == "DashboardMixin" for base in cls.bases if isinstance(base, ast.Name)))
|
||||
|
||||
def test_production_python_does_not_embed_blocked_hosts(self) -> None:
|
||||
violations = []
|
||||
@@ -273,7 +306,8 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
|
||||
|
||||
def test_website_runtime_does_not_call_blocked_hosts_from_gateway(self) -> None:
|
||||
gateway_src = (ROOT / "backend" / "data" / "gateway.py").read_text(encoding="utf-8")
|
||||
self.assertIn("legacy.realtime_aggregator = None", gateway_src)
|
||||
self.assertNotIn("TushareProvider", gateway_src)
|
||||
self.assertIsNone(re.search(r"(?<![A-Za-z])TushareClient\(", gateway_src))
|
||||
self.assertIn("DatahubAwareTushareClient", gateway_src)
|
||||
|
||||
def test_bridge_query_has_no_legacy_call(self) -> None:
|
||||
@@ -298,13 +332,47 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
|
||||
settings = _enabled_settings()
|
||||
with patch("urllib.request.urlopen", blocked_urlopen):
|
||||
gateway = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
|
||||
gateway.datahub.client = DatahubClient(settings, urlopen=blocked_urlopen)
|
||||
hub_client = DatahubClient(settings, urlopen=blocked_urlopen)
|
||||
gateway.datahub.client = hub_client
|
||||
rows = gateway.ifind.wencai("涨停")
|
||||
quotes = gateway.realtime_observer.tencent_indices()
|
||||
chart = gateway.chart_data.stock_daily("600000", "20240902")
|
||||
market = gateway.tushare()
|
||||
market_quotes = market.try_quotes(["600000.SH"])
|
||||
auction = market.query("stk_auction", {"trade_date": "20240902"}, "")
|
||||
gateway.ifind.set_credentials("refresh-token", "access-token")
|
||||
self.assertEqual(rows[0]["涨停原因"], "重组")
|
||||
self.assertEqual(len(quotes), 3)
|
||||
self.assertEqual(chart[-1]["close"], 10.2)
|
||||
self.assertEqual(market_quotes[0]["close"], 10.2)
|
||||
self.assertEqual(auction[0]["close"], 10.2)
|
||||
self.assertIsNone(market.realtime_aggregator)
|
||||
self.assertEqual(market.token, "datahub")
|
||||
|
||||
def test_set_credentials_posts_to_hub_not_ifind(self) -> None:
|
||||
seen: list[str] = []
|
||||
|
||||
def urlopen(request, timeout=None):
|
||||
url = str(getattr(request, "full_url", None) or request)
|
||||
seen.append(url)
|
||||
if any(host in url for host in BLOCKED_HOSTS):
|
||||
raise AssertionError(f"website opened blocked host: {url}")
|
||||
return _Resp(hub_payload(request))
|
||||
|
||||
settings = _enabled_settings()
|
||||
hub_client = DatahubClient(settings, urlopen=urlopen)
|
||||
proxy = HubIfindProxy(DatahubBridge(settings, hub_client))
|
||||
proxy.set_credentials("refresh-token", "access-token")
|
||||
self.assertTrue(any("/v1/credentials/ifind" in url for url in seen))
|
||||
self.assertFalse(any("51ifind.com" in url for url in seen))
|
||||
self.assertFalse(any("quantapi" in url for url in seen))
|
||||
|
||||
def test_compose_passes_ifind_env_to_hub(self) -> None:
|
||||
overlay = (ROOT / "compose.datahub.yaml").read_text(encoding="utf-8")
|
||||
standalone = (ROOT / "xiaobai-datahub" / "compose.yaml").read_text(encoding="utf-8")
|
||||
for text in (overlay, standalone):
|
||||
self.assertIn('IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"', text)
|
||||
self.assertIn('IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"', text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -380,10 +380,9 @@ class RealtimeDashboardTests(unittest.TestCase):
|
||||
|
||||
def test_gateway_dashboard_uses_bound_market_quotes(self) -> None:
|
||||
from backend.data import build_data_gateway
|
||||
from backend.data.datahub.bridge import DatahubAwareTushareClient
|
||||
from backend.data.datahub.client import DatahubResponse
|
||||
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
||||
from backend.data.gateway import DataGateway
|
||||
from backend.data.providers.tushare import TushareProvider
|
||||
|
||||
quotes = [
|
||||
{
|
||||
@@ -442,30 +441,17 @@ class RealtimeDashboardTests(unittest.TestCase):
|
||||
datasets = {name: DatasetFlags(name) for name in DATASETS}
|
||||
datasets["quotes"] = DatasetFlags("quotes", read=True, shadow=False)
|
||||
settings = DatahubSettings(base_url="http://127.0.0.1:9", token="tok", datasets=datasets)
|
||||
base = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
|
||||
gateway = DataGateway(
|
||||
policy=base.policy,
|
||||
quality=base.quality,
|
||||
tushare_provider=TushareProvider(
|
||||
lambda: "tok",
|
||||
client_factory=lambda token: FakeRealtimeClient(token),
|
||||
),
|
||||
ifind_provider=base.ifind_provider,
|
||||
chart_data=base.chart_data,
|
||||
realtime_observer=base.realtime_observer,
|
||||
datahub=base.datahub,
|
||||
)
|
||||
gateway = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
|
||||
gateway.datahub.client = QuoteHub()
|
||||
wrapped = gateway.tushare()
|
||||
inner = wrapped._legacy
|
||||
inner.clock = lambda: datetime(2026, 7, 20, 10, 30, tzinfo=timezone(timedelta(hours=8)))
|
||||
inner.realtime_aggregator = FakeFreeAggregator(fail=True)
|
||||
TushareClient._realtime_reference_cache.clear()
|
||||
wrapped.clock = lambda: datetime(2026, 7, 20, 10, 30, tzinfo=timezone(timedelta(hours=8)))
|
||||
wrapped.realtime_aggregator = FakeFreeAggregator(fail=True)
|
||||
DatahubAwareTushareClient._realtime_reference_cache.clear()
|
||||
dashboard = wrapped.dashboard("20260720")
|
||||
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
|
||||
self.assertIn("/v1/quotes/latest", gateway.datahub.client.calls)
|
||||
self.assertTrue(callable(getattr(inner, "try_market_quotes", None)))
|
||||
self.assertFalse(hasattr(type(inner), "try_market_quotes"))
|
||||
self.assertTrue(callable(getattr(type(wrapped), "try_market_quotes", None)))
|
||||
self.assertFalse(hasattr(wrapped, "_legacy"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user