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
@@ -125,6 +125,10 @@ class DashboardService(
|
||||
)
|
||||
self.data_gateway = self.container.data_gateway
|
||||
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.strategy_tracking = self.container.strategy_tracking
|
||||
self.alert_service = self.container.alert_service
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user