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:
总工
2026-09-08 23:37:53 +08:00
co-authored by Cursor multica-agent
parent 100752f43c
commit 8a7d1f3698
15 changed files with 235 additions and 83 deletions
+4
View File
@@ -125,6 +125,10 @@ class DashboardService(
) )
self.data_gateway = self.container.data_gateway self.data_gateway = self.container.data_gateway
self.ifind = self.container.ifind self.ifind = self.container.ifind
refresh = str(self._system_credentials.get("ifind_refresh_token") or "")
access = str(self._system_credentials.get("ifind_access_token") or "")
if refresh or access:
self.ifind.set_credentials(refresh, access)
self.screener = self.container.screener self.screener = self.container.screener
self.strategy_tracking = self.container.strategy_tracking self.strategy_tracking = self.container.strategy_tracking
self.alert_service = self.container.alert_service self.alert_service = self.container.alert_service
+42 -25
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
import logging import logging
import sys import sys
from typing import Any, Callable from threading import Lock
from typing import Any, Callable, ClassVar
from backend.data.datahub.client import DatahubClient, DatahubResponse from backend.data.datahub.client import DatahubClient, DatahubResponse
from backend.data.datahub.compare import compare_rows from backend.data.datahub.compare import compare_rows
@@ -18,7 +19,13 @@ from backend.data.datahub.native import (
from backend.data.datahub.redact import redact_text, redact_value from backend.data.datahub.redact import redact_text, redact_value
from backend.data.datahub.route_state import LEDGER from backend.data.datahub.route_state import LEDGER
from backend.data.datahub.settings import DatahubSettings from backend.data.datahub.settings import DatahubSettings
from backend.data.providers.tushare_client import TushareClient from backend.data.providers.tushare_daily import DailyMarketMixin
from backend.data.providers.tushare_dashboard import DashboardMixin
from backend.data.providers.tushare_dragon_tiger import DragonTigerMixin
from backend.data.providers.tushare_indices import IndexMixin
from backend.data.providers.tushare_industries import ShenwanIndustryMixin
from backend.data.providers.tushare_sectors import SectorMixin
from backend.data.providers.tushare_stocks import StockMixin
from backend.data.providers.tushare_transport import TushareError from backend.data.providers.tushare_transport import TushareError
LOGGER = logging.getLogger("xiaobai.datahub") LOGGER = logging.getLogger("xiaobai.datahub")
@@ -304,11 +311,9 @@ class DatahubBridge:
def query( def query(
self, self,
api_name: str, api_name: str,
params: dict[str, Any] | None, params: dict[str, Any] | None = None,
fields: str, fields: str = "",
legacy_query: Callable[..., list[dict[str, Any]]],
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
del legacy_query # 主网站不再直连 Tushare;调度全部由数据中枢完成。
if api_name == "rt_sw_k": if api_name == "rt_sw_k":
raise TushareError("rt_sw_k is disabled; use published sw_daily or free Shenwan realtime") raise TushareError("rt_sw_k is disabled; use published sw_daily or free Shenwan realtime")
dataset = API_TO_DATASET.get(api_name) dataset = API_TO_DATASET.get(api_name)
@@ -542,21 +547,36 @@ def _finite(value: Any) -> float:
return 0.0 return 0.0
class DatahubAwareTushareClient: class DatahubAwareTushareClient(
def __init__(self, legacy: TushareClient, bridge: DatahubBridge) -> None: DashboardMixin,
self._legacy = legacy IndexMixin,
self._bridge = bridge ShenwanIndustryMixin,
# Mixins run as methods on the inner instance (dashboard / indices / SectorMixin,
# getattr). Bind hub hooks and query onto that instance so real DragonTigerMixin,
# assembly cannot skip 8766. StockMixin,
self._legacy_query = legacy.query DailyMarketMixin,
legacy.query = self.query ):
legacy.try_market_quotes = self.try_market_quotes """Website market facade. Mixins call query(); query talks only to the hub."""
legacy.try_quotes = self.try_quotes
legacy.try_index_quotes = self.try_index_quotes _realtime_reference_cache: ClassVar[dict[str, dict[str, Any]]] = {}
legacy.try_sector_quote = self.try_sector_quote _realtime_reference_lock: ClassVar[Lock] = Lock()
legacy.try_limit_pool = self.try_limit_pool _capital_cache: ClassVar[dict[str, dict[str, Any]]] = {}
legacy.record_datahub_legacy = self.record_datahub_legacy _latest_realtime_market: ClassVar[dict[str, dict[str, Any]]] = {}
_stock_activity_cache: ClassVar[dict[str, dict[str, Any]]] = {}
_stock_listing_cache: ClassVar[dict[str, Any]] = {}
_stock_listing_lock: ClassVar[Lock] = Lock()
_suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {}
_suspension_lock: ClassVar[Lock] = Lock()
_sw_member_cache: ClassVar[dict[str, Any]] = {}
_sw_member_lock: ClassVar[Lock] = Lock()
def __init__(self, first: Any, second: Any | None = None) -> None:
# Production: DatahubAwareTushareClient(bridge)
# Older tests: DatahubAwareTushareClient(unused_legacy, bridge)
self._bridge = second if second is not None else first
self.token = "datahub"
self.timeout = 30
self.realtime_aggregator = None
def query( def query(
self, self,
@@ -564,7 +584,7 @@ class DatahubAwareTushareClient:
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
fields: str = "", fields: str = "",
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
return self._bridge.query(api_name, params, fields, self._legacy_query) return self._bridge.query(api_name, params, fields)
def try_market_quotes(self, trade_date: str = "") -> list[dict[str, Any]] | None: def try_market_quotes(self, trade_date: str = "") -> list[dict[str, Any]] | None:
return self._bridge.try_market_quotes(trade_date) return self._bridge.try_market_quotes(trade_date)
@@ -583,6 +603,3 @@ class DatahubAwareTushareClient:
def record_datahub_legacy(self, dataset: str, source: str = "", error: str = "") -> None: def record_datahub_legacy(self, dataset: str, source: str = "", error: str = "") -> None:
self._bridge.record_legacy(dataset, source, error) self._bridge.record_legacy(dataset, source, error)
def __getattr__(self, name: str) -> Any:
return getattr(self._legacy, name)
+9
View File
@@ -96,6 +96,15 @@ class DatahubClient:
{"api_name": api_name, "params": params or {}, "fields": fields}, {"api_name": api_name, "params": params or {}, "fields": fields},
) )
def put_ifind_credentials(self, refresh_token: str, access_token: str = "") -> DatahubResponse:
return self.post(
"/v1/credentials/ifind",
{
"ifind_refresh_token": refresh_token,
"ifind_access_token": access_token,
},
)
def sector_quote(self, code: str, date: str = "") -> DatahubResponse: def sector_quote(self, code: str, date: str = "") -> DatahubResponse:
payload: dict[str, Any] = {"code": code} payload: dict[str, Any] = {"code": code}
if date: if date:
+18
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import logging
import time import time
from typing import Any from typing import Any
@@ -7,6 +8,8 @@ from backend.data.datahub.bridge import DatahubBridge
from backend.data.datahub.errors import DatahubError from backend.data.datahub.errors import DatahubError
from backend.data.providers.ifind_client import IfindError from backend.data.providers.ifind_client import IfindError
LOGGER = logging.getLogger("xiaobai.datahub")
class HubIfindProxy: class HubIfindProxy:
"""Website-facing iFinD facade. Talks only to xiaobai-datahub.""" """Website-facing iFinD facade. Talks only to xiaobai-datahub."""
@@ -15,16 +18,20 @@ class HubIfindProxy:
self._datahub = datahub self._datahub = datahub
self._status: dict[str, Any] | None = None self._status: dict[str, Any] | None = None
self._status_at = 0.0 self._status_at = 0.0
self._pending: tuple[str, str] | None = None
@property @property
def configured(self) -> bool: def configured(self) -> bool:
return bool(self.status().get("configured")) return bool(self.status().get("configured"))
def set_credentials(self, refresh_token: str, access_token: str = "") -> None: def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
self._pending = (str(refresh_token or ""), str(access_token or ""))
self._status = None self._status = None
self._status_at = 0.0 self._status_at = 0.0
self._flush_credentials()
def status(self) -> dict[str, Any]: def status(self) -> dict[str, Any]:
self._flush_credentials()
now = time.monotonic() now = time.monotonic()
if self._status is not None and now - self._status_at < 30: if self._status is not None and now - self._status_at < 30:
return dict(self._status) return dict(self._status)
@@ -132,7 +139,18 @@ class HubIfindProxy:
"sample_time": str(payload[0].get("time") or "") if payload else "", "sample_time": str(payload[0].get("time") or "") if payload else "",
} }
def _flush_credentials(self) -> None:
pending = self._pending
if pending is None or not self._datahub.settings.token:
return
try:
self._datahub.client.put_ifind_credentials(pending[0], pending[1])
self._pending = None
except DatahubError:
LOGGER.warning("datahub ifind credential push failed; will retry")
def _rows(self, api_name: str, params: dict[str, Any]) -> list[dict[str, Any]]: def _rows(self, api_name: str, params: dict[str, Any]) -> list[dict[str, Any]]:
self._flush_credentials()
try: try:
response = self._datahub.client.query_api(api_name, params) response = self._datahub.client.query_api(api_name, params)
except DatahubError as exc: except DatahubError as exc:
+5 -11
View File
@@ -10,9 +10,8 @@ from backend.data.datahub import DatahubAwareTushareClient, DatahubBridge, Datah
from backend.data.datahub.ifind_proxy import HubIfindProxy from backend.data.datahub.ifind_proxy import HubIfindProxy
from backend.data.datahub.realtime_proxy import HubRealtimeProxy from backend.data.datahub.realtime_proxy import HubRealtimeProxy
from backend.data.policy import DataSourcePolicy from backend.data.policy import DataSourcePolicy
from backend.data.providers import IfindProvider, TushareProvider from backend.data.providers import IfindProvider
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
from backend.data.providers.tushare_client import TushareClient
from backend.features.market.charts import MarketChartClient from backend.features.market.charts import MarketChartClient
@@ -20,7 +19,6 @@ from backend.features.market.charts import MarketChartClient
class DataGateway: class DataGateway:
policy: DataSourcePolicy policy: DataSourcePolicy
quality: DataQualityGate quality: DataQualityGate
tushare_provider: TushareProvider
ifind_provider: IfindProvider ifind_provider: IfindProvider
chart_data: MarketChartClient chart_data: MarketChartClient
realtime_observer: HubRealtimeProxy realtime_observer: HubRealtimeProxy
@@ -34,12 +32,10 @@ class DataGateway:
self, self,
dataset_id: str = "", dataset_id: str = "",
usage: DataUsage = "calculation", usage: DataUsage = "calculation",
) -> TushareClient: ) -> DatahubAwareTushareClient:
if dataset_id: if dataset_id:
self.policy.assert_allowed(dataset_id, "tushare", usage) self.policy.assert_allowed(dataset_id, "tushare", usage)
legacy = self.tushare_provider.client() return DatahubAwareTushareClient(self.datahub)
legacy.realtime_aggregator = None
return DatahubAwareTushareClient(legacy, self.datahub)
def dataset_status(self, trade_date: str) -> list[dict[str, Any]] | None: def dataset_status(self, trade_date: str) -> list[dict[str, Any]] | None:
return self.datahub.dataset_status(trade_date) return self.datahub.dataset_status(trade_date)
@@ -102,9 +98,8 @@ def build_data_gateway(
tushare_token_supplier: Callable[[], str] | None = None, tushare_token_supplier: Callable[[], str] | None = None,
datahub_settings: DatahubSettings | None = None, datahub_settings: DatahubSettings | None = None,
) -> DataGateway: ) -> DataGateway:
token_supplier = tushare_token_supplier or ( # Website Tushare tokens are not used to assemble market clients.
lambda: str(credentials.get("tushare_token") or "") del tushare_token_supplier
)
policy = DataSourcePolicy.load() policy = DataSourcePolicy.load()
settings = datahub_settings or DatahubSettings.load(credentials=credentials) settings = datahub_settings or DatahubSettings.load(credentials=credentials)
datahub_client = DatahubClient(settings) datahub_client = DatahubClient(settings)
@@ -113,7 +108,6 @@ def build_data_gateway(
return DataGateway( return DataGateway(
policy=policy, policy=policy,
quality=DataQualityGate.load(policy), quality=DataQualityGate.load(policy),
tushare_provider=TushareProvider(token_supplier),
ifind_provider=IfindProvider(ifind), ifind_provider=IfindProvider(ifind),
chart_data=MarketChartClient(datahub), chart_data=MarketChartClient(datahub),
realtime_observer=HubRealtimeProxy(datahub), realtime_observer=HubRealtimeProxy(datahub),
+2
View File
@@ -20,6 +20,8 @@ services:
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}" DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}" DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}"
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}" TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
DATAHUB_DB_PATH: /app/data/datahub.db DATAHUB_DB_PATH: /app/data/datahub.db
DATAHUB_BACKUP_DIR: /app/data/backups DATAHUB_BACKUP_DIR: /app/data/backups
TZ: Asia/Shanghai TZ: Asia/Shanghai
+5 -5
View File
@@ -636,16 +636,16 @@
"bytes": 8357, "bytes": 8357,
"lines": 116 "lines": 116
}, },
{
"path": "backend/application.py",
"bytes": 6997,
"lines": 182
},
{ {
"path": "backend/features/screener/formula.py", "path": "backend/features/screener/formula.py",
"bytes": 6983, "bytes": 6983,
"lines": 146 "lines": 146
}, },
{
"path": "backend/application.py",
"bytes": 6751,
"lines": 178
},
{ {
"path": "backend/features/market/insights_popularity.py", "path": "backend/features/market/insights_popularity.py",
"bytes": 6739, "bytes": 6739,
+9 -7
View File
@@ -35,21 +35,23 @@ class DataGatewayTests(unittest.TestCase):
with self.assertRaises(DataPolicyError): with self.assertRaises(DataPolicyError):
policy.assert_allowed("market.level2", "unresolved", "display") policy.assert_allowed("market.level2", "unresolved", "display")
def test_gateway_uses_live_token_supplier_and_hub_proxies(self) -> None: def test_gateway_uses_hub_facade_and_proxies(self) -> None:
token = {"value": "first"}
gateway = build_data_gateway( gateway = build_data_gateway(
{"ifind_refresh_token": "refresh", "ifind_access_token": "access"}, {"ifind_refresh_token": "refresh", "ifind_access_token": "access"},
lambda: token["value"], lambda: "must-not-be-used",
) )
self.assertEqual(gateway.tushare().token, "first") client = gateway.tushare()
token["value"] = "second" self.assertEqual(client.token, "datahub")
self.assertEqual(gateway.tushare().token, "second") self.assertIsNone(client.realtime_aggregator)
self.assertFalse(hasattr(client, "_legacy"))
self.assertIs(gateway.ifind, gateway.ifind_provider.client) self.assertIs(gateway.ifind, gateway.ifind_provider.client)
self.assertIs(gateway.chart_data.datahub, gateway.datahub) self.assertIs(gateway.chart_data.datahub, gateway.datahub)
self.assertIsNone(gateway.chart_data.ifind) 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.ifind_proxy import HubIfindProxy
from backend.data.datahub.realtime_proxy import HubRealtimeProxy from backend.data.datahub.realtime_proxy import HubRealtimeProxy
self.assertIsInstance(client, DatahubAwareTushareClient)
self.assertIsInstance(gateway.ifind, HubIfindProxy) self.assertIsInstance(gateway.ifind, HubIfindProxy)
self.assertIsInstance(gateway.realtime_observer, HubRealtimeProxy) self.assertIsInstance(gateway.realtime_observer, HubRealtimeProxy)
@@ -70,7 +72,6 @@ class DataGatewayTests(unittest.TestCase):
"IfindProvider": {"backend/data/gateway.py"}, "IfindProvider": {"backend/data/gateway.py"},
"MarketChartClient": {"backend/data/gateway.py"}, "MarketChartClient": {"backend/data/gateway.py"},
"TushareClient": {"backend/features/market/service.py"}, "TushareClient": {"backend/features/market/service.py"},
"TushareProvider": {"backend/data/gateway.py"},
"DatahubClient": {"backend/data/gateway.py"}, "DatahubClient": {"backend/data/gateway.py"},
"DatahubAwareTushareClient": {"backend/data/gateway.py"}, "DatahubAwareTushareClient": {"backend/data/gateway.py"},
"DatahubBridge": {"backend/data/gateway.py"}, "DatahubBridge": {"backend/data/gateway.py"},
@@ -82,6 +83,7 @@ class DataGatewayTests(unittest.TestCase):
"IfindHttpClient": set(), "IfindHttpClient": set(),
"EastmoneyChartClient": set(), "EastmoneyChartClient": set(),
"WebRealtimeAggregator": set(), "WebRealtimeAggregator": set(),
"TushareProvider": set(),
} }
found_forbidden = {name: set() for name in forbidden} found_forbidden = {name: set() for name in forbidden}
for path in (root / "backend").rglob("*.py"): for path in (root / "backend").rglob("*.py"):
+9 -10
View File
@@ -546,7 +546,7 @@ class DatahubBridgeTests(unittest.TestCase):
self.assertEqual(chart[-1]["trade_date"], "2024-09-02") self.assertEqual(chart[-1]["trade_date"], "2024-09-02")
self.assertEqual(chart[-1]["close"], 10.4) 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 = [ quotes = [
{ {
"ts_code": f"{index:06d}.SZ", "ts_code": f"{index:06d}.SZ",
@@ -574,21 +574,20 @@ class DatahubBridgeTests(unittest.TestCase):
) )
gateway.datahub.client = hub_client gateway.datahub.client = hub_client
wrapped = gateway.tushare() wrapped = gateway.tushare()
inner = wrapped._legacy self.assertFalse(hasattr(wrapped, "_legacy"))
self.assertTrue(callable(getattr(inner, "try_market_quotes", None))) self.assertIsNone(getattr(type(wrapped), "__getattr__", None))
self.assertTrue(callable(getattr(inner, "try_index_quotes", None))) self.assertTrue(callable(getattr(type(wrapped), "try_market_quotes", None)))
self.assertTrue(callable(getattr(inner, "record_datahub_legacy", None))) self.assertTrue(callable(getattr(type(wrapped), "try_index_quotes", None)))
self.assertIs(inner.query.__self__, wrapped) self.assertTrue(callable(getattr(type(wrapped), "record_datahub_legacy", None)))
self.assertEqual(inner.query.__func__, wrapped.query.__func__) self.assertTrue(callable(getattr(type(wrapped), "dashboard", None)))
self.assertFalse(hasattr(type(inner), "try_market_quotes")) rows = wrapped.try_market_quotes("20240902")
rows = inner.try_market_quotes("20240902")
self.assertGreaterEqual(len(rows or []), 200) self.assertGreaterEqual(len(rows or []), 200)
self.assertIn("/v1/quotes/latest", hub_client.paths) self.assertIn("/v1/quotes/latest", hub_client.paths)
hub_client.response = DatahubResponse( hub_client.response = DatahubResponse(
data=[dict(HUB_DAILY)], data=[dict(HUB_DAILY)],
meta={"stale": False, "staleness_seconds": 0, "source": "tushare: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.assertEqual(daily[0]["amount"], 2000.0)
self.assertIn("/v1/bars/daily", hub_client.paths) self.assertIn("/v1/bars/daily", hub_client.paths)
+72 -4
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import ast import ast
import json import json
import re
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -143,6 +144,26 @@ def hub_payload(request) -> dict:
], ],
"meta": {"stale": False, "staleness_seconds": 0, "source": "tencent"}, "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": if path == "/v1/intraday/points":
return { return {
"schema_version": 1, "schema_version": 1,
@@ -184,7 +205,7 @@ def hub_payload(request) -> dict:
], ],
"meta": {"source": "ifind"}, "meta": {"source": "ifind"},
} }
if api_name in {"daily", "rt_k"}: if api_name in {"daily", "rt_k", "stk_auction"}:
return { return {
"schema_version": 1, "schema_version": 1,
"data": [{"ts_code": "600000.SH", "trade_date": "20240902", "close": 10.2, "amount": 2000.0}], "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("IfindHttpClient", source)
self.assertNotIn("EastmoneyChartClient", source) self.assertNotIn("EastmoneyChartClient", source)
self.assertNotIn("WebRealtimeAggregator", 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("HubIfindProxy", source)
self.assertIn("HubRealtimeProxy", source) self.assertIn("HubRealtimeProxy", source)
self.assertIn("legacy.realtime_aggregator = None", source)
self.assertIn("DatahubAwareTushareClient", 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: def test_production_python_does_not_embed_blocked_hosts(self) -> None:
violations = [] violations = []
@@ -273,7 +306,8 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
def test_website_runtime_does_not_call_blocked_hosts_from_gateway(self) -> None: 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") 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) self.assertIn("DatahubAwareTushareClient", gateway_src)
def test_bridge_query_has_no_legacy_call(self) -> None: def test_bridge_query_has_no_legacy_call(self) -> None:
@@ -298,13 +332,47 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
settings = _enabled_settings() settings = _enabled_settings()
with patch("urllib.request.urlopen", blocked_urlopen): with patch("urllib.request.urlopen", blocked_urlopen):
gateway = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings) 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("涨停") rows = gateway.ifind.wencai("涨停")
quotes = gateway.realtime_observer.tencent_indices() quotes = gateway.realtime_observer.tencent_indices()
chart = gateway.chart_data.stock_daily("600000", "20240902") 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(rows[0]["涨停原因"], "重组")
self.assertEqual(len(quotes), 3) self.assertEqual(len(quotes), 3)
self.assertEqual(chart[-1]["close"], 10.2) 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__": if __name__ == "__main__":
+7 -21
View File
@@ -380,10 +380,9 @@ class RealtimeDashboardTests(unittest.TestCase):
def test_gateway_dashboard_uses_bound_market_quotes(self) -> None: def test_gateway_dashboard_uses_bound_market_quotes(self) -> None:
from backend.data import build_data_gateway 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.client import DatahubResponse
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
from backend.data.gateway import DataGateway
from backend.data.providers.tushare import TushareProvider
quotes = [ quotes = [
{ {
@@ -442,30 +441,17 @@ class RealtimeDashboardTests(unittest.TestCase):
datasets = {name: DatasetFlags(name) for name in DATASETS} datasets = {name: DatasetFlags(name) for name in DATASETS}
datasets["quotes"] = DatasetFlags("quotes", read=True, shadow=False) datasets["quotes"] = DatasetFlags("quotes", read=True, shadow=False)
settings = DatahubSettings(base_url="http://127.0.0.1:9", token="tok", datasets=datasets) 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 = 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.datahub.client = QuoteHub() gateway.datahub.client = QuoteHub()
wrapped = gateway.tushare() wrapped = gateway.tushare()
inner = wrapped._legacy wrapped.clock = lambda: datetime(2026, 7, 20, 10, 30, tzinfo=timezone(timedelta(hours=8)))
inner.clock = lambda: datetime(2026, 7, 20, 10, 30, tzinfo=timezone(timedelta(hours=8))) wrapped.realtime_aggregator = FakeFreeAggregator(fail=True)
inner.realtime_aggregator = FakeFreeAggregator(fail=True) DatahubAwareTushareClient._realtime_reference_cache.clear()
TushareClient._realtime_reference_cache.clear()
dashboard = wrapped.dashboard("20260720") dashboard = wrapped.dashboard("20260720")
self.assertEqual(dashboard["meta"]["quote_source"], "datahub") self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
self.assertIn("/v1/quotes/latest", gateway.datahub.client.calls) self.assertIn("/v1/quotes/latest", gateway.datahub.client.calls)
self.assertTrue(callable(getattr(inner, "try_market_quotes", None))) self.assertTrue(callable(getattr(type(wrapped), "try_market_quotes", None)))
self.assertFalse(hasattr(type(inner), "try_market_quotes")) self.assertFalse(hasattr(wrapped, "_legacy"))
if __name__ == "__main__": if __name__ == "__main__":
+2
View File
@@ -14,6 +14,8 @@ services:
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}" DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}" DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}"
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}" TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
DATAHUB_DB_PATH: /app/data/datahub.db DATAHUB_DB_PATH: /app/data/datahub.db
DATAHUB_BACKUP_DIR: /app/data/backups DATAHUB_BACKUP_DIR: /app/data/backups
TZ: Asia/Shanghai TZ: Asia/Shanghai
+5
View File
@@ -76,6 +76,11 @@ class HubRequestHandler(BaseHTTPRequestHandler):
payload = self.hub.api.query_api(body) payload = self.hub.api.query_api(body)
self._json(payload, HTTPStatus.OK) self._json(payload, HTTPStatus.OK)
return return
if path == "/v1/credentials/ifind" and method == "POST":
body = self._read_json()
payload = self.hub.put_ifind_credentials(body)
self._json(payload, HTTPStatus.OK)
return
payload = self.hub.api.handle(path, parse_query(query)) payload = self.hub.api.handle(path, parse_query(query))
self._json(payload, HTTPStatus.OK) self._json(payload, HTTPStatus.OK)
+12
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any
from datahub.adapters.ifind import IfindAdapter from datahub.adapters.ifind import IfindAdapter
from datahub.adapters.tushare import TushareAdapter from datahub.adapters.tushare import TushareAdapter
@@ -52,6 +53,17 @@ class Hub:
self.admin = AdminAPI(self.db, self.pipeline, self.scheduler, self.auth, ifind=self.ifind) self.admin = AdminAPI(self.db, self.pipeline, self.scheduler, self.auth, ifind=self.ifind)
self.static_dir = Path(__file__).resolve().parents[1] / "admin" self.static_dir = Path(__file__).resolve().parents[1] / "admin"
def put_ifind_credentials(self, body: dict[str, Any] | None) -> dict[str, Any]:
from datahub.serving import envelope
payload = dict(body or {})
refresh = str(payload.get("ifind_refresh_token") or "").strip()
access = str(payload.get("ifind_access_token") or "").strip()
self.auth.store_credential("ifind_refresh_token", refresh)
self.auth.store_credential("ifind_access_token", access)
self.ifind.set_credentials(refresh, access)
return envelope(self.ifind.status(), {"source": "ifind"})
def start(self) -> None: def start(self) -> None:
if self.settings.scheduler_enabled: if self.settings.scheduler_enabled:
self.scheduler.start() self.scheduler.start()
+34
View File
@@ -61,6 +61,18 @@ class ApiContractTests(unittest.TestCase):
self.hub.stop() self.hub.stop()
self.tmp.cleanup() self.tmp.cleanup()
def _post(self, path: str, body: dict, token: str | None = None) -> tuple[int, dict]:
headers = {"Content-Type": "application/json"}
if token is not None:
headers["X-Datahub-Token"] = token
raw = json.dumps(body).encode("utf-8")
req = Request(self.base + path, data=raw, headers=headers, method="POST")
try:
with urlopen(req, timeout=5) as resp:
return resp.status, json.loads(resp.read().decode())
except HTTPError as exc:
return exc.code, json.loads(exc.read().decode())
def _get(self, path: str, token: str | None = None) -> tuple[int, dict]: def _get(self, path: str, token: str | None = None) -> tuple[int, dict]:
headers = {} headers = {}
if token is not None: if token is not None:
@@ -145,6 +157,28 @@ class ApiContractTests(unittest.TestCase):
self.assertNotIn("tushare-secret-token-xyz", blob) self.assertNotIn("tushare-secret-token-xyz", blob)
self.assertNotIn(self.token, blob) self.assertNotIn(self.token, blob)
def test_ifind_credentials_require_token_and_update_adapter(self) -> None:
status, body = self._post(
"/v1/credentials/ifind",
{"ifind_refresh_token": "refresh-secret", "ifind_access_token": "access-secret"},
token=None,
)
self.assertEqual(status, 401)
self.assertEqual(body["error"]["code"], "UNAUTHORIZED")
status, body = self._post(
"/v1/credentials/ifind",
{"ifind_refresh_token": "refresh-secret", "ifind_access_token": "access-secret"},
token=self.token,
)
self.assertEqual(status, 200, body)
self.assertTrue(body["data"]["configured"])
self.assertTrue(body["data"]["access_ready"])
self.assertTrue(self.hub.ifind.configured)
self.assertEqual(self.hub.auth.load_credential("ifind_refresh_token"), "refresh-secret")
blob = json.dumps(body)
self.assertNotIn("refresh-secret", blob)
self.assertNotIn("access-secret", blob)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()