diff --git a/.env.example b/.env.example index efbd904..b869041 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 809bc8d..c18156e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -38,10 +38,15 @@ background scheduler fields, and feature-specific exceptions belong to `backend/features//routes.py`. - `backend/features//` 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 diff --git a/DOCKER_DEPLOY.md b/DOCKER_DEPLOY.md index 3e22a97..ffbd071 100644 --- a/DOCKER_DEPLOY.md +++ b/DOCKER_DEPLOY.md @@ -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. 服务器要求 diff --git a/backend/application.py b/backend/application.py index 1babcab..98ab9eb 100644 --- a/backend/application.py +++ b/backend/application.py @@ -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 diff --git a/backend/bootstrap/container.py b/backend/bootstrap/container.py index fd43561..7984a4b 100644 --- a/backend/bootstrap/container.py +++ b/backend/bootstrap/container.py @@ -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( diff --git a/backend/bootstrap/settings.py b/backend/bootstrap/settings.py index 80e952a..523520d 100644 --- a/backend/bootstrap/settings.py +++ b/backend/bootstrap/settings.py @@ -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(), diff --git a/backend/data/datahub/client.py b/backend/data/datahub/client.py index 496597d..5d04aa1 100644 --- a/backend/data/datahub/client.py +++ b/backend/data/datahub/client.py @@ -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: diff --git a/backend/data/datahub/ifind_proxy.py b/backend/data/datahub/ifind_proxy.py index 0e6c061..d7d8178 100644 --- a/backend/data/datahub/ifind_proxy.py +++ b/backend/data/datahub/ifind_proxy.py @@ -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: diff --git a/backend/data/gateway.py b/backend/data/gateway.py index 0d01b2f..8f349e1 100644 --- a/backend/data/gateway.py +++ b/backend/data/gateway.py @@ -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) diff --git a/backend/features/market/service.py b/backend/features/market/service.py index 51391b8..d4b5372 100644 --- a/backend/features/market/service.py +++ b/backend/features/market/service.py @@ -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}" diff --git a/backend/features/system/service.py b/backend/features/system/service.py index 267c618..766bdeb 100644 --- a/backend/features/system/service.py +++ b/backend/features/system/service.py @@ -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, diff --git a/compose.yaml b/compose.yaml index 934fcc2..71a11a0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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" diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index 0e8c63e..70b24ee 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -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, diff --git a/docs/product/小白复盘-完整产品规格说明书.md b/docs/product/小白复盘-完整产品规格说明书.md index 92e9903..f6a7e3e 100644 --- a/docs/product/小白复盘-完整产品规格说明书.md +++ b/docs/product/小白复盘-完整产品规格说明书.md @@ -320,6 +320,11 @@ PC端统一采用以下固定骨架: ### 6.1 数据源职责 +运行边界:下表中的职责全部由独立的 `xiaobai-datahub` 数据中枢执行。主网站只按固定业务 +协议请求“行情、日K、分时、申万、竞价”等数据,不接触任何提供方参数或凭据,也不决定优先级、 +重试、降级和回填。数据中枢是主网站唯一的行情出口;中枢暂时取不到新数据时,网站只能读取 +已经归档的真实快照,不能绕回旧提供方直连接口。 + | 数据源 | 可用于正式计算 | 主要职责 | |---|:---:|---| | Tushare | 是 | 交易日历、股票主表、日线、估值、财务、资金流、申万行业、涨跌停、9:25竞价、热榜、龙虎榜 | diff --git a/frontend/index.html b/frontend/index.html index fc7f787..f7059a5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -607,13 +607,13 @@

公共行情

待检查
- - + +

所有用户读取同一份后台快照,页面不会随后台任务自动重绘。

数据中枢线路待检查
尚未手动刷新
-
+

历史数据回补

管理员任务
diff --git a/frontend/m/js/pages.js b/frontend/m/js/pages.js index 9d105b2..3176090 100644 --- a/frontend/m/js/pages.js +++ b/frontend/m/js/pages.js @@ -5242,16 +5242,16 @@ '
' + '
数据源状态' + '
' + - '
Tushare' + statusDot(data.configured) + (data.configured ? " 已配置" : " 未配置") + "
" + + '
数据中枢' + statusDot(data.configured) + (data.configured ? " 已连接" : " 未连接") + "
" + '
iFinD' + statusDot(ifind.configured) + (ifind.configured ? " 已配置" : " 未配置") + "
" + '
行情快照' + number(data.snapshot_dates) + " 个交易日
" + '
后台刷新' + statusDot(data.background_refresh_enabled) + (data.background_refresh_enabled ? " 已启用" : " 已暂停") + "
" + '
数据中枢' + statusDot(Boolean((data.datahub || {}).configured) && !((data.datahub || {}).fallback_count)) + datahubStatusText(data.datahub || {}) + "
" + "
" + '
数据源密钥' + - formFieldHtml("Tushare Token", '', false) + - formFieldHtml("iFinD Refresh Token", '', false) + - '
' + + formFieldHtml("行情来源凭据", '', false) + + formFieldHtml("实时来源凭据", '', false) + + '
' + '
后台刷新' + '
交易时段自动刷新

开启后后台定时更新快照

' + '
' + @@ -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; }); diff --git a/frontend/shared/admin.js b/frontend/shared/admin.js index ca33f1b..012178e 100644 --- a/frontend/shared/admin.js +++ b/frontend/shared/admin.js @@ -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 || "系统配置保存失败"); diff --git a/tests/test_bootstrap_container.py b/tests/test_bootstrap_container.py index 667ba0a..3be8e44 100644 --- a/tests/test_bootstrap_container.py +++ b/tests/test_bootstrap_container.py @@ -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") diff --git a/tests/test_data_gateway.py b/tests/test_data_gateway.py index 8c82d84..ed773f2 100644 --- a/tests/test_data_gateway.py +++ b/tests/test_data_gateway.py @@ -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"}, diff --git a/tests/test_hub_exclusive.py b/tests/test_hub_exclusive.py index faf9b57..3e1a4c3 100644 --- a/tests/test_hub_exclusive.py +++ b/tests/test_hub_exclusive.py @@ -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") diff --git a/tests/test_mobile_system_pages.py b/tests/test_mobile_system_pages.py index c634254..bca1694 100644 --- a/tests/test_mobile_system_pages.py +++ b/tests/test_mobile_system_pages.py @@ -58,7 +58,7 @@ class MobileSystemPagesRegressionTests(unittest.TestCase): "data-system-edit-model", "data-system-open-member", "管理员专区", - "保存密钥", + "刷新状态", "保存分工", 'location.assign("/login/")', ): diff --git a/tests/test_stock_detail_realtime.py b/tests/test_stock_detail_realtime.py index 15b4a8e..5f928eb 100644 --- a/tests/test_stock_detail_realtime.py +++ b/tests/test_stock_detail_realtime.py @@ -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)