fix(HEL-494): enforce datahub as sole website market boundary

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总管
2026-09-09 00:22:27 +08:00
co-authored by multica-agent
parent 8a7d1f3698
commit c9e2d30780
22 changed files with 212 additions and 216 deletions
+4 -4
View File
@@ -1,13 +1,13 @@
# Generated automatically when omitted. Back it up together with the database.
APP_ENCRYPTION_KEY=
# Initial shared market-data credential. After first launch it is encrypted into
# the system settings; all accounts use the same backend market snapshot.
# Market-source credentials are consumed and encrypted only by xiaobai-datahub.
# compose.yaml masks them from the xiaobai-review website process.
TUSHARE_TOKEN=your_tushare_token_here
# Official xiaobai-datahub client. Read flags default on in config/datahub.config.json.
# compose.yaml pins every DATAHUB_READ_* to 1 so leftover .env zeros cannot keep
# official pages on the old APIs. Old website APIs are emergency fallback only.
# compose.yaml pins every DATAHUB_READ_* to 1. The website has no provider
# fallback; source selection and failover happen inside xiaobai-datahub.
# DATAHUB_SHADOW_* can still override a single dataset.
DATAHUB_BASE_URL=http://127.0.0.1:8766
DATAHUB_TOKEN=
+8 -3
View File
@@ -38,10 +38,15 @@ background scheduler
fields, and feature-specific exceptions belong to `backend/features/<feature>/routes.py`.
- `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent,
or deterministic calculation code for that product area.
- `backend/data/` owns provider construction, source policy, provenance, units, freshness,
coverage, display-versus-calculation eligibility, and shared numeric normalization policies.
- `backend/data/` owns the website-side DataHub client, stable dataset contracts, provenance,
units, freshness, coverage, display-versus-calculation eligibility, and shared numeric
normalization policies. The website process does not construct or configure external market
providers; provider credentials, source selection, retries, fallbacks, caching, and backfill
belong exclusively to the `xiaobai-datahub` service.
- `backend/data/providers/tushare_client.py` is the stable public `TushareClient` facade and
owns only its dataclass fields and shared cache state. Tushare HTTP transport belongs to
is retained as the dataset-contract compatibility surface and isolated test facade. Production
website services never instantiate it directly: its query methods are served by the DataHub
proxy. Its split modules document the stable contract: Tushare HTTP transport belongs to
`tushare_transport.py`; market overview and realtime breadth belong to
`tushare_dashboard.py`; indices belong to `tushare_indices.py`; Shenwan membership and
industry snapshots belong to `tushare_industries.py`; generic sector snapshots belong to
+7 -4
View File
@@ -22,9 +22,10 @@ xiaobai-review 容器 :8765
`-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed
```
账号、加密后的公共数据 Token、平台模型 API Key、生辰资料、行情快照和复盘数据均在
`data/review.db`。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与
密钥必须成对备份,任意一个丢失都无法恢复账号内的加密资料。
账号、平台模型 API Key、生辰资料、行情快照和复盘数据均在 `data/review.db`。外部行情源
Token 只允许保存在 `xiaobai-datahub` 的环境或凭据库,网站进程不读取、不保存,也不向
提供方直接发请求。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与密钥必须成对
备份,任意一个丢失都无法恢复账号内的加密资料。
问天静态知识文件:
@@ -41,7 +42,9 @@ xiaobai-review 容器 :8765
挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill
只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。
首个注册账号自动成为管理员。管理员在“系统管理”中配置全站共享行情、后台刷新、平台会员模型及手动会员;普通用户的“账号设置”用于个人资料、会员状态、修改密码和切换账号。后台行情更新不会主动刷新任何浏览器页面。
首个注册账号自动成为管理员。管理员在网站“系统管理”中查看数据中枢状态并配置后台刷新、
平台会员模型及手动会员;行情源凭据和调度策略在数据中枢后台统一管理。普通用户的“账号设置”
用于个人资料、会员状态、修改密码和切换账号。后台行情更新不会主动刷新任何浏览器页面。
## 2. 服务器要求
-21
View File
@@ -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
+1 -3
View File
@@ -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(
-3
View File
@@ -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(),
-9
View File
@@ -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:
-23
View File
@@ -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:
-4
View File
@@ -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)
+11 -14
View File
@@ -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}"
+25 -31
View File
@@ -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,
+4
View File
@@ -13,6 +13,10 @@ services:
- ./.env
environment:
APP_ENCRYPTION_KEY: "${APP_ENCRYPTION_KEY:?APP_ENCRYPTION_KEY must be set in .env}"
# Provider credentials are consumed only by xiaobai-datahub.
TUSHARE_TOKEN: ""
IFIND_REFRESH_TOKEN: ""
IFIND_ACCESS_TOKEN: ""
DATAHUB_BASE_URL: "${DATAHUB_BASE_URL:-http://192.168.200.11:8766}"
DATAHUB_READ_CALENDAR: "1"
DATAHUB_READ_STOCKS: "1"
+16 -16
View File
@@ -478,7 +478,7 @@
},
{
"path": "frontend/index.html",
"bytes": 48447,
"bytes": 48403,
"lines": 665
},
{
@@ -546,11 +546,6 @@
"bytes": 15743,
"lines": 401
},
{
"path": "frontend/shared/admin.js",
"bytes": 15235,
"lines": 289
},
{
"path": "frontend/shared/dashboard.js",
"bytes": 15063,
@@ -561,6 +556,11 @@
"bytes": 14942,
"lines": 235
},
{
"path": "frontend/shared/admin.js",
"bytes": 14836,
"lines": 283
},
{
"path": "backend/features/screener/data_sync.py",
"bytes": 14743,
@@ -576,16 +576,16 @@
"bytes": 13219,
"lines": 289
},
{
"path": "backend/features/system/service.py",
"bytes": 12937,
"lines": 271
},
{
"path": "backend/features/market/insights_auction_data.py",
"bytes": 12829,
"lines": 318
},
{
"path": "backend/features/system/service.py",
"bytes": 12180,
"lines": 265
},
{
"path": "backend/features/market/insights_auction.py",
"bytes": 10717,
@@ -636,11 +636,6 @@
"bytes": 8357,
"lines": 116
},
{
"path": "backend/application.py",
"bytes": 6997,
"lines": 182
},
{
"path": "backend/features/screener/formula.py",
"bytes": 6983,
@@ -661,6 +656,11 @@
"bytes": 6488,
"lines": 81
},
{
"path": "backend/application.py",
"bytes": 6399,
"lines": 161
},
{
"path": "frontend/pages/market/stock-detail.js",
"bytes": 6325,
@@ -320,6 +320,11 @@ PC端统一采用以下固定骨架:
### 6.1 数据源职责
运行边界:下表中的职责全部由独立的 `xiaobai-datahub` 数据中枢执行。主网站只按固定业务
协议请求“行情、日K、分时、申万、竞价”等数据,不接触任何提供方参数或凭据,也不决定优先级、
重试、降级和回填。数据中枢是主网站唯一的行情出口;中枢暂时取不到新数据时,网站只能读取
已经归档的真实快照,不能绕回旧提供方直连接口。
| 数据源 | 可用于正式计算 | 主要职责 |
|---|:---:|---|
| Tushare | 是 | 交易日历、股票主表、日线、估值、财务、资金流、申万行业、涨跌停、9:25竞价、热榜、龙虎榜 |
+3 -3
View File
@@ -607,13 +607,13 @@
<div class="admin-panel" data-admin-panel="market">
<form id="systemMarketForm" class="settings-section">
<div class="settings-section-heading"><h3>公共行情</h3><span id="systemDataStatus">待检查</span></div>
<label class="form-field"><span>Tushare Token</span><input id="systemTokenInput" type="password" autocomplete="off" minlength="20" placeholder="留空保留现有 Token"></label>
<label class="form-field"><span>iFinD Refresh Token</span><input id="systemIfindTokenInput" type="password" autocomplete="off" maxlength="2048" placeholder="留空保留现有 Token"></label>
<label class="form-field"><span>行情来源凭据</span><input id="systemTokenInput" type="text" value="请在数据中枢后台统一管理" disabled></label>
<label class="form-field"><span>实时来源凭据</span><input id="systemIfindTokenInput" type="text" value="请在数据中枢后台统一管理" disabled></label>
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
<p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>
<div id="datahubRouteStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="database"></i><span>数据中枢线路待检查</span></div>
<div id="adminRefreshStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="circle-dot"></i><span>尚未手动刷新</span></div>
<div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存行情配</button></div>
<div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存刷新设</button></div>
</form>
<section class="settings-section">
<div class="settings-section-heading"><h3>历史数据回补</h3><span>管理员任务</span></div>
+7 -11
View File
@@ -5242,16 +5242,16 @@
'<div class="m-sys-body" data-system-admin-panel="market">' +
'<div class="m-card m-sys-section"><strong>数据源状态</strong>' +
'<div class="m-sys-status-list">' +
'<div class="m-sys-status-item"><span>Tushare</span><span>' + statusDot(data.configured) + (data.configured ? " 已配置" : " 未配置") + "</span></div>" +
'<div class="m-sys-status-item"><span>数据中枢</span><span>' + statusDot(data.configured) + (data.configured ? " 已连接" : " 未连接") + "</span></div>" +
'<div class="m-sys-status-item"><span>iFinD</span><span>' + statusDot(ifind.configured) + (ifind.configured ? " 已配置" : " 未配置") + "</span></div>" +
'<div class="m-sys-status-item"><span>行情快照</span><strong>' + number(data.snapshot_dates) + " 个交易日</strong></div>" +
'<div class="m-sys-status-item"><span>后台刷新</span><span>' + statusDot(data.background_refresh_enabled) + (data.background_refresh_enabled ? " 已启用" : " 已暂停") + "</span></div>" +
'<div class="m-sys-status-item"><span>数据中枢</span><span>' + statusDot(Boolean((data.datahub || {}).configured) && !((data.datahub || {}).fallback_count)) + datahubStatusText(data.datahub || {}) + "</span></div>" +
"</div></div>" +
'<div class="m-card m-sys-section"><strong>数据源密钥</strong>' +
formFieldHtml("Tushare Token", '<input id="m-sys-token" type="password" autocomplete="off" minlength="20" placeholder="留空则保留现有 Token">', false) +
formFieldHtml("iFinD Refresh Token", '<input id="m-sys-ifind" type="password" autocomplete="off" maxlength="2048" placeholder="留空则保留现有 Token">', false) +
'<button class="m-btn-primary" type="button" data-system-save-market>保存密钥</button></div>' +
formFieldHtml("行情来源凭据", '<input id="m-sys-token" type="text" value="请在数据中枢后台统一管理" disabled>', false) +
formFieldHtml("实时来源凭据", '<input id="m-sys-ifind" type="text" value="请在数据中枢后台统一管理" disabled>', false) +
'<button class="m-btn-primary" type="button" data-system-save-market>刷新状态</button></div>' +
'<div class="m-card m-sys-section"><strong>后台刷新</strong>' +
'<div class="m-sys-switch-row"><div><strong>交易时段自动刷新</strong><p class="m-sys-hint">开启后后台定时更新快照</p></div>' +
'<button class="m-theme-switch" type="button" data-system-toggle-refresh role="switch" aria-checked="' + (data.background_refresh_enabled ? "true" : "false") + '" aria-label="交易时段自动刷新"><span class="m-theme-switch-thumb"></span></button></div>' +
@@ -5550,14 +5550,10 @@
function saveSystemMarket() {
const button = document.querySelector("[data-system-save-market]");
if (button) button.disabled = true;
global.MobileAPI.request("/api/admin/settings", "POST", {
tushare_token: ((document.getElementById("m-sys-token") || {}).value || "").trim(),
ifind_refresh_token: ((document.getElementById("m-sys-ifind") || {}).value || "").trim(),
}).then(function () {
showToast("行情密钥已保存");
loadSystem();
loadSystem().then(function () {
showToast("数据中枢状态已刷新");
}).catch(function (error) {
showToast(error && error.message ? error.message : "系统配置保存失败");
showToast(error && error.message ? error.message : "数据中枢状态读取失败");
}).then(function () {
if (button) button.disabled = false;
});
+3 -9
View File
@@ -41,12 +41,10 @@ async function openAdminSettings(refreshOnly = false) {
const ifind = data.ifind || {};
const llm = payload.llm || {};
const membership = payload.membership || {};
status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
status.textContent = `数据中枢 ${data.configured ? "已连接" : "未连接"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
status.classList.toggle("connected", Boolean(data.configured));
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
renderDatahubRouteStatus(data.datahub || {});
document.querySelector("#systemTokenInput").value = "";
document.querySelector("#systemIfindTokenInput").value = "";
document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled);
document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50;
renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || "");
@@ -72,7 +70,7 @@ function renderDatahubRouteStatus(hub) {
if (label) {
label.textContent = hub.configured
? `数据中枢主线路 ${enabled}/${total},当前无备用`
: "数据中枢未配置,网站仍走原接口";
: "数据中枢未配置,网站只保留已有真实快照";
}
}
@@ -205,13 +203,9 @@ async function saveMarketSettings(event) {
button.disabled = true;
try {
await apiRequest("/api/admin/settings", "POST", {
tushare_token: document.querySelector("#systemTokenInput").value.trim(),
ifind_refresh_token: document.querySelector("#systemIfindTokenInput").value.trim(),
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
});
document.querySelector("#systemTokenInput").value = "";
document.querySelector("#systemIfindTokenInput").value = "";
showToast("行情配置已保存");
showToast("行情刷新设置已保存");
await openAdminSettings(true);
} catch (error) {
showToast(error.message || "系统配置保存失败");
+3 -3
View File
@@ -10,7 +10,7 @@ from database import ReviewDatabase
class BootstrapContainerTests(unittest.TestCase):
def test_environment_credentials_preserve_legacy_model_fallbacks(self) -> None:
def test_environment_credentials_exclude_provider_secrets_and_preserve_llm_fallbacks(self) -> None:
result = environment_credentials(
{
"TUSHARE_TOKEN": " tushare ",
@@ -20,8 +20,8 @@ class BootstrapContainerTests(unittest.TestCase):
"LLM_MODEL": "legacy-model",
}
)
self.assertEqual(result["tushare_token"], "tushare")
self.assertEqual(result["ifind_refresh_token"], "refresh")
self.assertNotIn("tushare_token", result)
self.assertNotIn("ifind_refresh_token", result)
self.assertEqual(result["platform_llm_primary_api_key"], "legacy-key")
self.assertEqual(result["platform_llm_primary_base_url"], "https://legacy.example/v1")
self.assertEqual(result["platform_llm_primary_model"], "legacy-model")
+10 -4
View File
@@ -12,6 +12,7 @@ from backend.data import (
QualityEvidence,
build_data_gateway,
)
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
from backend.data.quality import market_timezone
@@ -36,9 +37,14 @@ class DataGatewayTests(unittest.TestCase):
policy.assert_allowed("market.level2", "unresolved", "display")
def test_gateway_uses_hub_facade_and_proxies(self) -> None:
settings = DatahubSettings(
base_url="http://127.0.0.1:8766",
token="hub-token",
datasets={name: DatasetFlags(name, read=True) for name in DATASETS},
)
gateway = build_data_gateway(
{"ifind_refresh_token": "refresh", "ifind_access_token": "access"},
lambda: "must-not-be-used",
{},
datahub_settings=settings,
)
client = gateway.tushare()
self.assertEqual(client.token, "datahub")
@@ -63,7 +69,7 @@ class DataGatewayTests(unittest.TestCase):
/ "market"
/ "service.py"
).read_text(encoding="utf-8")
self.assertEqual(source.count("TushareClient(self.token)"), 1)
self.assertNotIn("TushareClient(self.token)", source)
self.assertIn("return gateway.tushare()", source)
def test_provider_construction_has_unique_declared_owners(self) -> None:
@@ -71,7 +77,7 @@ class DataGatewayTests(unittest.TestCase):
owners = {
"IfindProvider": {"backend/data/gateway.py"},
"MarketChartClient": {"backend/data/gateway.py"},
"TushareClient": {"backend/features/market/service.py"},
"TushareClient": set(),
"DatahubClient": {"backend/data/gateway.py"},
"DatahubAwareTushareClient": {"backend/data/gateway.py"},
"DatahubBridge": {"backend/data/gateway.py"},
+72 -17
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import ast
import json
import re
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
@@ -15,6 +16,7 @@ from backend.data.datahub.realtime_proxy import HubRealtimeProxy
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
from backend.data.providers.tushare_transport import TushareError
from backend.features.market.charts import MarketChartClient
from backend.features.system.service import SystemServiceMixin
from tests.test_datahub_bridge import FakeClient, FakeLegacy, flags
@@ -220,6 +222,63 @@ def blocked_urlopen(request, timeout=None):
class HubExclusiveWebsiteTests(unittest.TestCase):
def test_website_availability_depends_on_hub_not_provider_credentials(self) -> None:
service = SystemServiceMixin()
service._system_credentials = {}
service.data_gateway = build_data_gateway({}, datahub_settings=_enabled_settings())
self.assertTrue(service.configured)
self.assertEqual(service.token, "datahub")
def test_website_vault_scrubs_historical_provider_credentials(self) -> None:
class Vault:
@staticmethod
def decrypt_json(payload):
if isinstance(payload, str):
return json.loads(payload)
return dict(payload)
@staticmethod
def encrypt_json(payload):
return dict(payload)
class Database:
system = {
"tushare_token": "old-tushare",
"ifind_refresh_token": "old-refresh",
"ifind_access_token": "old-access",
}
personal = {"tushare_token": "old-personal", "llm_primary_model": "model"}
def get_system_setting(self, _name):
return dict(self.system)
@staticmethod
def first_user_id():
return 1
def get_user_credentials(self, _user_id):
return dict(self.personal)
@staticmethod
def list_user_credentials():
return [{"user_id": 1, "encrypted_payload": '{"tushare_token":"old-personal"}'}]
def save_system_setting(self, _name, payload):
self.system = dict(payload)
def save_user_credentials(self, _user_id, payload):
self.personal = dict(payload)
service = SystemServiceMixin()
service.database = Database()
service.vault = Vault()
service.system_lock = threading.Lock()
loaded = service._load_system_credentials({})
for key in ("tushare_token", "ifind_refresh_token", "ifind_access_token"):
self.assertNotIn(key, loaded)
self.assertNotIn(key, service.database.system)
self.assertNotIn(key, service.database.personal)
def test_query_never_calls_website_tushare_transport(self) -> None:
client = FakeClient()
legacy = FakeLegacy(TushareError("website tushare must stay dark"))
@@ -340,7 +399,6 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
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)
@@ -349,23 +407,20 @@ class HubExclusiveWebsiteTests(unittest.TestCase):
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 test_website_does_not_accept_or_forward_provider_credentials(self) -> None:
proxy = HubIfindProxy(DatahubBridge(_enabled_settings(), FakeClient()))
self.assertFalse(hasattr(proxy, "set_credentials"))
client_source = (ROOT / "backend" / "data" / "datahub" / "client.py").read_text(encoding="utf-8")
application_source = (ROOT / "backend" / "application.py").read_text(encoding="utf-8")
self.assertNotIn("/v1/credentials", client_source)
self.assertNotIn("ifind_refresh_token", application_source)
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_site_configuration_depends_on_hub_not_provider_credentials(self) -> None:
service = SystemServiceMixin()
service._system_credentials = {}
service.data_gateway = build_data_gateway({}, datahub_settings=_enabled_settings())
self.assertTrue(service.configured)
self.assertEqual(service.token, "datahub")
def test_compose_passes_ifind_env_to_hub(self) -> None:
overlay = (ROOT / "compose.datahub.yaml").read_text(encoding="utf-8")
+1 -1
View File
@@ -58,7 +58,7 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
"data-system-edit-model",
"data-system-open-member",
"管理员专区",
"保存密钥",
"刷新状态",
"保存分工",
'location.assign("/login/")',
):
+32 -33
View File
@@ -53,6 +53,18 @@ class RealtimeClientStub:
}
class DataGatewayStub:
def __init__(self, client):
self.client = client
def tushare(self):
return self.client
@staticmethod
def datahub_status():
return {"configured": True}
class FixedMarketDatetime(datetime):
fixed_now = datetime(2026, 7, 31, 10, 30).astimezone()
@@ -195,6 +207,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
self.service.ifind = None
self.service.realtime_aggregator = None
self.service.chart_data = None
self.service._market_client_override = RealtimeClientStub("datahub")
self.service.data_gateway = DataGatewayStub(self.service._market_client_override)
RealtimeClientStub.quote_calls = 0
DeniedRealtimeClientStub.quote_calls = 0
@@ -218,9 +232,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
"moneyflow": {},
}
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
"backend.features.market.service.TushareClient", RealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(cached, "002141", today)
self.assertEqual(result["meta"]["trade_date"], FixedMarketDatetime.fixed_now.strftime("%Y-%m-%d"))
@@ -240,9 +252,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
"stock": {"code": "002141", "price": 10, "change": 1.2},
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
}
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
"backend.features.market.service.TushareClient", RealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(payload, "002141", historical)
self.assertEqual(result["stock"]["change"], 1.2)
@@ -279,9 +289,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
},
],
}
with patch("backend.features.market.service.datetime", FixedPreopenDatetime), patch(
"backend.features.market.service.TushareClient", RealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedPreopenDatetime):
result = self.service._prepare_stock_detail(payload, "002141", today)
self.assertEqual(result["meta"]["trade_date"], yesterday)
@@ -294,11 +302,10 @@ class StockDetailRealtimeTests(unittest.TestCase):
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
aggregator = FreeQuoteAggregator(_free_quote())
self.service.realtime_aggregator = aggregator
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
bar = result["prices"][-1]
@@ -327,11 +334,10 @@ class StockDetailRealtimeTests(unittest.TestCase):
),
)
self.service.realtime_aggregator = aggregator
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(_history_payload("600000"), "600000", today)
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
@@ -365,9 +371,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
},
]
)
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(_history_payload("300750"), "300750", today)
bar = result["prices"][-1]
@@ -384,11 +388,10 @@ class StockDetailRealtimeTests(unittest.TestCase):
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
self.service.realtime_aggregator = FreeQuoteAggregator(fail=True)
self.service.chart_data = IntradayChartStub([], trade_date="2026-07-30")
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-30")
@@ -401,10 +404,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
self.service.realtime_aggregator = FreeQuoteAggregator(
_free_quote(quote_time_epoch=int(datetime(2026, 7, 31, 11, 30).timestamp()))
)
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedLunchDatetime), patch(
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedLunchDatetime):
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
@@ -413,10 +415,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
def test_after_close_keeps_forming_bar_until_official_ready(self):
today = FixedAfterCloseDatetime.fixed_now.strftime("%Y%m%d")
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime), patch(
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime):
forming = self.service._prepare_stock_detail(_history_payload(), "002141", today)
self.assertEqual(forming["prices"][-1]["trade_date"], "2026-07-31")
self.assertTrue(forming["prices"][-1]["realtime"])
@@ -435,9 +436,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
}
)
RealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime), patch(
"backend.features.market.service.TushareClient", RealtimeClientStub
):
self.service._market_client_override = RealtimeClientStub("datahub")
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime):
replaced = self.service._prepare_stock_detail(official, "002141", today)
self.assertEqual(replaced["prices"][-1]["close"], 10.7)
@@ -462,10 +462,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
}
)
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
DeniedRealtimeClientStub.quote_calls = 0
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
):
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
result = self.service._prepare_stock_detail(payload, "002141", today)
self.assertEqual(len(result["prices"]), 2)