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
+42 -25
View File
@@ -2,7 +2,8 @@ from __future__ import annotations
import logging
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.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.route_state import LEDGER
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
LOGGER = logging.getLogger("xiaobai.datahub")
@@ -304,11 +311,9 @@ class DatahubBridge:
def query(
self,
api_name: str,
params: dict[str, Any] | None,
fields: str,
legacy_query: Callable[..., list[dict[str, Any]]],
params: dict[str, Any] | None = None,
fields: str = "",
) -> list[dict[str, Any]]:
del legacy_query # 主网站不再直连 Tushare;调度全部由数据中枢完成。
if api_name == "rt_sw_k":
raise TushareError("rt_sw_k is disabled; use published sw_daily or free Shenwan realtime")
dataset = API_TO_DATASET.get(api_name)
@@ -542,21 +547,36 @@ def _finite(value: Any) -> float:
return 0.0
class DatahubAwareTushareClient:
def __init__(self, legacy: TushareClient, bridge: DatahubBridge) -> None:
self._legacy = legacy
self._bridge = bridge
# Mixins run as methods on the inner instance (dashboard / indices /
# getattr). Bind hub hooks and query onto that instance so real
# assembly cannot skip 8766.
self._legacy_query = legacy.query
legacy.query = self.query
legacy.try_market_quotes = self.try_market_quotes
legacy.try_quotes = self.try_quotes
legacy.try_index_quotes = self.try_index_quotes
legacy.try_sector_quote = self.try_sector_quote
legacy.try_limit_pool = self.try_limit_pool
legacy.record_datahub_legacy = self.record_datahub_legacy
class DatahubAwareTushareClient(
DashboardMixin,
IndexMixin,
ShenwanIndustryMixin,
SectorMixin,
DragonTigerMixin,
StockMixin,
DailyMarketMixin,
):
"""Website market facade. Mixins call query(); query talks only to the hub."""
_realtime_reference_cache: ClassVar[dict[str, dict[str, Any]]] = {}
_realtime_reference_lock: ClassVar[Lock] = Lock()
_capital_cache: ClassVar[dict[str, dict[str, Any]]] = {}
_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(
self,
@@ -564,7 +584,7 @@ class DatahubAwareTushareClient:
params: dict[str, Any] | None = None,
fields: str = "",
) -> 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:
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:
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},
)
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:
payload: dict[str, Any] = {"code": code}
if date:
+18
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import time
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.providers.ifind_client import IfindError
LOGGER = logging.getLogger("xiaobai.datahub")
class HubIfindProxy:
"""Website-facing iFinD facade. Talks only to xiaobai-datahub."""
@@ -15,16 +18,20 @@ class HubIfindProxy:
self._datahub = datahub
self._status: dict[str, Any] | None = None
self._status_at = 0.0
self._pending: tuple[str, str] | None = None
@property
def configured(self) -> bool:
return bool(self.status().get("configured"))
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_at = 0.0
self._flush_credentials()
def status(self) -> dict[str, Any]:
self._flush_credentials()
now = time.monotonic()
if self._status is not None and now - self._status_at < 30:
return dict(self._status)
@@ -132,7 +139,18 @@ class HubIfindProxy:
"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]]:
self._flush_credentials()
try:
response = self._datahub.client.query_api(api_name, params)
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.realtime_proxy import HubRealtimeProxy
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.providers.tushare_client import TushareClient
from backend.features.market.charts import MarketChartClient
@@ -20,7 +19,6 @@ from backend.features.market.charts import MarketChartClient
class DataGateway:
policy: DataSourcePolicy
quality: DataQualityGate
tushare_provider: TushareProvider
ifind_provider: IfindProvider
chart_data: MarketChartClient
realtime_observer: HubRealtimeProxy
@@ -34,12 +32,10 @@ class DataGateway:
self,
dataset_id: str = "",
usage: DataUsage = "calculation",
) -> TushareClient:
) -> DatahubAwareTushareClient:
if dataset_id:
self.policy.assert_allowed(dataset_id, "tushare", usage)
legacy = self.tushare_provider.client()
legacy.realtime_aggregator = None
return DatahubAwareTushareClient(legacy, self.datahub)
return DatahubAwareTushareClient(self.datahub)
def dataset_status(self, trade_date: str) -> list[dict[str, Any]] | None:
return self.datahub.dataset_status(trade_date)
@@ -102,9 +98,8 @@ def build_data_gateway(
tushare_token_supplier: Callable[[], str] | None = None,
datahub_settings: DatahubSettings | None = None,
) -> DataGateway:
token_supplier = tushare_token_supplier or (
lambda: str(credentials.get("tushare_token") or "")
)
# Website Tushare tokens are not used to assemble market clients.
del tushare_token_supplier
policy = DataSourcePolicy.load()
settings = datahub_settings or DatahubSettings.load(credentials=credentials)
datahub_client = DatahubClient(settings)
@@ -113,7 +108,6 @@ def build_data_gateway(
return DataGateway(
policy=policy,
quality=DataQualityGate.load(policy),
tushare_provider=TushareProvider(token_supplier),
ifind_provider=IfindProvider(ifind),
chart_data=MarketChartClient(datahub),
realtime_observer=HubRealtimeProxy(datahub),