fix(HEL-494): enforce datahub as sole website market boundary
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -60,22 +60,6 @@ from backend.llm.service import LLMServiceMixin
|
||||
from database import ReviewDatabase
|
||||
|
||||
|
||||
LEGACY_SECRET_KEYS = {
|
||||
"TUSHARE_TOKEN",
|
||||
"IFIND_REFRESH_TOKEN",
|
||||
"IFIND_ACCESS_TOKEN",
|
||||
"LLM_API_KEY",
|
||||
"LLM_BASE_URL",
|
||||
"LLM_MODEL",
|
||||
"LLM_PRIMARY_API_KEY",
|
||||
"LLM_PRIMARY_BASE_URL",
|
||||
"LLM_PRIMARY_MODEL",
|
||||
"LLM_FALLBACK_API_KEY",
|
||||
"LLM_FALLBACK_BASE_URL",
|
||||
"LLM_FALLBACK_MODEL",
|
||||
}
|
||||
|
||||
|
||||
class DashboardService(
|
||||
SystemServiceMixin,
|
||||
AccountApplicationMixin,
|
||||
@@ -121,14 +105,9 @@ class DashboardService(
|
||||
self._system_credentials,
|
||||
MENTOR_SKILLS_DIR,
|
||||
PRIVATE_MENTOR_SKILLS_DIR,
|
||||
lambda: self.token,
|
||||
)
|
||||
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,6 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from collections.abc import Callable
|
||||
|
||||
from backend.data import DataGateway, build_data_gateway
|
||||
from backend.database.repositories import RepositoryBundle, build_repository_bundle
|
||||
@@ -39,9 +38,8 @@ def build_application_container(
|
||||
credentials: dict[str, object],
|
||||
mentor_skills_dir: Path,
|
||||
private_mentor_skills_dir: Path,
|
||||
tushare_token_supplier: Callable[[], str] | None = None,
|
||||
) -> ApplicationContainer:
|
||||
data_gateway = build_data_gateway(credentials, tushare_token_supplier)
|
||||
data_gateway = build_data_gateway(credentials)
|
||||
repositories = build_repository_bundle(database)
|
||||
jobs = InProcessJobRunner(JobRegistry.load(), SQLiteJobRunRepository(database))
|
||||
return ApplicationContainer(
|
||||
|
||||
@@ -10,11 +10,8 @@ from backend.features.accounts.security import SecretVault
|
||||
|
||||
def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]:
|
||||
return {
|
||||
"tushare_token": str(environment.get("TUSHARE_TOKEN") or "").strip(),
|
||||
"datahub_token": str(environment.get("DATAHUB_TOKEN") or "").strip(),
|
||||
"datahub_base_url": str(environment.get("DATAHUB_BASE_URL") or "").strip(),
|
||||
"ifind_refresh_token": str(environment.get("IFIND_REFRESH_TOKEN") or "").strip(),
|
||||
"ifind_access_token": str(environment.get("IFIND_ACCESS_TOKEN") or "").strip(),
|
||||
"platform_llm_primary_api_key": str(
|
||||
environment.get("LLM_PRIMARY_API_KEY") or environment.get("LLM_API_KEY") or ""
|
||||
).strip(),
|
||||
|
||||
@@ -96,15 +96,6 @@ 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,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -8,9 +7,6 @@ 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."""
|
||||
|
||||
@@ -18,20 +14,12 @@ 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)
|
||||
@@ -139,18 +127,7 @@ 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:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
@@ -95,11 +94,8 @@ class DataGateway:
|
||||
|
||||
def build_data_gateway(
|
||||
credentials: dict[str, object],
|
||||
tushare_token_supplier: Callable[[], str] | None = None,
|
||||
datahub_settings: DatahubSettings | None = None,
|
||||
) -> DataGateway:
|
||||
# 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)
|
||||
|
||||
@@ -14,7 +14,7 @@ from backend.bootstrap.config import (
|
||||
validate_text,
|
||||
)
|
||||
from backend.data.providers.ifind_client import IfindError
|
||||
from backend.data.providers.tushare_client import TushareClient, TushareError
|
||||
from backend.data.providers.tushare_client import TushareError
|
||||
from backend.data.providers.tushare_helpers import _moneyflow_payload, _optional_number
|
||||
from backend.data.realtime import RealtimeAggregateError
|
||||
from backend.features.market.backfill_history import (
|
||||
@@ -61,16 +61,14 @@ class MarketServiceMixin:
|
||||
self._tushare_client(),
|
||||
ifind=self.ifind,
|
||||
)
|
||||
def _tushare_client(self) -> TushareClient:
|
||||
def _tushare_client(self) -> Any:
|
||||
override = getattr(self, "_market_client_override", None)
|
||||
if override is not None:
|
||||
return override
|
||||
gateway = getattr(self, "data_gateway", None)
|
||||
if gateway is not None:
|
||||
return gateway.tushare()
|
||||
# Compatibility for isolated legacy unit-test service stubs.
|
||||
client = TushareClient(self.token)
|
||||
aggregator = getattr(self, "realtime_aggregator", None)
|
||||
if aggregator is not None:
|
||||
client.realtime_aggregator = aggregator
|
||||
return client
|
||||
if gateway is None:
|
||||
raise RuntimeError("数据中枢尚未装配。")
|
||||
return gateway.tushare()
|
||||
|
||||
def _now(self) -> datetime:
|
||||
clock = getattr(self, "clock", None)
|
||||
@@ -178,11 +176,10 @@ class MarketServiceMixin:
|
||||
return "最近可用交易日"
|
||||
return f"{int(compact[4:6])} 月 {int(compact[6:8])} 日"
|
||||
|
||||
@classmethod
|
||||
def _preparing_display_notice(cls, actual_date: str, requested_date: str) -> str:
|
||||
shown = cls._chinese_month_day(actual_date)
|
||||
def _preparing_display_notice(self, actual_date: str, requested_date: str) -> str:
|
||||
shown = self._chinese_month_day(actual_date)
|
||||
requested = str(requested_date or "").replace("-", "")
|
||||
if requested == date.today().strftime("%Y%m%d"):
|
||||
if requested == self._now().strftime("%Y%m%d"):
|
||||
return f"今日数据正在准备,当前展示 {shown}"
|
||||
return f"所选日期数据尚未到齐,当前展示 {shown}"
|
||||
|
||||
|
||||
@@ -4,7 +4,14 @@ import re
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from backend.bootstrap.config import TOKEN_PATTERN, validate_text
|
||||
from backend.bootstrap.config import validate_text
|
||||
|
||||
|
||||
MARKET_SOURCE_SECRET_KEYS = {
|
||||
"tushare_token",
|
||||
"ifind_refresh_token",
|
||||
"ifind_access_token",
|
||||
}
|
||||
|
||||
|
||||
class SystemServiceMixin:
|
||||
@@ -18,9 +25,6 @@ class SystemServiceMixin:
|
||||
first_encrypted = self.database.get_user_credentials(first_user_id)
|
||||
first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {}
|
||||
defaults = {
|
||||
"tushare_token": environment.get("tushare_token") or first_personal.get("tushare_token") or "",
|
||||
"ifind_refresh_token": environment.get("ifind_refresh_token") or "",
|
||||
"ifind_access_token": environment.get("ifind_access_token") or "",
|
||||
"platform_llm_primary_api_key": environment.get("platform_llm_primary_api_key") or first_personal.get("llm_primary_api_key") or "",
|
||||
"platform_llm_primary_base_url": environment.get("platform_llm_primary_base_url") or first_personal.get("llm_primary_base_url") or "https://api.openai.com/v1",
|
||||
"platform_llm_primary_model": environment.get("platform_llm_primary_model") or first_personal.get("llm_primary_model") or "",
|
||||
@@ -34,6 +38,10 @@ class SystemServiceMixin:
|
||||
if key not in current:
|
||||
current[key] = value
|
||||
changed = True
|
||||
for key in MARKET_SOURCE_SECRET_KEYS:
|
||||
if key in current:
|
||||
current.pop(key, None)
|
||||
changed = True
|
||||
if not isinstance(current.get("llm_models"), list):
|
||||
migrated_models: list[dict[str, str]] = []
|
||||
for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")):
|
||||
@@ -56,26 +64,27 @@ class SystemServiceMixin:
|
||||
self.database.save_system_setting("credentials", self.vault.encrypt_json(current))
|
||||
for row in self.database.list_user_credentials():
|
||||
personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or ""))
|
||||
if "tushare_token" in personal:
|
||||
personal.pop("tushare_token", None)
|
||||
if any(key in personal for key in MARKET_SOURCE_SECRET_KEYS):
|
||||
for key in MARKET_SOURCE_SECRET_KEYS:
|
||||
personal.pop(key, None)
|
||||
self.database.save_user_credentials(
|
||||
int(row["user_id"]), self.vault.encrypt_json(personal)
|
||||
)
|
||||
return current
|
||||
|
||||
def _save_system_credentials(self, credentials: dict[str, Any]) -> None:
|
||||
sanitized = {
|
||||
key: value
|
||||
for key, value in credentials.items()
|
||||
if key not in MARKET_SOURCE_SECRET_KEYS
|
||||
}
|
||||
with self.system_lock:
|
||||
self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials))
|
||||
self._system_credentials = dict(credentials)
|
||||
if hasattr(self, "ifind"):
|
||||
self.ifind.set_credentials(
|
||||
str(credentials.get("ifind_refresh_token") or ""),
|
||||
str(credentials.get("ifind_access_token") or ""),
|
||||
)
|
||||
self.database.save_system_setting("credentials", self.vault.encrypt_json(sanitized))
|
||||
self._system_credentials = dict(sanitized)
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.token)
|
||||
return bool(self._datahub_status().get("configured"))
|
||||
|
||||
def _credentials(self) -> dict[str, str]:
|
||||
credentials = getattr(self._request_context, "credentials", {})
|
||||
@@ -99,7 +108,7 @@ class SystemServiceMixin:
|
||||
|
||||
@property
|
||||
def token(self) -> str:
|
||||
return str(self._system_credentials.get("tushare_token") or "")
|
||||
return "datahub" if self.configured else ""
|
||||
|
||||
def system_status(self) -> dict[str, Any]:
|
||||
platform = self._platform_llm_profile()
|
||||
@@ -164,19 +173,6 @@ class SystemServiceMixin:
|
||||
|
||||
def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
current = dict(self._system_credentials)
|
||||
token = str(payload.get("tushare_token") or current.get("tushare_token") or "").strip()
|
||||
if token and not TOKEN_PATTERN.fullmatch(token):
|
||||
raise ValueError("Tushare Token 格式不正确。")
|
||||
ifind_refresh_token = str(
|
||||
payload.get("ifind_refresh_token")
|
||||
or current.get("ifind_refresh_token")
|
||||
or ""
|
||||
).strip()
|
||||
if ifind_refresh_token and (
|
||||
len(ifind_refresh_token) > 2048
|
||||
or any(character.isspace() for character in ifind_refresh_token)
|
||||
):
|
||||
raise ValueError("iFinD Refresh Token 格式不正确。")
|
||||
existing_models = {
|
||||
str(item.get("id") or ""): item
|
||||
for item in current.get("llm_models") or []
|
||||
@@ -238,8 +234,6 @@ class SystemServiceMixin:
|
||||
raise ValueError("会员每日额度应为 1 至 1000。") from exc
|
||||
current.update(
|
||||
{
|
||||
"tushare_token": token,
|
||||
"ifind_refresh_token": ifind_refresh_token,
|
||||
"llm_models": models,
|
||||
"primary_model_id": primary_model_id,
|
||||
"fallback_model_id": fallback_model_id,
|
||||
@@ -259,7 +253,7 @@ class SystemServiceMixin:
|
||||
llm_access = self.llm_access_status()
|
||||
return {
|
||||
"configured": self.configured,
|
||||
"mode": "tushare" if self.configured else "unavailable",
|
||||
"mode": "datahub" if self.configured else "unavailable",
|
||||
"llm_configured": self.llm_configured,
|
||||
"llm_model": self.llm_primary_model if self.llm_configured else "",
|
||||
"llm_fallback_configured": self.llm_fallback_configured,
|
||||
|
||||
Reference in New Issue
Block a user