Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbcab3064c | ||
|
|
b402b3b2a8 | ||
|
|
3eaa36a8d5 | ||
|
|
3203574b6a | ||
|
|
35ee43ea02 | ||
|
|
78931f9c42 | ||
|
|
6f99ee9d9e | ||
|
|
9b2f0993d3 | ||
|
|
3bafd30aad | ||
|
|
d9358ab377 | ||
|
|
31da37b49e | ||
|
|
2e9442ac5a | ||
|
|
ad70d8fccc | ||
|
|
4a90c32fcc | ||
|
|
5385bad9ec | ||
|
|
955785b9fd | ||
|
|
88411efa1c | ||
|
|
c3881af708 | ||
|
|
5f343a6bae | ||
|
|
abee8306be | ||
|
|
cb45d742da | ||
|
|
16843bdc7e | ||
|
|
b640e77264 | ||
|
|
b950ea4344 | ||
|
|
41f8509a98 | ||
|
|
c9e2d30780 | ||
|
|
8a7d1f3698 | ||
|
|
100752f43c | ||
|
|
0b8419abca | ||
|
|
ef13d6feb5 | ||
|
|
b5d65ecb41 | ||
|
|
3e828b346c |
+11
-7
@@ -1,20 +1,24 @@
|
|||||||
# Generated automatically when omitted. Back it up together with the database.
|
# Generated automatically when omitted. Back it up together with the database.
|
||||||
APP_ENCRYPTION_KEY=
|
APP_ENCRYPTION_KEY=
|
||||||
|
|
||||||
# Initial shared market-data credential. After first launch it is encrypted into
|
# Market-source credentials are consumed and encrypted only by xiaobai-datahub.
|
||||||
# the system settings; all accounts use the same backend market snapshot.
|
# compose.yaml masks them from the xiaobai-review website process.
|
||||||
TUSHARE_TOKEN=your_tushare_token_here
|
TUSHARE_TOKEN=your_tushare_token_here
|
||||||
|
|
||||||
# Official xiaobai-datahub client. Read flags default on in config/datahub.config.json.
|
# 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
|
# compose.yaml pins every DATAHUB_READ_* to 1. The website has no provider
|
||||||
# official pages on the old APIs. Old website APIs are emergency fallback only.
|
# fallback; source selection and failover happen inside xiaobai-datahub.
|
||||||
# DATAHUB_SHADOW_* can still override a single dataset.
|
# DATAHUB_SHADOW_* can still override a single dataset.
|
||||||
DATAHUB_BASE_URL=http://127.0.0.1:8766
|
DATAHUB_BASE_URL=http://127.0.0.1:8766
|
||||||
DATAHUB_TOKEN=
|
DATAHUB_TOKEN=
|
||||||
|
|
||||||
# Optional iFinD HTTP credential. The backend exchanges it for a short-lived
|
# 数据中枢控制台(8766)用它调用 /api/hub-admin/* 校验主站会话、读写模型池与
|
||||||
# access token and never exposes either token to browsers.
|
# 会员/邀请码。两个服务必须填同一个值;32+ 随机字节,未设置则桥接直接拒绝。
|
||||||
IFIND_REFRESH_TOKEN=your_ifind_refresh_token_here
|
HUB_ADMIN_TOKEN=
|
||||||
|
|
||||||
|
# iFinD credentials live on xiaobai-datahub, not the website process.
|
||||||
|
# IFIND_REFRESH_TOKEN=your_ifind_refresh_token_here
|
||||||
|
# IFIND_ACCESS_TOKEN=
|
||||||
|
|
||||||
# Initial platform member models (OpenAI-compatible). After first launch these
|
# Initial platform member models (OpenAI-compatible). After first launch these
|
||||||
# are encrypted into system settings and used only by admins and active members.
|
# are encrypted into system settings and used only by admins and active members.
|
||||||
|
|||||||
+8
-3
@@ -38,10 +38,15 @@ background scheduler
|
|||||||
fields, and feature-specific exceptions belong to `backend/features/<feature>/routes.py`.
|
fields, and feature-specific exceptions belong to `backend/features/<feature>/routes.py`.
|
||||||
- `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent,
|
- `backend/features/<feature>/` owns the mechanically moved service, repository, HTTP, agent,
|
||||||
or deterministic calculation code for that product area.
|
or deterministic calculation code for that product area.
|
||||||
- `backend/data/` owns provider construction, source policy, provenance, units, freshness,
|
- `backend/data/` owns the website-side DataHub client, stable dataset contracts, provenance,
|
||||||
coverage, display-versus-calculation eligibility, and shared numeric normalization policies.
|
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
|
- `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_transport.py`; market overview and realtime breadth belong to
|
||||||
`tushare_dashboard.py`; indices belong to `tushare_indices.py`; Shenwan membership and
|
`tushare_dashboard.py`; indices belong to `tushare_indices.py`; Shenwan membership and
|
||||||
industry snapshots belong to `tushare_industries.py`; generic sector snapshots belong to
|
industry snapshots belong to `tushare_industries.py`; generic sector snapshots belong to
|
||||||
|
|||||||
+22
-4
@@ -22,9 +22,10 @@ xiaobai-review 容器 :8765
|
|||||||
`-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed
|
`-- heaven_knowledge.json 优先读取;缺失时回退到上方 seed
|
||||||
```
|
```
|
||||||
|
|
||||||
账号、加密后的公共数据 Token、平台模型 API Key、生辰资料、行情快照和复盘数据均在
|
账号、平台模型 API Key、生辰资料、行情快照和复盘数据均在 `data/review.db`。外部行情源
|
||||||
`data/review.db`。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与
|
Token 只允许保存在 `xiaobai-datahub` 的环境或凭据库,网站进程不读取、不保存,也不向
|
||||||
密钥必须成对备份,任意一个丢失都无法恢复账号内的加密资料。
|
提供方直接发请求。解密密钥来自 `.env` 中的 `APP_ENCRYPTION_KEY`。数据库与密钥必须成对
|
||||||
|
备份,任意一个丢失都无法恢复账号内的加密资料。
|
||||||
|
|
||||||
问天静态知识文件:
|
问天静态知识文件:
|
||||||
|
|
||||||
@@ -41,7 +42,9 @@ xiaobai-review 容器 :8765
|
|||||||
挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill
|
挂载进入容器,但被 Git 与 Docker 构建上下文排除,不会进入 Gitea 或镜像。私有 Skill
|
||||||
只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。
|
只对管理员账号返回和开放调用,也会随本指南的 `data` 备份一起保存。
|
||||||
|
|
||||||
首个注册账号自动成为管理员。管理员在“系统管理”中配置全站共享行情、后台刷新、平台会员模型及手动会员;普通用户的“账号设置”用于个人资料、会员状态、修改密码和切换账号。后台行情更新不会主动刷新任何浏览器页面。
|
首个注册账号自动成为管理员。管理员在网站“系统管理”中查看数据中枢状态并配置后台刷新、
|
||||||
|
平台会员模型及手动会员;行情源凭据和调度策略在数据中枢后台统一管理。普通用户的“账号设置”
|
||||||
|
用于个人资料、会员状态、修改密码和切换账号。后台行情更新不会主动刷新任何浏览器页面。
|
||||||
|
|
||||||
## 2. 服务器要求
|
## 2. 服务器要求
|
||||||
|
|
||||||
@@ -271,3 +274,18 @@ docker compose restart xiaobai-review
|
|||||||
当前部署使用局域网 HTTP,账号密码和会话只适合可信内网使用。不要直接将
|
当前部署使用局域网 HTTP,账号密码和会话只适合可信内网使用。不要直接将
|
||||||
`8765` 暴露到互联网。以后需要公网访问时,应在容器前增加 Caddy 或 Nginx,
|
`8765` 暴露到互联网。以后需要公网访问时,应在容器前增加 Caddy 或 Nginx,
|
||||||
启用 HTTPS,并限制可信来源。
|
启用 HTTPS,并限制可信来源。
|
||||||
|
|
||||||
|
### 数据中枢控制台(8766)
|
||||||
|
|
||||||
|
数据中枢是管理员的统一配置入口(数据源凭证、模型池、会员与邀请码),叠加
|
||||||
|
`compose.datahub.yaml` 部署,容器名 `xiaobai-datahub`:
|
||||||
|
|
||||||
|
- 它没有独立账号,用主站管理员账号进入;未登录会跳主站登录页,非管理员一律 403。
|
||||||
|
每个页面与接口都在服务端校验,前端隐藏不作为权限依据。
|
||||||
|
- 两侧 `.env` 的 `HUB_ADMIN_TOKEN` 必须填成同一个随机值(服务间桥接令牌)。缺失或
|
||||||
|
不一致时控制台无法校验会话,页面会停在门禁面板。
|
||||||
|
- `REVIEW_BASE_URL` 是中枢访问主站的地址(同一 compose 网络内用服务名
|
||||||
|
`http://xiaobai-review:8765`);`REVIEW_PUBLIC_URL` 是浏览器可达的主站地址,
|
||||||
|
留空则按当前主机名推导。
|
||||||
|
- 会话 cookie 靠"同主机不同端口"共享,因此主站与中枢必须对浏览器暴露在同一主机名下;
|
||||||
|
8766 与 8765 同样只在可信内网开放。
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ python server.py
|
|||||||
|
|
||||||
默认监听 `127.0.0.1:8765`(仅本机可访问)。浏览器打开该地址,首次使用先注册账号;第一个账号自动成为管理员,之后注册的默认为普通用户。
|
默认监听 `127.0.0.1:8765`(仅本机可访问)。浏览器打开该地址,首次使用先注册账号;第一个账号自动成为管理员,之后注册的默认为普通用户。
|
||||||
|
|
||||||
|
第一个账号之外的注册都必须填写一次性邀请码:管理员在数据中枢「会员管理」里生成,一个码只能成功注册一次,已使用或已作废的码不再可用。
|
||||||
|
|
||||||
主行情不再回退演示数据:盘前、非交易日或临时取数失败时沿用最近真实收盘快照;没有任何真实快照时,页面会提示等待管理员完成首次同步。
|
主行情不再回退演示数据:盘前、非交易日或临时取数失败时沿用最近真实收盘快照;没有任何真实快照时,页面会提示等待管理员完成首次同步。
|
||||||
|
|
||||||
可选参数:
|
可选参数:
|
||||||
@@ -115,9 +117,11 @@ compose.yaml
|
|||||||
.env.example 环境变量模板(复制为 .env 后填写)
|
.env.example 环境变量模板(复制为 .env 后填写)
|
||||||
```
|
```
|
||||||
|
|
||||||
管理员通过页面右上角「系统管理」保存公共 Tushare Token、平台主/辅助模型、会员每日额度和后台刷新开关。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。
|
管理员的配置入口在数据中枢(页面右上角「数据中枢」按钮,指向 8766):数据源凭证、模型池、会员与邀请码都在那里维护,数据仍存在主站同一份 SQLite 里。主站自身只保留「行情管理」面板(Tushare Token、后台刷新开关、手动刷新与补数)。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。
|
||||||
|
|
||||||
普通用户在「账号设置」中维护个人资料、查看会员状态和修改密码,不配置个人 LLM。有效会员使用平台模型;管理员可开通、续期、停用会员。平台模型受每日调用次数限制,管理员账号始终可用。
|
数据中枢用主站的管理员账号进入,没有独立账号;桥接令牌 `HUB_ADMIN_TOKEN` 需在主站与中枢两侧 `.env` 填成同一个值。详见 [xiaobai-datahub/README.md](xiaobai-datahub/README.md)。
|
||||||
|
|
||||||
|
普通用户在「账号设置」中维护个人资料、查看会员状态和修改密码,不配置个人 LLM。有效会员使用平台模型;管理员在数据中枢开通、续期、停用会员。平台模型受每日调用次数限制,管理员账号始终可用。
|
||||||
|
|
||||||
相关文档:
|
相关文档:
|
||||||
|
|
||||||
|
|||||||
+3
-17
@@ -50,9 +50,11 @@ from backend.features.themes.routes import ThemeRoutesMixin
|
|||||||
from backend.http import HttpTransportMixin
|
from backend.http import HttpTransportMixin
|
||||||
from backend.http.dispatch import (
|
from backend.http.dispatch import (
|
||||||
AUTHENTICATED_POST_HANDLERS,
|
AUTHENTICATED_POST_HANDLERS,
|
||||||
|
HUB_SERVICE_HANDLERS,
|
||||||
PUBLIC_POST_HANDLERS,
|
PUBLIC_POST_HANDLERS,
|
||||||
ApplicationHttpDispatchMixin,
|
ApplicationHttpDispatchMixin,
|
||||||
)
|
)
|
||||||
|
from backend.http.hubadmin import HubAdminHttpMixin
|
||||||
from backend.jobs.service import JobServiceMixin
|
from backend.jobs.service import JobServiceMixin
|
||||||
from backend.llm import LLMGateway
|
from backend.llm import LLMGateway
|
||||||
from backend.llm.http import LLMHttpMixin
|
from backend.llm.http import LLMHttpMixin
|
||||||
@@ -60,22 +62,6 @@ from backend.llm.service import LLMServiceMixin
|
|||||||
from database import ReviewDatabase
|
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(
|
class DashboardService(
|
||||||
SystemServiceMixin,
|
SystemServiceMixin,
|
||||||
AccountApplicationMixin,
|
AccountApplicationMixin,
|
||||||
@@ -121,7 +107,6 @@ class DashboardService(
|
|||||||
self._system_credentials,
|
self._system_credentials,
|
||||||
MENTOR_SKILLS_DIR,
|
MENTOR_SKILLS_DIR,
|
||||||
PRIVATE_MENTOR_SKILLS_DIR,
|
PRIVATE_MENTOR_SKILLS_DIR,
|
||||||
lambda: self.token,
|
|
||||||
)
|
)
|
||||||
self.data_gateway = self.container.data_gateway
|
self.data_gateway = self.container.data_gateway
|
||||||
self.ifind = self.container.ifind
|
self.ifind = self.container.ifind
|
||||||
@@ -169,6 +154,7 @@ class RequestHandler(
|
|||||||
AlertHttpMixin,
|
AlertHttpMixin,
|
||||||
ReviewHttpMixin,
|
ReviewHttpMixin,
|
||||||
LLMHttpMixin,
|
LLMHttpMixin,
|
||||||
|
HubAdminHttpMixin,
|
||||||
ApplicationHttpDispatchMixin,
|
ApplicationHttpDispatchMixin,
|
||||||
HttpTransportMixin,
|
HttpTransportMixin,
|
||||||
BaseHTTPRequestHandler,
|
BaseHTTPRequestHandler,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from backend.data import DataGateway, build_data_gateway
|
from backend.data import DataGateway, build_data_gateway
|
||||||
from backend.database.repositories import RepositoryBundle, build_repository_bundle
|
from backend.database.repositories import RepositoryBundle, build_repository_bundle
|
||||||
@@ -13,8 +12,8 @@ from backend.features.screener.engine import ScreenerEngine
|
|||||||
from backend.features.screener.tracking import StrategyTrackingService
|
from backend.features.screener.tracking import StrategyTrackingService
|
||||||
from backend.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository
|
from backend.jobs import InProcessJobRunner, JobRegistry, SQLiteJobRunRepository
|
||||||
from database import ReviewDatabase
|
from database import ReviewDatabase
|
||||||
from backend.data.providers.ifind_client import IfindHttpClient
|
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
||||||
from backend.data.realtime import WebRealtimeAggregator
|
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
||||||
from backend.features.market.charts import MarketChartClient
|
from backend.features.market.charts import MarketChartClient
|
||||||
|
|
||||||
|
|
||||||
@@ -23,13 +22,13 @@ class ApplicationContainer:
|
|||||||
database: ReviewDatabase
|
database: ReviewDatabase
|
||||||
repositories: RepositoryBundle
|
repositories: RepositoryBundle
|
||||||
data_gateway: DataGateway
|
data_gateway: DataGateway
|
||||||
ifind: IfindHttpClient
|
ifind: HubIfindProxy
|
||||||
screener: ScreenerEngine
|
screener: ScreenerEngine
|
||||||
strategy_tracking: StrategyTrackingService
|
strategy_tracking: StrategyTrackingService
|
||||||
alert_service: AlertService
|
alert_service: AlertService
|
||||||
trade_journal: TradeJournalService
|
trade_journal: TradeJournalService
|
||||||
mentor_skills: MentorSkillRegistry
|
mentor_skills: MentorSkillRegistry
|
||||||
realtime_aggregator: WebRealtimeAggregator
|
realtime_aggregator: HubRealtimeProxy
|
||||||
chart_data: MarketChartClient
|
chart_data: MarketChartClient
|
||||||
jobs: InProcessJobRunner
|
jobs: InProcessJobRunner
|
||||||
|
|
||||||
@@ -39,9 +38,8 @@ def build_application_container(
|
|||||||
credentials: dict[str, object],
|
credentials: dict[str, object],
|
||||||
mentor_skills_dir: Path,
|
mentor_skills_dir: Path,
|
||||||
private_mentor_skills_dir: Path,
|
private_mentor_skills_dir: Path,
|
||||||
tushare_token_supplier: Callable[[], str] | None = None,
|
|
||||||
) -> ApplicationContainer:
|
) -> ApplicationContainer:
|
||||||
data_gateway = build_data_gateway(credentials, tushare_token_supplier)
|
data_gateway = build_data_gateway(credentials)
|
||||||
repositories = build_repository_bundle(database)
|
repositories = build_repository_bundle(database)
|
||||||
jobs = InProcessJobRunner(JobRegistry.load(), SQLiteJobRunRepository(database))
|
jobs = InProcessJobRunner(JobRegistry.load(), SQLiteJobRunRepository(database))
|
||||||
return ApplicationContainer(
|
return ApplicationContainer(
|
||||||
|
|||||||
@@ -10,11 +10,8 @@ from backend.features.accounts.security import SecretVault
|
|||||||
|
|
||||||
def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]:
|
def environment_credentials(environment: Mapping[str, str]) -> dict[str, str]:
|
||||||
return {
|
return {
|
||||||
"tushare_token": str(environment.get("TUSHARE_TOKEN") or "").strip(),
|
|
||||||
"datahub_token": str(environment.get("DATAHUB_TOKEN") or "").strip(),
|
"datahub_token": str(environment.get("DATAHUB_TOKEN") or "").strip(),
|
||||||
"datahub_base_url": str(environment.get("DATAHUB_BASE_URL") 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(
|
"platform_llm_primary_api_key": str(
|
||||||
environment.get("LLM_PRIMARY_API_KEY") or environment.get("LLM_API_KEY") or ""
|
environment.get("LLM_PRIMARY_API_KEY") or environment.get("LLM_API_KEY") or ""
|
||||||
).strip(),
|
).strip(),
|
||||||
|
|||||||
+129
-66
@@ -2,7 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, Callable
|
from threading import Lock
|
||||||
|
from typing import Any, Callable, ClassVar
|
||||||
|
|
||||||
from backend.data.datahub.client import DatahubClient, DatahubResponse
|
from backend.data.datahub.client import DatahubClient, DatahubResponse
|
||||||
from backend.data.datahub.compare import compare_rows
|
from backend.data.datahub.compare import compare_rows
|
||||||
@@ -18,7 +19,14 @@ from backend.data.datahub.native import (
|
|||||||
from backend.data.datahub.redact import redact_text, redact_value
|
from backend.data.datahub.redact import redact_text, redact_value
|
||||||
from backend.data.datahub.route_state import LEDGER
|
from backend.data.datahub.route_state import LEDGER
|
||||||
from backend.data.datahub.settings import DatahubSettings
|
from backend.data.datahub.settings import DatahubSettings
|
||||||
from backend.data.providers.tushare_client import TushareClient
|
from backend.data.providers.tushare_daily import DailyMarketMixin
|
||||||
|
from backend.data.providers.tushare_dashboard import DashboardMixin
|
||||||
|
from backend.data.providers.tushare_dragon_tiger import DragonTigerMixin
|
||||||
|
from backend.data.providers.tushare_indices import IndexMixin
|
||||||
|
from backend.data.providers.tushare_industries import ShenwanIndustryMixin
|
||||||
|
from backend.data.providers.tushare_sectors import SectorMixin
|
||||||
|
from backend.data.providers.tushare_stocks import StockMixin
|
||||||
|
from backend.data.providers.tushare_transport import TushareError
|
||||||
|
|
||||||
LOGGER = logging.getLogger("xiaobai.datahub")
|
LOGGER = logging.getLogger("xiaobai.datahub")
|
||||||
ShadowSink = Callable[[dict[str, Any]], None]
|
ShadowSink = Callable[[dict[str, Any]], None]
|
||||||
@@ -148,7 +156,7 @@ class DatahubBridge:
|
|||||||
cleaned = [str(item or "").strip() for item in codes if str(item or "").strip()]
|
cleaned = [str(item or "").strip() for item in codes if str(item or "").strip()]
|
||||||
if not cleaned:
|
if not cleaned:
|
||||||
return None
|
return None
|
||||||
return self._try_quote_rows("quotes", {"codes": ",".join(cleaned[:60])}, minimum=1)
|
return self._try_quote_rows("quotes", {"codes": ",".join(cleaned)}, minimum=1)
|
||||||
|
|
||||||
def try_index_quotes(self) -> list[dict[str, Any]] | None:
|
def try_index_quotes(self) -> list[dict[str, Any]] | None:
|
||||||
flags = self.settings.flags("index_quotes")
|
flags = self.settings.flags("index_quotes")
|
||||||
@@ -171,6 +179,45 @@ class DatahubBridge:
|
|||||||
self._log_failure("index_quotes", exc)
|
self._log_failure("index_quotes", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def try_sector_quote(self, code: str, trade_date: str = "") -> dict[str, Any] | None:
|
||||||
|
flags = self.settings.flags("quotes")
|
||||||
|
if not flags.read:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = self.client.sector_quote(code, trade_date)
|
||||||
|
data = response.data
|
||||||
|
if not isinstance(data, dict) or not data:
|
||||||
|
raise DatahubError("EMPTY", "datahub sector quote empty")
|
||||||
|
row = dict(data)
|
||||||
|
if (response.meta or {}).get("stale"):
|
||||||
|
row["delayed"] = True
|
||||||
|
row["delay_seconds"] = int((response.meta or {}).get("staleness_seconds") or 0)
|
||||||
|
row["delay_notice"] = str((response.meta or {}).get("delay_notice") or "")
|
||||||
|
self._record_route("quotes", "datahub", str((response.meta or {}).get("source") or "datahub"))
|
||||||
|
return row
|
||||||
|
except Exception as exc:
|
||||||
|
self._log_failure("quotes", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_limit_pool(self, trade_date: str = "") -> list[dict[str, Any]] | None:
|
||||||
|
flags = self.settings.flags("limit_events")
|
||||||
|
if not flags.read:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
response = self.client.limit_pool(trade_date)
|
||||||
|
rows = [dict(item) for item in (response.data or []) if isinstance(item, dict)]
|
||||||
|
if not rows:
|
||||||
|
raise DatahubError("EMPTY", "datahub limit pool empty")
|
||||||
|
self._record_route(
|
||||||
|
"limit_events",
|
||||||
|
"datahub",
|
||||||
|
str((response.meta or {}).get("source") or "datahub"),
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
except Exception as exc:
|
||||||
|
self._log_failure("limit_events", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
def try_daily_chart(
|
def try_daily_chart(
|
||||||
self,
|
self,
|
||||||
code: str,
|
code: str,
|
||||||
@@ -191,6 +238,11 @@ class DatahubBridge:
|
|||||||
self.client.index_bars,
|
self.client.index_bars,
|
||||||
{"code": code, "from": start, "to": compact_end},
|
{"code": code, "from": start, "to": compact_end},
|
||||||
)
|
)
|
||||||
|
elif dataset == "sector_daily":
|
||||||
|
response = self._paginate(
|
||||||
|
self.client.sectors,
|
||||||
|
{"code": code, "from": start, "to": compact_end},
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
response = self._paginate(
|
response = self._paginate(
|
||||||
self.client.daily_bars,
|
self.client.daily_bars,
|
||||||
@@ -208,7 +260,7 @@ class DatahubBridge:
|
|||||||
if not rows:
|
if not rows:
|
||||||
raise DatahubError("EMPTY", f"{dataset} chart empty")
|
raise DatahubError("EMPTY", f"{dataset} chart empty")
|
||||||
self._record_route(dataset, "datahub", str((response.meta or {}).get("source") or "datahub"))
|
self._record_route(dataset, "datahub", str((response.meta or {}).get("source") or "datahub"))
|
||||||
return rows[-max(20, min(180, int(limit))):]
|
return rows[-max(1, int(limit)):]
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._log_failure(dataset, exc)
|
self._log_failure(dataset, exc)
|
||||||
return None
|
return None
|
||||||
@@ -240,9 +292,17 @@ class DatahubBridge:
|
|||||||
rows = dated
|
rows = dated
|
||||||
if len(rows) < minimum:
|
if len(rows) < minimum:
|
||||||
raise DatahubError("EMPTY", f"datahub {dataset} empty")
|
raise DatahubError("EMPTY", f"datahub {dataset} empty")
|
||||||
if (response.meta or {}).get("stale"):
|
stale = bool((response.meta or {}).get("stale"))
|
||||||
raise DatahubError("STALE", f"datahub {dataset} stale")
|
delay = int((response.meta or {}).get("staleness_seconds") or 0)
|
||||||
self._record_route(dataset, "datahub", str((response.meta or {}).get("source") or "datahub"))
|
notice = str((response.meta or {}).get("delay_notice") or "")
|
||||||
|
source = str((response.meta or {}).get("source") or "datahub")
|
||||||
|
if stale:
|
||||||
|
for item in rows:
|
||||||
|
item["delayed"] = True
|
||||||
|
item["delay_seconds"] = delay
|
||||||
|
item["delay_notice"] = notice
|
||||||
|
item["source"] = source
|
||||||
|
self._record_route(dataset, "datahub", source)
|
||||||
return rows
|
return rows
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._log_failure(dataset, exc)
|
self._log_failure(dataset, exc)
|
||||||
@@ -251,57 +311,35 @@ class DatahubBridge:
|
|||||||
def query(
|
def query(
|
||||||
self,
|
self,
|
||||||
api_name: str,
|
api_name: str,
|
||||||
params: dict[str, Any] | None,
|
params: dict[str, Any] | None = None,
|
||||||
fields: str,
|
fields: str = "",
|
||||||
legacy_query: Callable[..., list[dict[str, Any]]],
|
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
if api_name == "rt_sw_k":
|
||||||
|
raise TushareError("rt_sw_k is disabled; use published sw_daily or free Shenwan realtime")
|
||||||
dataset = API_TO_DATASET.get(api_name)
|
dataset = API_TO_DATASET.get(api_name)
|
||||||
# 问天按实际数据依赖接入:已映射到 hub 的 API 跟随开关;未映射的继续旧链路。
|
if dataset:
|
||||||
if not dataset:
|
|
||||||
return legacy_query(api_name, params, fields)
|
|
||||||
flags = self.settings.flags(dataset)
|
flags = self.settings.flags(dataset)
|
||||||
if not flags.read and not flags.shadow:
|
if flags.read:
|
||||||
return legacy_query(api_name, params, fields)
|
|
||||||
|
|
||||||
hub_rows: list[dict[str, Any]] | None = None
|
|
||||||
hub_meta: dict[str, Any] = {}
|
|
||||||
hub_error: str | None = None
|
|
||||||
hub_canonical: list[dict[str, Any]] = []
|
|
||||||
try:
|
try:
|
||||||
response = self._fetch_dataset(dataset, params or {}, api_name=api_name)
|
response = self._fetch_dataset(dataset, params or {}, api_name=api_name)
|
||||||
hub_canonical = self._extract_rows(dataset, response, params or {})
|
hub_canonical = self._extract_rows(dataset, response, params or {})
|
||||||
hub_rows = to_native_rows(dataset, hub_canonical)
|
hub_rows = to_native_rows(dataset, hub_canonical)
|
||||||
hub_meta = dict(response.meta)
|
|
||||||
self._validate_usable(dataset, hub_rows, response)
|
self._validate_usable(dataset, hub_rows, response)
|
||||||
|
self._record_route(dataset, "datahub", str(response.meta.get("source") or "datahub"))
|
||||||
|
return project_fields(hub_rows, fields)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
hub_error = self._error_text(exc)
|
|
||||||
self._log_failure(dataset, exc)
|
self._log_failure(dataset, exc)
|
||||||
|
|
||||||
if flags.shadow:
|
|
||||||
try:
|
try:
|
||||||
legacy_rows = legacy_query(api_name, params, fields)
|
response = self.client.query_api(api_name, params or {}, fields)
|
||||||
|
rows = [dict(item) for item in (response.data or []) if isinstance(item, dict)]
|
||||||
|
if dataset:
|
||||||
|
self._record_route(dataset, "datahub", str((response.meta or {}).get("source") or "datahub"))
|
||||||
|
else:
|
||||||
|
self._record_route(api_name, "datahub", str((response.meta or {}).get("source") or "datahub"))
|
||||||
|
return rows if not fields else project_fields(rows, fields)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if flags.read and hub_rows is not None and hub_error is None:
|
self._log_failure(dataset or api_name, exc)
|
||||||
self._emit_shadow(
|
raise TushareError(self._error_text(exc)) from exc
|
||||||
compare_rows(dataset, [], hub_canonical, hub_meta, self._error_text(exc), fields)
|
|
||||||
)
|
|
||||||
return project_fields(hub_rows, fields)
|
|
||||||
raise
|
|
||||||
self._emit_shadow(compare_rows(dataset, legacy_rows, hub_canonical, hub_meta, hub_error, fields))
|
|
||||||
if flags.read and hub_rows is not None and hub_error is None:
|
|
||||||
self._record_route(dataset, "datahub", str(hub_meta.get("source") or "datahub"))
|
|
||||||
return project_fields(hub_rows, fields)
|
|
||||||
if flags.read:
|
|
||||||
self._record_route(dataset, "legacy", "tushare", hub_error or "")
|
|
||||||
return legacy_rows
|
|
||||||
|
|
||||||
if flags.read and hub_rows is not None and hub_error is None:
|
|
||||||
self._record_route(dataset, "datahub", str(hub_meta.get("source") or "datahub"))
|
|
||||||
return project_fields(hub_rows, fields)
|
|
||||||
result = legacy_query(api_name, params, fields)
|
|
||||||
if flags.read:
|
|
||||||
self._record_route(dataset, "legacy", "tushare", hub_error or "")
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _fetch_dataset(self, dataset: str, params: dict[str, Any], api_name: str = "") -> DatahubResponse:
|
def _fetch_dataset(self, dataset: str, params: dict[str, Any], api_name: str = "") -> DatahubResponse:
|
||||||
date = yyyymmdd(params.get("trade_date") or params.get("date"))
|
date = yyyymmdd(params.get("trade_date") or params.get("date"))
|
||||||
@@ -421,8 +459,8 @@ class DatahubBridge:
|
|||||||
|
|
||||||
def _log_failure(self, dataset: str, exc: Exception) -> None:
|
def _log_failure(self, dataset: str, exc: Exception) -> None:
|
||||||
error = redact_text(self._error_text(exc), self.settings.secrets())
|
error = redact_text(self._error_text(exc), self.settings.secrets())
|
||||||
LOGGER.warning("datahub fallback dataset=%s error=%s", dataset, error)
|
LOGGER.warning("datahub unavailable dataset=%s error=%s", dataset, error)
|
||||||
self._record_route(dataset, "legacy", "pending-legacy", error)
|
self._record_route(dataset, "datahub", "unavailable", error)
|
||||||
|
|
||||||
def _record_route(self, dataset: str, route: str, source: str = "", error: str = "") -> None:
|
def _record_route(self, dataset: str, route: str, source: str = "", error: str = "") -> None:
|
||||||
LEDGER.record(dataset, route, source, redact_text(error, self.settings.secrets()))
|
LEDGER.record(dataset, route, source, redact_text(error, self.settings.secrets()))
|
||||||
@@ -444,7 +482,7 @@ def _native_quote(row: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
if not ts_code or close <= 0 or previous <= 0:
|
if not ts_code or close <= 0 or previous <= 0:
|
||||||
return None
|
return None
|
||||||
volume = _finite(row.get("vol") if row.get("vol") not in (None, "") else row.get("volume"))
|
volume = _finite(row.get("vol") if row.get("vol") not in (None, "") else row.get("volume"))
|
||||||
return {
|
payload = {
|
||||||
"ts_code": ts_code,
|
"ts_code": ts_code,
|
||||||
"name": str(row.get("name") or ts_code).strip(),
|
"name": str(row.get("name") or ts_code).strip(),
|
||||||
"pre_close": previous,
|
"pre_close": previous,
|
||||||
@@ -458,6 +496,11 @@ def _native_quote(row: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
"quote_date": yyyymmdd(row.get("quote_date") or row.get("trade_date")),
|
"quote_date": yyyymmdd(row.get("quote_date") or row.get("trade_date")),
|
||||||
"source": str(row.get("source") or "datahub"),
|
"source": str(row.get("source") or "datahub"),
|
||||||
}
|
}
|
||||||
|
if row.get("delayed"):
|
||||||
|
payload["delayed"] = True
|
||||||
|
payload["delay_seconds"] = int(row.get("delay_seconds") or 0)
|
||||||
|
payload["delay_notice"] = str(row.get("delay_notice") or "")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _chart_bars(rows: list[Any]) -> list[dict[str, Any]]:
|
def _chart_bars(rows: list[Any]) -> list[dict[str, Any]]:
|
||||||
@@ -504,19 +547,36 @@ def _finite(value: Any) -> float:
|
|||||||
return 0.0
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
class DatahubAwareTushareClient:
|
class DatahubAwareTushareClient(
|
||||||
def __init__(self, legacy: TushareClient, bridge: DatahubBridge) -> None:
|
DashboardMixin,
|
||||||
self._legacy = legacy
|
IndexMixin,
|
||||||
self._bridge = bridge
|
ShenwanIndustryMixin,
|
||||||
# Mixins run as methods on the inner instance (dashboard / indices /
|
SectorMixin,
|
||||||
# getattr). Bind hub hooks and query onto that instance so real
|
DragonTigerMixin,
|
||||||
# assembly cannot skip 8766.
|
StockMixin,
|
||||||
self._legacy_query = legacy.query
|
DailyMarketMixin,
|
||||||
legacy.query = self.query
|
):
|
||||||
legacy.try_market_quotes = self.try_market_quotes
|
"""Website market facade. Mixins call query(); query talks only to the hub."""
|
||||||
legacy.try_quotes = self.try_quotes
|
|
||||||
legacy.try_index_quotes = self.try_index_quotes
|
_realtime_reference_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
legacy.record_datahub_legacy = self.record_datahub_legacy
|
_realtime_reference_lock: ClassVar[Lock] = Lock()
|
||||||
|
_capital_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
|
_latest_realtime_market: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
|
_stock_activity_cache: ClassVar[dict[str, dict[str, Any]]] = {}
|
||||||
|
_stock_listing_cache: ClassVar[dict[str, Any]] = {}
|
||||||
|
_stock_listing_lock: ClassVar[Lock] = Lock()
|
||||||
|
_suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {}
|
||||||
|
_suspension_lock: ClassVar[Lock] = Lock()
|
||||||
|
_sw_member_cache: ClassVar[dict[str, Any]] = {}
|
||||||
|
_sw_member_lock: ClassVar[Lock] = Lock()
|
||||||
|
|
||||||
|
def __init__(self, first: Any, second: Any | None = None) -> None:
|
||||||
|
# Production: DatahubAwareTushareClient(bridge)
|
||||||
|
# Older tests: DatahubAwareTushareClient(unused_legacy, bridge)
|
||||||
|
self._bridge = second if second is not None else first
|
||||||
|
self.token = "datahub"
|
||||||
|
self.timeout = 30
|
||||||
|
self.realtime_aggregator = None
|
||||||
|
|
||||||
def query(
|
def query(
|
||||||
self,
|
self,
|
||||||
@@ -524,7 +584,7 @@ class DatahubAwareTushareClient:
|
|||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
fields: str = "",
|
fields: str = "",
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
return self._bridge.query(api_name, params, fields, self._legacy_query)
|
return self._bridge.query(api_name, params, fields)
|
||||||
|
|
||||||
def try_market_quotes(self, trade_date: str = "") -> list[dict[str, Any]] | None:
|
def try_market_quotes(self, trade_date: str = "") -> list[dict[str, Any]] | None:
|
||||||
return self._bridge.try_market_quotes(trade_date)
|
return self._bridge.try_market_quotes(trade_date)
|
||||||
@@ -535,8 +595,11 @@ class DatahubAwareTushareClient:
|
|||||||
def try_index_quotes(self) -> list[dict[str, Any]] | None:
|
def try_index_quotes(self) -> list[dict[str, Any]] | None:
|
||||||
return self._bridge.try_index_quotes()
|
return self._bridge.try_index_quotes()
|
||||||
|
|
||||||
|
def try_sector_quote(self, code: str, trade_date: str = "") -> dict[str, Any] | None:
|
||||||
|
return self._bridge.try_sector_quote(code, trade_date)
|
||||||
|
|
||||||
|
def try_limit_pool(self, trade_date: str = "") -> list[dict[str, Any]] | None:
|
||||||
|
return self._bridge.try_limit_pool(trade_date)
|
||||||
|
|
||||||
def record_datahub_legacy(self, dataset: str, source: str = "", error: str = "") -> None:
|
def record_datahub_legacy(self, dataset: str, source: str = "", error: str = "") -> None:
|
||||||
self._bridge.record_legacy(dataset, source, error)
|
self._bridge.record_legacy(dataset, source, error)
|
||||||
|
|
||||||
def __getattr__(self, name: str) -> Any:
|
|
||||||
return getattr(self._legacy, name)
|
|
||||||
|
|||||||
@@ -90,6 +90,24 @@ class DatahubClient:
|
|||||||
params["dataset"] = dataset
|
params["dataset"] = dataset
|
||||||
return self.get("/v1/batches", params)
|
return self.get("/v1/batches", params)
|
||||||
|
|
||||||
|
def query_api(self, api_name: str, params: dict[str, Any] | None = None, fields: str = "") -> DatahubResponse:
|
||||||
|
return self.post(
|
||||||
|
"/v1/query",
|
||||||
|
{"api_name": api_name, "params": params or {}, "fields": fields},
|
||||||
|
)
|
||||||
|
|
||||||
|
def sector_quote(self, code: str, date: str = "") -> DatahubResponse:
|
||||||
|
payload: dict[str, Any] = {"code": code}
|
||||||
|
if date:
|
||||||
|
payload["date"] = date
|
||||||
|
return self.get("/v1/sectors/quote", payload)
|
||||||
|
|
||||||
|
def limit_pool(self, trade_date: str = "") -> DatahubResponse:
|
||||||
|
params: dict[str, Any] = {}
|
||||||
|
if trade_date:
|
||||||
|
params["date"] = trade_date
|
||||||
|
return self.get("/v1/limit-pool", params)
|
||||||
|
|
||||||
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
|
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
|
||||||
if not self.settings.token:
|
if not self.settings.token:
|
||||||
raise DatahubError("NOT_CONFIGURED", "DATAHUB_TOKEN is not configured")
|
raise DatahubError("NOT_CONFIGURED", "DATAHUB_TOKEN is not configured")
|
||||||
@@ -118,15 +136,41 @@ class DatahubClient:
|
|||||||
)
|
)
|
||||||
raise last_error or DatahubError("INTERNAL", "datahub request failed")
|
raise last_error or DatahubError("INTERNAL", "datahub request failed")
|
||||||
|
|
||||||
def _request(self, url: str) -> DatahubResponse:
|
def post(self, path: str, body: dict[str, Any] | None = None) -> DatahubResponse:
|
||||||
request = urllib.request.Request(
|
if not self.settings.token:
|
||||||
url,
|
raise DatahubError("NOT_CONFIGURED", "DATAHUB_TOKEN is not configured")
|
||||||
|
url = self.settings.base_url + path
|
||||||
|
attempts = 1 + max(0, self.settings.retries)
|
||||||
|
last_error: DatahubError | None = None
|
||||||
|
payload = json.dumps(body or {}, ensure_ascii=False).encode("utf-8")
|
||||||
|
for attempt in range(attempts):
|
||||||
|
try:
|
||||||
|
return self._request(url, method="POST", data=payload)
|
||||||
|
except DatahubError as exc:
|
||||||
|
last_error = exc
|
||||||
|
if exc.code not in {"TIMEOUT", "UNAVAILABLE"} or attempt + 1 >= attempts:
|
||||||
|
raise
|
||||||
|
LOGGER.warning(
|
||||||
|
"datahub retry %s/%s %s",
|
||||||
|
attempt + 1,
|
||||||
|
attempts,
|
||||||
|
redact_text(str(exc), self.settings.secrets()),
|
||||||
|
)
|
||||||
|
raise last_error or DatahubError("INTERNAL", "datahub request failed")
|
||||||
|
|
||||||
|
def _request(self, url: str, method: str = "GET", data: bytes | None = None) -> DatahubResponse:
|
||||||
headers = {
|
headers = {
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"X-Datahub-Token": self.settings.token,
|
"X-Datahub-Token": self.settings.token,
|
||||||
"User-Agent": "XiaobaiReviewDatahub/1.0",
|
"User-Agent": "XiaobaiReviewDatahub/1.0",
|
||||||
},
|
}
|
||||||
method="GET",
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
request = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
headers=headers,
|
||||||
|
method=method,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with self._urlopen(request, timeout=self.settings.timeout_seconds) as response:
|
with self._urlopen(request, timeout=self.settings.timeout_seconds) as response:
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.data.datahub.bridge import DatahubBridge
|
||||||
|
from backend.data.datahub.errors import DatahubError
|
||||||
|
from backend.data.providers.ifind_client import IfindError
|
||||||
|
|
||||||
|
class HubIfindProxy:
|
||||||
|
"""Website-facing iFinD facade. Talks only to xiaobai-datahub."""
|
||||||
|
|
||||||
|
def __init__(self, datahub: DatahubBridge) -> None:
|
||||||
|
self._datahub = datahub
|
||||||
|
self._status: dict[str, Any] | None = None
|
||||||
|
self._status_at = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self.status().get("configured"))
|
||||||
|
|
||||||
|
def status(self) -> dict[str, Any]:
|
||||||
|
now = time.monotonic()
|
||||||
|
if self._status is not None and now - self._status_at < 30:
|
||||||
|
return dict(self._status)
|
||||||
|
fallback = {"configured": False, "access_ready": False, "access_expires_at": ""}
|
||||||
|
if not self._datahub.settings.token:
|
||||||
|
self._status = fallback
|
||||||
|
self._status_at = now
|
||||||
|
return dict(fallback)
|
||||||
|
try:
|
||||||
|
rows = self._rows("ifind_status", {})
|
||||||
|
except IfindError:
|
||||||
|
self._status = fallback
|
||||||
|
self._status_at = now
|
||||||
|
return dict(fallback)
|
||||||
|
row = rows[0] if rows else {}
|
||||||
|
status = {
|
||||||
|
"configured": bool(row.get("configured")),
|
||||||
|
"access_ready": bool(row.get("access_ready")),
|
||||||
|
"access_expires_at": str(row.get("access_expires_at") or ""),
|
||||||
|
}
|
||||||
|
self._status = status
|
||||||
|
self._status_at = now
|
||||||
|
return dict(status)
|
||||||
|
|
||||||
|
def wencai(self, query: str, search_type: str = "stock", cache_ttl: int = 300) -> list[dict[str, Any]]:
|
||||||
|
return self._rows(
|
||||||
|
"ifind_wencai",
|
||||||
|
{"query": query, "search_type": search_type, "cache_ttl": cache_ttl},
|
||||||
|
)
|
||||||
|
|
||||||
|
def snapshots(
|
||||||
|
self,
|
||||||
|
codes: str | list[str],
|
||||||
|
indicators: list[str],
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
cache_ttl: int = 8,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._rows(
|
||||||
|
"ifind_snapshots",
|
||||||
|
{
|
||||||
|
"codes": codes,
|
||||||
|
"indicators": indicators,
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"cache_ttl": cache_ttl,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def history(
|
||||||
|
self,
|
||||||
|
codes: str | list[str],
|
||||||
|
indicators: list[str],
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
cache_ttl: int = 300,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._rows(
|
||||||
|
"ifind_history",
|
||||||
|
{
|
||||||
|
"codes": codes,
|
||||||
|
"indicators": indicators,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
"cache_ttl": cache_ttl,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def real_time(
|
||||||
|
self,
|
||||||
|
codes: str | list[str],
|
||||||
|
indicators: list[str],
|
||||||
|
cache_ttl: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._rows(
|
||||||
|
"ifind_realtime",
|
||||||
|
{"codes": codes, "indicators": indicators, "cache_ttl": cache_ttl},
|
||||||
|
)
|
||||||
|
|
||||||
|
def intraday(
|
||||||
|
self,
|
||||||
|
code: str,
|
||||||
|
start_time: str,
|
||||||
|
end_time: str,
|
||||||
|
cache_ttl: int = 20,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._rows(
|
||||||
|
"ifind_intraday",
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"cache_ttl": cache_ttl,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_connection(self) -> dict[str, Any]:
|
||||||
|
payload = self.real_time(
|
||||||
|
"000001.SH",
|
||||||
|
["open", "high", "low", "latest", "preClose"],
|
||||||
|
cache_ttl=0,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": bool(payload),
|
||||||
|
"sample_time": str(payload[0].get("time") or "") if payload else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _rows(self, api_name: str, params: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
response = self._datahub.client.query_api(api_name, params)
|
||||||
|
except DatahubError as exc:
|
||||||
|
raise IfindError(str(exc) or "iFinD 数据中枢暂不可用") from exc
|
||||||
|
data = response.data
|
||||||
|
if isinstance(data, list):
|
||||||
|
return [dict(item) for item in data if isinstance(item, dict)]
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return [dict(data)]
|
||||||
|
return []
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.data.datahub.bridge import DatahubBridge
|
||||||
|
from backend.data.realtime import RealtimeAggregateError
|
||||||
|
|
||||||
|
|
||||||
|
class HubRealtimeProxy:
|
||||||
|
"""Realtime observation facade. Talks only to xiaobai-datahub."""
|
||||||
|
|
||||||
|
def __init__(self, datahub: DatahubBridge) -> None:
|
||||||
|
self._datahub = datahub
|
||||||
|
|
||||||
|
def health_snapshot(self, sector: str = "") -> dict[str, Any]:
|
||||||
|
started = datetime.now().astimezone()
|
||||||
|
indices: list[dict[str, Any]] = []
|
||||||
|
error = ""
|
||||||
|
try:
|
||||||
|
indices = self.tencent_indices()
|
||||||
|
except RealtimeAggregateError as exc:
|
||||||
|
error = str(exc)
|
||||||
|
epochs = [int(item.get("quote_time_epoch") or 0) for item in indices]
|
||||||
|
max_skew = 120
|
||||||
|
index_consistent = bool(epochs) and max(epochs) - min(epochs) <= max_skew
|
||||||
|
ready = len(indices) == 3 and index_consistent
|
||||||
|
return {
|
||||||
|
"ready": ready,
|
||||||
|
"isolated": True,
|
||||||
|
"generated_at": started.isoformat(timespec="seconds"),
|
||||||
|
"elapsed_ms": 0,
|
||||||
|
"indices": indices,
|
||||||
|
"index_consistent": index_consistent,
|
||||||
|
"sector": None,
|
||||||
|
"sources": {
|
||||||
|
"datahub_indices": {
|
||||||
|
"ok": ready,
|
||||||
|
"error": error,
|
||||||
|
"source": "datahub",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"observations": {},
|
||||||
|
"policy": {
|
||||||
|
"integration": "datahub_exclusive",
|
||||||
|
"max_index_time_skew_seconds": max_skew,
|
||||||
|
"notice": "实时观察只走数据中枢,主网站不再直连东财/腾讯。",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def tencent_indices(self) -> list[dict[str, Any]]:
|
||||||
|
rows = self._datahub.try_index_quotes() or []
|
||||||
|
result = [_as_index(item) for item in rows if _as_index(item)]
|
||||||
|
wanted = {"000001", "399001", "399006"}
|
||||||
|
result = [item for item in result if item.get("code") in wanted]
|
||||||
|
result.sort(key=lambda item: str(item.get("code") or ""))
|
||||||
|
if len(result) != 3:
|
||||||
|
raise RealtimeAggregateError(f"datahub returned {len(result)}/3 indices")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def eastmoney_indices(self) -> list[dict[str, Any]]:
|
||||||
|
return self.tencent_indices()
|
||||||
|
|
||||||
|
def tencent_stock_quote(self, code: str, expected_date: str = "") -> dict[str, Any]:
|
||||||
|
return self._stock_quote(code, expected_date)
|
||||||
|
|
||||||
|
def eastmoney_stock_quote(self, code: str, expected_date: str = "") -> dict[str, Any]:
|
||||||
|
return self._stock_quote(code, expected_date)
|
||||||
|
|
||||||
|
def tencent_stock_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
expected_date: str = "",
|
||||||
|
minimum: int | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._stock_quotes(codes, expected_date, minimum)
|
||||||
|
|
||||||
|
def eastmoney_stock_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
expected_date: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return self._stock_quotes(codes, expected_date, None)
|
||||||
|
|
||||||
|
def eastmoney_shenwan_quote(self, ts_code: str, expected_date: str = "") -> dict[str, Any]:
|
||||||
|
quote = self._datahub.try_sector_quote(ts_code, expected_date)
|
||||||
|
if not quote:
|
||||||
|
raise RealtimeAggregateError(f"datahub shenwan quote unavailable for {ts_code}")
|
||||||
|
return quote
|
||||||
|
|
||||||
|
def _stock_quote(self, code: str, expected_date: str) -> dict[str, Any]:
|
||||||
|
rows = self._stock_quotes([code], expected_date, 1)
|
||||||
|
if not rows:
|
||||||
|
raise RealtimeAggregateError(f"datahub stock quote unavailable for {code}")
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
def _stock_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
expected_date: str,
|
||||||
|
minimum: int | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
cleaned = [str(item or "").strip() for item in codes if str(item or "").strip()]
|
||||||
|
rows = self._datahub.try_quotes(cleaned) if cleaned else (self._datahub.try_market_quotes(expected_date) or [])
|
||||||
|
quotes = [_as_stock(item) for item in (rows or []) if _as_stock(item)]
|
||||||
|
if expected_date:
|
||||||
|
compact = str(expected_date).replace("-", "")
|
||||||
|
quotes = [
|
||||||
|
item
|
||||||
|
for item in quotes
|
||||||
|
if not item.get("quote_date") or str(item.get("quote_date") or "").replace("-", "") == compact
|
||||||
|
]
|
||||||
|
if minimum is not None and len(quotes) < minimum:
|
||||||
|
raise RealtimeAggregateError(f"datahub returned {len(quotes)} quotes, need {minimum}")
|
||||||
|
return quotes
|
||||||
|
|
||||||
|
|
||||||
|
def _as_index(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
code = str(row.get("code") or str(row.get("ts_code") or "").split(".")[0] or "")
|
||||||
|
price = _number(row.get("price") if row.get("price") not in (None, "") else row.get("close"))
|
||||||
|
if not code or price <= 0:
|
||||||
|
return None
|
||||||
|
epoch = int(_number(row.get("quote_time_epoch")))
|
||||||
|
amount = _number(row.get("amount_billion"))
|
||||||
|
if amount <= 0:
|
||||||
|
amount = round(_number(row.get("amount")) / 100_000_000, 2)
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"name": row.get("name") or code,
|
||||||
|
"price": price,
|
||||||
|
"change": _number(row.get("change") if row.get("change") not in (None, "") else row.get("pct_chg")),
|
||||||
|
"change_amount": _number(row.get("change_amount")),
|
||||||
|
"open": _number(row.get("open")),
|
||||||
|
"high": _number(row.get("high")),
|
||||||
|
"low": _number(row.get("low")),
|
||||||
|
"previous_close": _number(
|
||||||
|
row.get("previous_close") if row.get("previous_close") not in (None, "") else row.get("pre_close")
|
||||||
|
),
|
||||||
|
"amount_billion": amount,
|
||||||
|
"quote_time_epoch": epoch,
|
||||||
|
"quote_time": str(row.get("quote_time") or ""),
|
||||||
|
"source": str(row.get("source") or "datahub"),
|
||||||
|
"cache_age_seconds": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_stock(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
close = _number(row.get("close") if row.get("close") not in (None, "") else row.get("price"))
|
||||||
|
if close <= 0:
|
||||||
|
return None
|
||||||
|
ts_code = str(row.get("ts_code") or "")
|
||||||
|
code = str(row.get("code") or ts_code.split(".")[0] or "")
|
||||||
|
return {
|
||||||
|
"ts_code": ts_code or code,
|
||||||
|
"code": code,
|
||||||
|
"name": row.get("name") or "",
|
||||||
|
"close": close,
|
||||||
|
"pre_close": _number(
|
||||||
|
row.get("pre_close") if row.get("pre_close") not in (None, "") else row.get("previous_close")
|
||||||
|
),
|
||||||
|
"open": _number(row.get("open")),
|
||||||
|
"high": _number(row.get("high")),
|
||||||
|
"low": _number(row.get("low")),
|
||||||
|
"volume": _number(row.get("volume") if row.get("volume") not in (None, "") else row.get("vol")),
|
||||||
|
"vol": _number(row.get("vol") if row.get("vol") not in (None, "") else row.get("volume")),
|
||||||
|
"amount": _number(row.get("amount")),
|
||||||
|
"quote_time_epoch": int(_number(row.get("quote_time_epoch"))),
|
||||||
|
"quote_time": str(row.get("quote_time") or ""),
|
||||||
|
"quote_date": str(row.get("quote_date") or ""),
|
||||||
|
"source": str(row.get("source") or "datahub"),
|
||||||
|
"delayed": bool(row.get("delayed")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _number(value: Any) -> float:
|
||||||
|
try:
|
||||||
|
return float(value or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
+11
-24
@@ -1,45 +1,40 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.data.contracts import DataUsage
|
from backend.data.contracts import DataUsage
|
||||||
from backend.data.datahub import DatahubAwareTushareClient, DatahubBridge, DatahubClient, DatahubSettings
|
from backend.data.datahub import DatahubAwareTushareClient, DatahubBridge, DatahubClient, DatahubSettings
|
||||||
|
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
||||||
|
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
||||||
from backend.data.policy import DataSourcePolicy
|
from backend.data.policy import DataSourcePolicy
|
||||||
from backend.data.providers import IfindProvider, TushareProvider
|
from backend.data.providers import IfindProvider
|
||||||
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
|
from backend.data.quality import DataQualityGate, QualityEvidence, QualityReport
|
||||||
from backend.data.providers.ifind_client import IfindHttpClient
|
from backend.features.market.charts import MarketChartClient
|
||||||
from backend.data.providers.tushare_client import TushareClient
|
|
||||||
from backend.data.realtime import WebRealtimeAggregator
|
|
||||||
from backend.features.market.charts import EastmoneyChartClient, MarketChartClient
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DataGateway:
|
class DataGateway:
|
||||||
policy: DataSourcePolicy
|
policy: DataSourcePolicy
|
||||||
quality: DataQualityGate
|
quality: DataQualityGate
|
||||||
tushare_provider: TushareProvider
|
|
||||||
ifind_provider: IfindProvider
|
ifind_provider: IfindProvider
|
||||||
chart_data: MarketChartClient
|
chart_data: MarketChartClient
|
||||||
realtime_observer: WebRealtimeAggregator
|
realtime_observer: HubRealtimeProxy
|
||||||
datahub: DatahubBridge
|
datahub: DatahubBridge
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def ifind(self) -> IfindHttpClient:
|
def ifind(self) -> HubIfindProxy:
|
||||||
return self.ifind_provider.client
|
return self.ifind_provider.client
|
||||||
|
|
||||||
def tushare(
|
def tushare(
|
||||||
self,
|
self,
|
||||||
dataset_id: str = "",
|
dataset_id: str = "",
|
||||||
usage: DataUsage = "calculation",
|
usage: DataUsage = "calculation",
|
||||||
) -> TushareClient:
|
) -> DatahubAwareTushareClient:
|
||||||
if dataset_id:
|
if dataset_id:
|
||||||
self.policy.assert_allowed(dataset_id, "tushare", usage)
|
self.policy.assert_allowed(dataset_id, "tushare", usage)
|
||||||
legacy = self.tushare_provider.client()
|
return DatahubAwareTushareClient(self.datahub)
|
||||||
legacy.realtime_aggregator = self.realtime_observer
|
|
||||||
return DatahubAwareTushareClient(legacy, self.datahub)
|
|
||||||
|
|
||||||
def dataset_status(self, trade_date: str) -> list[dict[str, Any]] | None:
|
def dataset_status(self, trade_date: str) -> list[dict[str, Any]] | None:
|
||||||
return self.datahub.dataset_status(trade_date)
|
return self.datahub.dataset_status(trade_date)
|
||||||
@@ -99,26 +94,18 @@ class DataGateway:
|
|||||||
|
|
||||||
def build_data_gateway(
|
def build_data_gateway(
|
||||||
credentials: dict[str, object],
|
credentials: dict[str, object],
|
||||||
tushare_token_supplier: Callable[[], str] | None = None,
|
|
||||||
datahub_settings: DatahubSettings | None = None,
|
datahub_settings: DatahubSettings | None = None,
|
||||||
) -> DataGateway:
|
) -> DataGateway:
|
||||||
ifind = IfindHttpClient(
|
|
||||||
str(credentials.get("ifind_refresh_token") or ""),
|
|
||||||
str(credentials.get("ifind_access_token") or ""),
|
|
||||||
)
|
|
||||||
token_supplier = tushare_token_supplier or (
|
|
||||||
lambda: str(credentials.get("tushare_token") or "")
|
|
||||||
)
|
|
||||||
policy = DataSourcePolicy.load()
|
policy = DataSourcePolicy.load()
|
||||||
settings = datahub_settings or DatahubSettings.load(credentials=credentials)
|
settings = datahub_settings or DatahubSettings.load(credentials=credentials)
|
||||||
datahub_client = DatahubClient(settings)
|
datahub_client = DatahubClient(settings)
|
||||||
datahub = DatahubBridge(settings, datahub_client)
|
datahub = DatahubBridge(settings, datahub_client)
|
||||||
|
ifind = HubIfindProxy(datahub)
|
||||||
return DataGateway(
|
return DataGateway(
|
||||||
policy=policy,
|
policy=policy,
|
||||||
quality=DataQualityGate.load(policy),
|
quality=DataQualityGate.load(policy),
|
||||||
tushare_provider=TushareProvider(token_supplier),
|
|
||||||
ifind_provider=IfindProvider(ifind),
|
ifind_provider=IfindProvider(ifind),
|
||||||
chart_data=MarketChartClient(ifind, EastmoneyChartClient(), datahub),
|
chart_data=MarketChartClient(datahub),
|
||||||
realtime_observer=WebRealtimeAggregator(),
|
realtime_observer=HubRealtimeProxy(datahub),
|
||||||
datahub=datahub,
|
datahub=datahub,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from backend.data.providers.ifind_client import IfindHttpClient
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class IfindProvider:
|
class IfindProvider:
|
||||||
def __init__(self, client: IfindHttpClient) -> None:
|
def __init__(self, client: Any) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
|
|
||||||
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
|
def set_credentials(self, refresh_token: str, access_token: str = "") -> None:
|
||||||
self.client.set_credentials(refresh_token, access_token)
|
setter = getattr(self.client, "set_credentials", None)
|
||||||
|
if callable(setter):
|
||||||
|
setter(refresh_token, access_token)
|
||||||
|
|||||||
@@ -66,3 +66,5 @@ class TushareClient(
|
|||||||
_stock_listing_lock: ClassVar[Lock] = Lock()
|
_stock_listing_lock: ClassVar[Lock] = Lock()
|
||||||
_suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {}
|
_suspension_cache: ClassVar[dict[str, dict[str, str] | None]] = {}
|
||||||
_suspension_lock: ClassVar[Lock] = Lock()
|
_suspension_lock: ClassVar[Lock] = Lock()
|
||||||
|
_sw_member_cache: ClassVar[dict[str, Any]] = {}
|
||||||
|
_sw_member_lock: ClassVar[Lock] = Lock()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
from backend.data.numbers import finite_number as _number
|
from backend.data.numbers import finite_number as _number
|
||||||
from backend.data.providers.tushare_helpers import (
|
from backend.data.providers.tushare_helpers import (
|
||||||
_display_time,
|
_display_time,
|
||||||
|
_optional_number,
|
||||||
_prices_equal,
|
_prices_equal,
|
||||||
calendar_is_open,
|
calendar_is_open,
|
||||||
)
|
)
|
||||||
@@ -137,7 +138,75 @@ class DailyMarketMixin:
|
|||||||
)
|
)
|
||||||
item["capital_trade_date"] = str(capital.get("trade_date") or "")
|
item["capital_trade_date"] = str(capital.get("trade_date") or "")
|
||||||
result.append(item)
|
result.append(item)
|
||||||
return result
|
return self._overlay_board_fields(result, trade_date)
|
||||||
|
|
||||||
|
def _overlay_board_fields(
|
||||||
|
self,
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
trade_date: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if not rows:
|
||||||
|
return rows
|
||||||
|
official = self._official_board_map(trade_date)
|
||||||
|
free = self._free_board_map(trade_date) if not official else {}
|
||||||
|
merged: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
code = str(row.get("ts_code") or "")
|
||||||
|
extra = official.get(code) or free.get(code) or {}
|
||||||
|
if not extra:
|
||||||
|
merged.append(row)
|
||||||
|
continue
|
||||||
|
item = dict(row)
|
||||||
|
for key in (
|
||||||
|
"first_time",
|
||||||
|
"last_time",
|
||||||
|
"fd_amount",
|
||||||
|
"open_times",
|
||||||
|
"limit_times",
|
||||||
|
"turnover_ratio",
|
||||||
|
):
|
||||||
|
incoming = extra.get(key)
|
||||||
|
current = item.get(key)
|
||||||
|
if incoming in (None, "", "--"):
|
||||||
|
continue
|
||||||
|
# The daily fallback and the board pool can arrive at different
|
||||||
|
# times. Keep the stronger streak instead of freezing the
|
||||||
|
# provisional default at one or letting a stale pool lower it.
|
||||||
|
if key == "limit_times":
|
||||||
|
item[key] = max(
|
||||||
|
1,
|
||||||
|
int(_number(current, 1)),
|
||||||
|
int(_number(incoming, 1)),
|
||||||
|
)
|
||||||
|
elif current in (None, "", "--", 0, 0.0):
|
||||||
|
item[key] = incoming
|
||||||
|
merged.append(item)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def _official_board_map(self, trade_date: str) -> dict[str, dict[str, Any]]:
|
||||||
|
mapped: dict[str, dict[str, Any]] = {}
|
||||||
|
try:
|
||||||
|
for row in self._load_limit_lists(trade_date):
|
||||||
|
code = str(row.get("ts_code") or "")
|
||||||
|
if code:
|
||||||
|
mapped[code] = row
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
def _free_board_map(self, trade_date: str) -> dict[str, dict[str, Any]]:
|
||||||
|
loader = getattr(self, "try_limit_pool", None)
|
||||||
|
if not callable(loader):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
rows = loader(trade_date) or []
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
str(row.get("ts_code") or ""): row
|
||||||
|
for row in rows
|
||||||
|
if row.get("ts_code")
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_limit(row: dict[str, Any], status: str) -> dict[str, Any]:
|
def _normalize_limit(row: dict[str, Any], status: str) -> dict[str, Any]:
|
||||||
@@ -162,7 +231,11 @@ class DailyMarketMixin:
|
|||||||
"turnover_source": row.get("turnover_source") or "provider",
|
"turnover_source": row.get("turnover_source") or "provider",
|
||||||
"capital_trade_date": row.get("capital_trade_date") or "",
|
"capital_trade_date": row.get("capital_trade_date") or "",
|
||||||
"amount_billion": round(amount_billion, 2),
|
"amount_billion": round(amount_billion, 2),
|
||||||
"seal_amount_million": round(_number(row.get("fd_amount")) / 10000, 0),
|
"seal_amount_million": (
|
||||||
|
round(fd / 10000, 0)
|
||||||
|
if (fd := _optional_number(row.get("fd_amount"))) is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
"float_mv_billion": round(_number(row.get("float_mv")) / 100000000, 1),
|
"float_mv_billion": round(_number(row.get("float_mv")) / 100000000, 1),
|
||||||
"status": status,
|
"status": status,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,16 +55,24 @@ class DashboardMixin:
|
|||||||
if not limit_rows:
|
if not limit_rows:
|
||||||
limit_data_source = "derived"
|
limit_data_source = "derived"
|
||||||
notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。")
|
notices.append("涨跌停高级接口当日数据尚未更新,已使用日线数据推算。")
|
||||||
limit_rows = self._derive_limits(trade_date, daily)
|
limit_rows = self._derive_limits(
|
||||||
|
trade_date,
|
||||||
|
daily,
|
||||||
|
previous_limit_rows=previous_limit_rows,
|
||||||
|
)
|
||||||
except TushareError as exc:
|
except TushareError as exc:
|
||||||
limit_data_source = "derived"
|
limit_data_source = "derived"
|
||||||
notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}")
|
notices.append(f"涨跌停高级接口不可用,已使用日线数据推算:{exc}")
|
||||||
limit_rows = self._derive_limits(trade_date, daily)
|
|
||||||
previous_daily = self._load_daily(previous_trade_date)
|
previous_daily = self._load_daily(previous_trade_date)
|
||||||
previous_limit_rows = [
|
previous_limit_rows = [
|
||||||
row for row in self._derive_limits(previous_trade_date, previous_daily)
|
row for row in self._derive_limits(previous_trade_date, previous_daily)
|
||||||
if row.get("limit_type") == "U"
|
if row.get("limit_type") == "U"
|
||||||
]
|
]
|
||||||
|
limit_rows = self._derive_limits(
|
||||||
|
trade_date,
|
||||||
|
daily,
|
||||||
|
previous_limit_rows=previous_limit_rows,
|
||||||
|
)
|
||||||
|
|
||||||
up_rows = [row for row in limit_rows if row.get("limit_type") == "U"]
|
up_rows = [row for row in limit_rows if row.get("limit_type") == "U"]
|
||||||
down_rows = [row for row in limit_rows if row.get("limit_type") == "D"]
|
down_rows = [row for row in limit_rows if row.get("limit_type") == "D"]
|
||||||
@@ -251,27 +259,23 @@ class DashboardMixin:
|
|||||||
quotes = hub(trade_date)
|
quotes = hub(trade_date)
|
||||||
if quotes:
|
if quotes:
|
||||||
return list(quotes), "datahub"
|
return list(quotes), "datahub"
|
||||||
rt_error = ""
|
named = getattr(self, "try_quotes", None)
|
||||||
|
code_list = [item for item in str(codes or "").split(",") if item]
|
||||||
|
if callable(named) and code_list:
|
||||||
|
collected: list[dict[str, Any]] = []
|
||||||
|
for index in range(0, len(code_list), 60):
|
||||||
|
collected.extend(named(code_list[index:index + 60]) or [])
|
||||||
|
if collected:
|
||||||
|
delayed = any(item.get("delayed") for item in collected)
|
||||||
|
return collected, "datahub_delayed" if delayed else "datahub"
|
||||||
try:
|
try:
|
||||||
quotes = self.query("rt_k", {"ts_code": codes})
|
quotes = self.query("rt_k", {"ts_code": codes})
|
||||||
if quotes:
|
if quotes:
|
||||||
self._mark_quote_legacy("tushare_rt_k", rt_error)
|
delayed = any(item.get("delayed") for item in quotes)
|
||||||
return list(quotes), "tushare_rt_k"
|
return list(quotes), "datahub_delayed" if delayed else "datahub"
|
||||||
rt_error = f"No realtime data returned for {trade_date}"
|
|
||||||
except TushareError as exc:
|
except TushareError as exc:
|
||||||
rt_error = str(exc)
|
raise TushareError(f"当天盘中实时行情不可用:{exc}") from exc
|
||||||
try:
|
raise TushareError("当天盘中实时行情不可用:数据中枢未返回可用行情")
|
||||||
quotes, quote_source = self._free_realtime_quotes(trade_date, codes)
|
|
||||||
except Exception as exc:
|
|
||||||
raise TushareError(
|
|
||||||
f"当天盘中实时行情不可用:rt_k={rt_error};免费源={exc}"
|
|
||||||
) from exc
|
|
||||||
if not quotes:
|
|
||||||
raise TushareError(
|
|
||||||
f"当天盘中实时行情不可用:rt_k={rt_error};免费源=empty"
|
|
||||||
)
|
|
||||||
self._mark_quote_legacy(quote_source, rt_error)
|
|
||||||
return quotes, quote_source
|
|
||||||
|
|
||||||
def _mark_quote_legacy(self, source: str, error: str = "") -> None:
|
def _mark_quote_legacy(self, source: str, error: str = "") -> None:
|
||||||
marker = getattr(self, "record_datahub_legacy", None)
|
marker = getattr(self, "record_datahub_legacy", None)
|
||||||
@@ -283,24 +287,8 @@ class DashboardMixin:
|
|||||||
trade_date: str,
|
trade_date: str,
|
||||||
codes: str = "",
|
codes: str = "",
|
||||||
) -> tuple[list[dict[str, Any]], str]:
|
) -> tuple[list[dict[str, Any]], str]:
|
||||||
aggregator = self._realtime_aggregator()
|
del trade_date, codes
|
||||||
last_error = ""
|
raise TushareError("主网站不再直连免费行情源,请走数据中枢")
|
||||||
try:
|
|
||||||
quotes = aggregator.eastmoney_market_quotes(expected_date=trade_date)
|
|
||||||
if quotes:
|
|
||||||
return quotes, "eastmoney_clist"
|
|
||||||
except Exception as exc:
|
|
||||||
last_error = str(exc)
|
|
||||||
code_list = [item for item in str(codes or "").split(",") if item]
|
|
||||||
try:
|
|
||||||
quotes = aggregator.tencent_market_quotes(code_list, expected_date=trade_date)
|
|
||||||
except Exception as exc:
|
|
||||||
raise TushareError(
|
|
||||||
f"eastmoney={last_error or 'empty'};tencent={exc}"
|
|
||||||
) from exc
|
|
||||||
if not quotes:
|
|
||||||
raise TushareError(f"eastmoney={last_error or 'empty'};tencent=empty")
|
|
||||||
return quotes, "tencent_qt"
|
|
||||||
|
|
||||||
def _free_realtime_indices(self) -> list[dict[str, Any]]:
|
def _free_realtime_indices(self) -> list[dict[str, Any]]:
|
||||||
hub = getattr(self, "try_index_quotes", None)
|
hub = getattr(self, "try_index_quotes", None)
|
||||||
@@ -309,13 +297,6 @@ class DashboardMixin:
|
|||||||
converted = [item for item in (_hub_index_quote(row) for row in rows or []) if item]
|
converted = [item for item in (_hub_index_quote(row) for row in rows or []) if item]
|
||||||
if converted:
|
if converted:
|
||||||
return converted
|
return converted
|
||||||
try:
|
|
||||||
rows = self._realtime_aggregator().eastmoney_indices()
|
|
||||||
marker = getattr(self, "record_datahub_legacy", None)
|
|
||||||
if callable(marker):
|
|
||||||
marker("index_quotes", "eastmoney_push2")
|
|
||||||
return rows
|
|
||||||
except Exception:
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _load_realtime_reference(
|
def _load_realtime_reference(
|
||||||
@@ -365,10 +346,9 @@ class DashboardMixin:
|
|||||||
ts_code: str,
|
ts_code: str,
|
||||||
reference_date: str = "",
|
reference_date: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
rows = self.query("rt_k", {"ts_code": ts_code})
|
row = self._realtime_quote_row(ts_code, reference_date)
|
||||||
if not rows:
|
if not row:
|
||||||
raise TushareError(f"No realtime quote returned for {ts_code}")
|
raise TushareError(f"No realtime quote returned for {ts_code}")
|
||||||
row = rows[0]
|
|
||||||
close = _number(row.get("close"))
|
close = _number(row.get("close"))
|
||||||
previous_close = _number(row.get("pre_close"))
|
previous_close = _number(row.get("pre_close"))
|
||||||
if close <= 0 or previous_close <= 0:
|
if close <= 0 or previous_close <= 0:
|
||||||
@@ -396,6 +376,11 @@ class DashboardMixin:
|
|||||||
basic = basics[0] if basics else {}
|
basic = basics[0] if basics else {}
|
||||||
capital = self._latest_capital(ts_code, reference_date)
|
capital = self._latest_capital(ts_code, reference_date)
|
||||||
float_share = _number(capital.get("float_share"))
|
float_share = _number(capital.get("float_share"))
|
||||||
|
if not float_share and previous_close:
|
||||||
|
# The hub's canonical valuation table stores market values but not
|
||||||
|
# share counts. circ_mv (10k CNY) / price (CNY) is float shares in
|
||||||
|
# 10k shares, the same unit used by the turnover formula below.
|
||||||
|
float_share = _number(capital.get("circ_mv")) / previous_close
|
||||||
# rt_k volume is shares; daily_basic float_share is reported in 10k shares.
|
# rt_k volume is shares; daily_basic float_share is reported in 10k shares.
|
||||||
turnover_rate = _number(row.get("vol")) / float_share / 100 if float_share else 0
|
turnover_rate = _number(row.get("vol")) / float_share / 100 if float_share else 0
|
||||||
market_date = reference_date or datetime.now().astimezone().strftime("%Y%m%d")
|
market_date = reference_date or datetime.now().astimezone().strftime("%Y%m%d")
|
||||||
@@ -417,6 +402,13 @@ class DashboardMixin:
|
|||||||
for item in market_rows:
|
for item in market_rows:
|
||||||
item_capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
item_capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
||||||
item_float_share = _number(item_capital.get("float_share"))
|
item_float_share = _number(item_capital.get("float_share"))
|
||||||
|
item_previous = _number(
|
||||||
|
item.get("pre_close")
|
||||||
|
if item.get("pre_close") not in (None, "")
|
||||||
|
else item.get("previous_close")
|
||||||
|
)
|
||||||
|
if not item_float_share and item_previous:
|
||||||
|
item_float_share = _number(item_capital.get("circ_mv")) / item_previous
|
||||||
if item_float_share:
|
if item_float_share:
|
||||||
market_turnovers.append(_number(item.get("vol")) / item_float_share / 100)
|
market_turnovers.append(_number(item.get("vol")) / item_float_share / 100)
|
||||||
market_turnover = (
|
market_turnover = (
|
||||||
@@ -428,6 +420,10 @@ class DashboardMixin:
|
|||||||
market_date,
|
market_date,
|
||||||
_number(row.get("vol")) / 100,
|
_number(row.get("vol")) / 100,
|
||||||
)
|
)
|
||||||
|
trade_time = str(row.get("trade_time") or row.get("quote_time") or "")
|
||||||
|
trade_date = str(row.get("quote_date") or "").replace("-", "")[:8]
|
||||||
|
if not trade_date and trade_time:
|
||||||
|
trade_date = trade_time[:10].replace("-", "")
|
||||||
return {
|
return {
|
||||||
"code": ts_code.split(".")[0],
|
"code": ts_code.split(".")[0],
|
||||||
"ts_code": ts_code,
|
"ts_code": ts_code,
|
||||||
@@ -452,10 +448,26 @@ class DashboardMixin:
|
|||||||
"float_share_10k": float_share,
|
"float_share_10k": float_share,
|
||||||
"capital_trade_date": str(capital.get("trade_date") or ""),
|
"capital_trade_date": str(capital.get("trade_date") or ""),
|
||||||
"turnover_source": "rt_volume/latest_float_share" if float_share else "unavailable",
|
"turnover_source": "rt_volume/latest_float_share" if float_share else "unavailable",
|
||||||
"data_source": "tushare",
|
"data_source": str(row.get("source") or "tushare"),
|
||||||
|
"trade_date": trade_date,
|
||||||
|
"trade_time": trade_time,
|
||||||
"realtime": True,
|
"realtime": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _realtime_quote_row(self, ts_code: str, reference_date: str = "") -> dict[str, Any]:
|
||||||
|
hub = getattr(self, "try_quotes", None)
|
||||||
|
if callable(hub):
|
||||||
|
rows = hub([ts_code]) or []
|
||||||
|
if rows:
|
||||||
|
return dict(rows[0])
|
||||||
|
try:
|
||||||
|
rows = self.query("rt_k", {"ts_code": ts_code})
|
||||||
|
if rows:
|
||||||
|
return dict(rows[0])
|
||||||
|
except TushareError:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
def _stock_activity_metrics(
|
def _stock_activity_metrics(
|
||||||
self,
|
self,
|
||||||
ts_code: str,
|
ts_code: str,
|
||||||
@@ -475,7 +487,10 @@ class DashboardMixin:
|
|||||||
{
|
{
|
||||||
"ts_code": ts_code,
|
"ts_code": ts_code,
|
||||||
"start_date": (end - timedelta(days=30)).strftime("%Y%m%d"),
|
"start_date": (end - timedelta(days=30)).strftime("%Y%m%d"),
|
||||||
"end_date": reference_date,
|
# Intraday bars are not official daily history yet. Asking
|
||||||
|
# the hub for today's daily row makes a complete historical
|
||||||
|
# range look incomplete and discards otherwise valid data.
|
||||||
|
"end_date": (end - timedelta(days=1)).strftime("%Y%m%d"),
|
||||||
},
|
},
|
||||||
"ts_code,trade_date,vol,amount",
|
"ts_code,trade_date,vol,amount",
|
||||||
)
|
)
|
||||||
@@ -573,7 +588,7 @@ class DashboardMixin:
|
|||||||
for row in reference.get("basic_rows") or []
|
for row in reference.get("basic_rows") or []
|
||||||
if row.get("ts_code")
|
if row.get("ts_code")
|
||||||
]
|
]
|
||||||
quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "")
|
quotes, quote_source = self._load_realtime_quotes(",".join(codes), trade_date)
|
||||||
rows = [
|
rows = [
|
||||||
row for row in quotes
|
row for row in quotes
|
||||||
if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
|
if _number(row.get("close")) > 0 and _number(row.get("pre_close")) > 0
|
||||||
@@ -600,7 +615,13 @@ class DashboardMixin:
|
|||||||
start_date = (end - timedelta(days=20)).strftime("%Y%m%d")
|
start_date = (end - timedelta(days=20)).strftime("%Y%m%d")
|
||||||
rows = self.query(
|
rows = self.query(
|
||||||
"daily_basic",
|
"daily_basic",
|
||||||
{"ts_code": ts_code, "start_date": start_date, "end_date": end_date},
|
{
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"start_date": start_date,
|
||||||
|
# Same rule as price history: today's official valuation is
|
||||||
|
# unavailable during the session, so use the latest prior row.
|
||||||
|
"end_date": (end - timedelta(days=1)).strftime("%Y%m%d"),
|
||||||
|
},
|
||||||
"ts_code,trade_date,turnover_rate,volume_ratio,total_share,float_share,"
|
"ts_code,trade_date,turnover_rate,volume_ratio,total_share,float_share,"
|
||||||
"free_share,total_mv,circ_mv",
|
"free_share,total_mv,circ_mv",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,55 @@ def _text(value: Any) -> str:
|
|||||||
return str(value or "").strip()
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_number(value: Any) -> float | None:
|
||||||
|
if value in (None, "", "-"):
|
||||||
|
return None
|
||||||
|
number = _number(value, default=float("nan"))
|
||||||
|
if number != number:
|
||||||
|
return None
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _moneyflow_payload(flow: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
if not flow:
|
||||||
|
return {
|
||||||
|
"available": False,
|
||||||
|
"net_million": None,
|
||||||
|
"large_million": None,
|
||||||
|
"medium_million": None,
|
||||||
|
"small_million": None,
|
||||||
|
}
|
||||||
|
net = _optional_number(flow.get("net_mf_amount"))
|
||||||
|
buy_lg = _optional_number(flow.get("buy_lg_amount"))
|
||||||
|
sell_lg = _optional_number(flow.get("sell_lg_amount"))
|
||||||
|
buy_elg = _optional_number(flow.get("buy_elg_amount"))
|
||||||
|
sell_elg = _optional_number(flow.get("sell_elg_amount"))
|
||||||
|
buy_md = _optional_number(flow.get("buy_md_amount"))
|
||||||
|
sell_md = _optional_number(flow.get("sell_md_amount"))
|
||||||
|
buy_sm = _optional_number(flow.get("buy_sm_amount"))
|
||||||
|
sell_sm = _optional_number(flow.get("sell_sm_amount"))
|
||||||
|
large = None
|
||||||
|
if None not in (buy_lg, sell_lg, buy_elg, sell_elg):
|
||||||
|
large = (buy_lg + buy_elg - sell_lg - sell_elg)
|
||||||
|
elif _optional_number(flow.get("large_amount")) is not None:
|
||||||
|
large = _optional_number(flow.get("large_amount"))
|
||||||
|
medium = None if None in (buy_md, sell_md) else (buy_md - sell_md)
|
||||||
|
if medium is None:
|
||||||
|
medium = _optional_number(flow.get("medium_amount"))
|
||||||
|
small = None if None in (buy_sm, sell_sm) else (buy_sm - sell_sm)
|
||||||
|
if small is None:
|
||||||
|
small = _optional_number(flow.get("small_amount"))
|
||||||
|
if net is None and large is None and medium is None and small is None:
|
||||||
|
return _moneyflow_payload(None)
|
||||||
|
return {
|
||||||
|
"available": True,
|
||||||
|
"net_million": None if net is None else round(net / 100, 2),
|
||||||
|
"large_million": None if large is None else round(large / 100, 2),
|
||||||
|
"medium_million": None if medium is None else round(medium / 100, 2),
|
||||||
|
"small_million": None if small is None else round(small / 100, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _prices_equal(left: Any, right: Any) -> bool:
|
def _prices_equal(left: Any, right: Any) -> bool:
|
||||||
if left is None or right is None:
|
if left is None or right is None:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -63,22 +63,8 @@ class IndexMixin:
|
|||||||
if callable(hub):
|
if callable(hub):
|
||||||
rows = hub()
|
rows = hub()
|
||||||
if rows:
|
if rows:
|
||||||
try:
|
|
||||||
return self._hub_realtime_market_indices(requested_date, rows)
|
return self._hub_realtime_market_indices(requested_date, rows)
|
||||||
except TushareError:
|
raise TushareError("Realtime index quotes are incomplete")
|
||||||
pass
|
|
||||||
try:
|
|
||||||
payload = self._tushare_realtime_market_indices(requested_date)
|
|
||||||
marker = getattr(self, "record_datahub_legacy", None)
|
|
||||||
if callable(marker):
|
|
||||||
marker("index_quotes", "tushare_rt_idx_k")
|
|
||||||
return payload
|
|
||||||
except TushareError:
|
|
||||||
payload = self._free_realtime_market_indices(requested_date)
|
|
||||||
marker = getattr(self, "record_datahub_legacy", None)
|
|
||||||
if callable(marker):
|
|
||||||
marker("index_quotes", str(payload.get("source") or "eastmoney_push2"))
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def _hub_realtime_market_indices(
|
def _hub_realtime_market_indices(
|
||||||
self,
|
self,
|
||||||
@@ -199,50 +185,5 @@ class IndexMixin:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _free_realtime_market_indices(self, requested_date: str) -> dict[str, Any]:
|
def _free_realtime_market_indices(self, requested_date: str) -> dict[str, Any]:
|
||||||
trade_date, _ = self.resolve_trade_context(requested_date)
|
del requested_date
|
||||||
aggregator = getattr(self, "realtime_aggregator", None)
|
raise TushareError("主网站不再直连免费行情源,请走数据中枢")
|
||||||
if aggregator is None:
|
|
||||||
raise TushareError("免费实时源未配置")
|
|
||||||
quotes = aggregator.eastmoney_indices()
|
|
||||||
index_names = {
|
|
||||||
"000001": ("000001.SH", "上证指数"),
|
|
||||||
"399001": ("399001.SZ", "深证成指"),
|
|
||||||
"399006": ("399006.SZ", "创业板指"),
|
|
||||||
}
|
|
||||||
indices = []
|
|
||||||
for quote in quotes:
|
|
||||||
mapped = index_names.get(str(quote.get("code") or ""))
|
|
||||||
if not mapped:
|
|
||||||
continue
|
|
||||||
ts_code, name = mapped
|
|
||||||
close = _number(quote.get("price"))
|
|
||||||
previous_close = _number(quote.get("previous_close"))
|
|
||||||
if close <= 0 or previous_close <= 0:
|
|
||||||
continue
|
|
||||||
indices.append(
|
|
||||||
{
|
|
||||||
"ts_code": ts_code,
|
|
||||||
"name": str(quote.get("name") or name).strip(),
|
|
||||||
"trade_date": trade_date,
|
|
||||||
"close": close,
|
|
||||||
"pct_chg": round(_number(quote.get("change")) or (close / previous_close - 1) * 100, 3),
|
|
||||||
"return_5d": 0,
|
|
||||||
"amount_billion": round(_number(quote.get("amount_billion")), 2),
|
|
||||||
"quote_time": quote.get("quote_time") or "",
|
|
||||||
"source": quote.get("source") or "eastmoney_push2",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if len(indices) != 3:
|
|
||||||
raise TushareError("Realtime index quotes are incomplete")
|
|
||||||
return {
|
|
||||||
"trade_date": trade_date,
|
|
||||||
"source": "eastmoney_push2",
|
|
||||||
"realtime": True,
|
|
||||||
"precise": True,
|
|
||||||
"indices": indices,
|
|
||||||
"aggregate": {
|
|
||||||
"average_pct_chg": round(sum(item["pct_chg"] for item in indices) / len(indices), 3),
|
|
||||||
"average_return_5d": 0,
|
|
||||||
"average_return_20d": 0,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from backend.data.numbers import finite_number as _number
|
from backend.data.numbers import finite_number as _number
|
||||||
from backend.data.providers.tushare_transport import TushareError
|
from backend.data.providers.tushare_transport import TushareError
|
||||||
|
|
||||||
|
_SW_MEMBER_TTL = timedelta(hours=24)
|
||||||
|
_SW_MEMBER_DIR = Path(__file__).resolve().parents[3] / "data" / "cache" / "sw_members"
|
||||||
|
|
||||||
|
|
||||||
class ShenwanIndustryMixin:
|
class ShenwanIndustryMixin:
|
||||||
def sw_stock_industry(self, ts_code: str, trade_date: str) -> dict[str, Any]:
|
def sw_stock_industry(self, ts_code: str, trade_date: str) -> dict[str, Any]:
|
||||||
@@ -132,21 +137,73 @@ class ShenwanIndustryMixin:
|
|||||||
actual_trade_date = str(daily.get("trade_date") or "")
|
actual_trade_date = str(daily.get("trade_date") or "")
|
||||||
outer_precise = actual_trade_date == trade_date
|
outer_precise = actual_trade_date == trade_date
|
||||||
outer_error = "" if outer_precise else (
|
outer_error = "" if outer_precise else (
|
||||||
f"No Shenwan daily returned for {sector_code} on {trade_date}"
|
f"申万行业 {sector_code} 当日盘后正式数据尚未入库"
|
||||||
)
|
)
|
||||||
|
outer_source = "tushare_sw_daily" if outer_precise else "unavailable"
|
||||||
if not outer_precise and allow_realtime_close:
|
if not outer_precise and allow_realtime_close:
|
||||||
|
inner_ok = bool(member_rows) and not coverage_issue
|
||||||
|
if inner_ok:
|
||||||
|
sw_row, rt_source, rt_error = self._sw_outer_realtime(
|
||||||
|
sector_code,
|
||||||
|
str(industry.get("l2_name") or ""),
|
||||||
|
trade_date,
|
||||||
|
finalized=True,
|
||||||
|
)
|
||||||
|
if sw_row:
|
||||||
|
daily = sw_row
|
||||||
|
actual_trade_date = str(
|
||||||
|
sw_row.get("quote_date") or sw_row.get("trade_date") or ""
|
||||||
|
)
|
||||||
|
trade_time = str(sw_row.get("trade_time") or sw_row.get("quote_time") or "")
|
||||||
|
quote_clock = (
|
||||||
|
trade_time[11:19]
|
||||||
|
if len(trade_time) >= 19
|
||||||
|
else str(sw_row.get("quote_clock") or "")
|
||||||
|
)
|
||||||
|
outer_precise = actual_trade_date == trade_date
|
||||||
|
if quote_clock and quote_clock < "15:00:00":
|
||||||
|
outer_precise = False
|
||||||
|
outer_source = rt_source or "eastmoney_sw"
|
||||||
|
outer_error = "" if outer_precise else (
|
||||||
|
rt_error or f"申万行业 {sector_code} 免费实时尚未形成收盘快照"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
outer_error = rt_error or outer_error
|
||||||
|
if not outer_precise:
|
||||||
|
# The exact SW L2 member set and every member's final daily bar
|
||||||
|
# are already available. Use their equal-weight move as an
|
||||||
|
# explicitly labelled provisional outer value until Tushare's
|
||||||
|
# official sw_daily row is published.
|
||||||
|
daily = {"change": equal_change}
|
||||||
|
actual_trade_date = trade_date
|
||||||
|
outer_precise = True
|
||||||
|
outer_source = "sw_member_equal_daily"
|
||||||
|
outer_error = ""
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
return self._sw_realtime_sector_snapshot(
|
snapshot = self._sw_realtime_sector_snapshot(
|
||||||
industry,
|
industry,
|
||||||
members,
|
members,
|
||||||
trade_date,
|
trade_date,
|
||||||
previous_trade_date,
|
previous_trade_date,
|
||||||
finalized=True,
|
finalized=True,
|
||||||
)
|
)
|
||||||
except TushareError as exc:
|
snapshot.update({
|
||||||
outer_error = f"{outer_error}; realtime close fallback failed: {exc}"
|
"raw_member_count": raw_member_count,
|
||||||
|
"excluded_member_count": len(excluded_members),
|
||||||
|
"excluded_members": excluded_members,
|
||||||
|
})
|
||||||
|
return snapshot
|
||||||
|
except TushareError:
|
||||||
|
outer_error = f"{outer_error}; 免费实时成分暂不可用"
|
||||||
|
|
||||||
official_change = _number(daily.get("pct_change")) if outer_precise else None
|
official_change = None
|
||||||
|
if outer_precise:
|
||||||
|
official_change = _number(
|
||||||
|
daily.get("pct_change")
|
||||||
|
if daily.get("pct_change") not in (None, "")
|
||||||
|
else daily.get("change")
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"code": sector_code,
|
"code": sector_code,
|
||||||
"name": industry.get("l2_name") or daily.get("name") or sector_code,
|
"name": industry.get("l2_name") or daily.get("name") or sector_code,
|
||||||
@@ -173,9 +230,9 @@ class ShenwanIndustryMixin:
|
|||||||
"amount_billion": round(amount_billion, 2),
|
"amount_billion": round(amount_billion, 2),
|
||||||
"count": 0,
|
"count": 0,
|
||||||
"max_streak": 0,
|
"max_streak": 0,
|
||||||
"source": "tushare_sw_daily+member_daily" if outer_precise else "tushare_member_daily",
|
"source": f"{outer_source}+tushare_member_daily" if outer_precise else "tushare_member_daily",
|
||||||
"inner_source": "tushare_member_daily",
|
"inner_source": "tushare_member_daily",
|
||||||
"outer_source": "tushare_sw_daily" if outer_precise else "unavailable",
|
"outer_source": outer_source,
|
||||||
"taxonomy": "sw_l2",
|
"taxonomy": "sw_l2",
|
||||||
"industry": industry,
|
"industry": industry,
|
||||||
"trade_date": trade_date,
|
"trade_date": trade_date,
|
||||||
@@ -189,7 +246,7 @@ class ShenwanIndustryMixin:
|
|||||||
"inner_error": inner_error,
|
"inner_error": inner_error,
|
||||||
"outer_error": outer_error,
|
"outer_error": outer_error,
|
||||||
"schema_version": 6,
|
"schema_version": 6,
|
||||||
"methodology": "外显使用申万二级行业官方日线;内核独立使用当日成分日线宽度与等权涨跌聚合",
|
"methodology": "外显使用已发布 sw_daily 或免费申万实时;内核优先使用当日成分日线,不调用 rt_sw_k",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _sw_sector_members(
|
def _sw_sector_members(
|
||||||
@@ -197,7 +254,11 @@ class ShenwanIndustryMixin:
|
|||||||
sector_code: str,
|
sector_code: str,
|
||||||
trade_date: str,
|
trade_date: str,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
rows = []
|
cached_rows = self._read_local_sw_members(sector_code)
|
||||||
|
if cached_rows is not None:
|
||||||
|
return _active_members(cached_rows, trade_date)
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
for is_new in ("Y", "N"):
|
for is_new in ("Y", "N"):
|
||||||
rows.extend(
|
rows.extend(
|
||||||
self.query(
|
self.query(
|
||||||
@@ -206,14 +267,87 @@ class ShenwanIndustryMixin:
|
|||||||
"l2_code,l2_name,ts_code,name,in_date,out_date,is_new",
|
"l2_code,l2_name,ts_code,name,in_date,out_date,is_new",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
deduped: dict[str, dict[str, Any]] = {}
|
except TushareError:
|
||||||
for row in _reconcile_membership_rows(rows):
|
stale = self._read_local_sw_members(sector_code, allow_stale=True) or []
|
||||||
code = str(row.get("ts_code") or "")
|
if stale:
|
||||||
if code and _membership_active_on(row, trade_date):
|
return _active_members(stale, trade_date)
|
||||||
current = deduped.get(code)
|
raise
|
||||||
if current is None or str(row.get("in_date") or "") > str(current.get("in_date") or ""):
|
reconciled = _reconcile_membership_rows(rows)
|
||||||
deduped[code] = row
|
self._write_local_sw_members(sector_code, reconciled)
|
||||||
return list(deduped.values())
|
return _active_members(reconciled, trade_date)
|
||||||
|
|
||||||
|
def _read_local_sw_members(
|
||||||
|
self,
|
||||||
|
sector_code: str,
|
||||||
|
allow_stale: bool = False,
|
||||||
|
) -> list[dict[str, Any]] | None:
|
||||||
|
now = datetime.now().astimezone()
|
||||||
|
cache = getattr(self, "_sw_member_cache", None)
|
||||||
|
lock = getattr(self, "_sw_member_lock", None)
|
||||||
|
if isinstance(cache, dict) and lock is not None:
|
||||||
|
with lock:
|
||||||
|
packed = cache.get(sector_code)
|
||||||
|
if isinstance(packed, dict):
|
||||||
|
loaded_at = packed.get("loaded_at")
|
||||||
|
rows = packed.get("rows")
|
||||||
|
fresh = (
|
||||||
|
isinstance(loaded_at, datetime)
|
||||||
|
and now - loaded_at < _SW_MEMBER_TTL
|
||||||
|
)
|
||||||
|
if isinstance(rows, list) and (fresh or allow_stale):
|
||||||
|
return [dict(item) for item in rows]
|
||||||
|
path = _sw_member_path(sector_code)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
rows = list(payload.get("rows") or [])
|
||||||
|
updated = str(payload.get("updated_at") or "")
|
||||||
|
fresh = False
|
||||||
|
try:
|
||||||
|
stamped = datetime.fromisoformat(updated)
|
||||||
|
if stamped.tzinfo is None:
|
||||||
|
stamped = stamped.replace(tzinfo=now.tzinfo)
|
||||||
|
fresh = now - stamped.astimezone(now.tzinfo) < _SW_MEMBER_TTL
|
||||||
|
except ValueError:
|
||||||
|
fresh = False
|
||||||
|
if rows and (fresh or allow_stale):
|
||||||
|
self._remember_sw_members(sector_code, rows)
|
||||||
|
return rows
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _write_local_sw_members(self, sector_code: str, rows: list[dict[str, Any]]) -> None:
|
||||||
|
packed = [dict(item) for item in rows]
|
||||||
|
self._remember_sw_members(sector_code, packed)
|
||||||
|
path = _sw_member_path(sector_code)
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"sector_code": sector_code,
|
||||||
|
"updated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"rows": packed,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _remember_sw_members(self, sector_code: str, rows: list[dict[str, Any]]) -> None:
|
||||||
|
cache = getattr(self, "_sw_member_cache", None)
|
||||||
|
lock = getattr(self, "_sw_member_lock", None)
|
||||||
|
if not isinstance(cache, dict) or lock is None:
|
||||||
|
return
|
||||||
|
with lock:
|
||||||
|
cache[sector_code] = {
|
||||||
|
"loaded_at": datetime.now().astimezone(),
|
||||||
|
"rows": [dict(item) for item in rows],
|
||||||
|
}
|
||||||
|
|
||||||
def sw_sector_members(self, sector_code: str, trade_date: str) -> list[dict[str, Any]]:
|
def sw_sector_members(self, sector_code: str, trade_date: str) -> list[dict[str, Any]]:
|
||||||
"""Return constituents active in a Shenwan L2 industry on the target date."""
|
"""Return constituents active in a Shenwan L2 industry on the target date."""
|
||||||
@@ -311,37 +445,37 @@ class ShenwanIndustryMixin:
|
|||||||
finalized: bool = False,
|
finalized: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
sector_code = str(industry.get("l2_code") or "")
|
sector_code = str(industry.get("l2_code") or "")
|
||||||
sw_rows = self.query(
|
sw_row, outer_source, outer_error = self._sw_outer_realtime(
|
||||||
"rt_sw_k",
|
sector_code,
|
||||||
{"ts_code": sector_code},
|
str(industry.get("l2_name") or ""),
|
||||||
"ts_code,name,trade_time,close,pre_close,high,open,low,vol,amount,pct_change",
|
trade_date,
|
||||||
|
finalized=finalized,
|
||||||
)
|
)
|
||||||
sw_row = sw_rows[0] if sw_rows else {}
|
trade_time = str(sw_row.get("trade_time") or sw_row.get("quote_time") or "")
|
||||||
trade_time = str(sw_row.get("trade_time") or "")
|
quote_date = str(sw_row.get("quote_date") or trade_time[:10].replace("-", ""))
|
||||||
quote_date = trade_time[:10].replace("-", "")
|
quote_clock = trade_time[11:19] if len(trade_time) >= 19 else str(sw_row.get("quote_clock") or "")
|
||||||
quote_clock = trade_time[11:19] if len(trade_time) >= 19 else ""
|
|
||||||
outer_precise = bool(sw_row and quote_date == trade_date)
|
outer_precise = bool(sw_row and quote_date == trade_date)
|
||||||
if finalized and (not quote_clock or quote_clock < "15:00:00"):
|
if finalized and quote_clock and quote_clock < "15:00:00":
|
||||||
outer_precise = False
|
outer_precise = False
|
||||||
official_change = _number(sw_row.get("pct_change"))
|
official_change = _number(sw_row.get("pct_change") if sw_row.get("pct_change") not in (None, "") else sw_row.get("change"))
|
||||||
if not official_change:
|
if not official_change:
|
||||||
close = _number(sw_row.get("close"))
|
close = _number(sw_row.get("close") if sw_row.get("close") not in (None, "") else sw_row.get("price"))
|
||||||
pre_close = _number(sw_row.get("pre_close"))
|
pre_close = _number(sw_row.get("pre_close") if sw_row.get("pre_close") not in (None, "") else sw_row.get("previous_close"))
|
||||||
official_change = (close / pre_close - 1) * 100 if close and pre_close else 0
|
official_change = (close / pre_close - 1) * 100 if close and pre_close else 0
|
||||||
if not outer_precise:
|
if not outer_precise:
|
||||||
official_change = None
|
official_change = None
|
||||||
outer_error = ""
|
if not sw_row and not outer_error:
|
||||||
if not sw_row:
|
outer_error = f"申万行业 {sector_code} 当日外显待盘后正式数据或免费实时源"
|
||||||
outer_error = f"No Shenwan realtime index returned for {sector_code}"
|
elif quote_date and quote_date != trade_date:
|
||||||
elif quote_date != trade_date:
|
outer_error = f"申万实时行业日期是 {quote_date},期望 {trade_date}"
|
||||||
outer_error = f"Shenwan realtime index date is {quote_date or 'unknown'}, expected {trade_date}"
|
elif finalized and quote_clock and quote_clock < "15:00:00":
|
||||||
elif finalized and (not quote_clock or quote_clock < "15:00:00"):
|
outer_error = f"申万行业尚未形成收盘快照({trade_time})"
|
||||||
outer_error = f"Shenwan realtime index is not a close snapshot ({trade_time})"
|
|
||||||
|
|
||||||
valid: list[dict[str, Any]] = []
|
valid: list[dict[str, Any]] = []
|
||||||
codes: list[str] = []
|
codes: list[str] = []
|
||||||
reference: dict[str, Any] = {}
|
reference: dict[str, Any] = {}
|
||||||
inner_error = ""
|
inner_error = ""
|
||||||
|
inner_source = "unavailable"
|
||||||
try:
|
try:
|
||||||
reference = self._load_realtime_reference(trade_date, previous_trade_date)
|
reference = self._load_realtime_reference(trade_date, previous_trade_date)
|
||||||
active_codes = {
|
active_codes = {
|
||||||
@@ -352,20 +486,27 @@ class ShenwanIndustryMixin:
|
|||||||
codes = [
|
codes = [
|
||||||
str(row.get("ts_code") or "")
|
str(row.get("ts_code") or "")
|
||||||
for row in members
|
for row in members
|
||||||
if str(row.get("ts_code") or "") in active_codes
|
if str(row.get("ts_code") or "")
|
||||||
]
|
]
|
||||||
if codes:
|
if active_codes:
|
||||||
quotes = self.query("rt_k", {"ts_code": ",".join(codes)}, "")
|
listed = [code for code in codes if code in active_codes]
|
||||||
|
if listed:
|
||||||
|
codes = listed
|
||||||
|
quotes, inner_source = self._load_member_realtime_quotes(codes, trade_date)
|
||||||
for row in quotes:
|
for row in quotes:
|
||||||
close = _number(row.get("close"))
|
close = _number(row.get("close"))
|
||||||
previous_close = _number(row.get("pre_close"))
|
previous_close = _number(row.get("pre_close"))
|
||||||
if close <= 0 or previous_close <= 0:
|
if close <= 0 or previous_close <= 0:
|
||||||
continue
|
continue
|
||||||
valid.append({**row, "change": (close / previous_close - 1) * 100})
|
valid.append({**row, "change": (close / previous_close - 1) * 100})
|
||||||
else:
|
if not codes:
|
||||||
inner_error = f"No active Shenwan members returned for {sector_code}"
|
inner_error = f"No active Shenwan members returned for {sector_code}"
|
||||||
|
elif not quotes:
|
||||||
|
inner_error = f"申万成分实时行情暂不可用:{sector_code}"
|
||||||
except TushareError as exc:
|
except TushareError as exc:
|
||||||
inner_error = str(exc)
|
inner_error = str(exc)
|
||||||
|
if "rt_k" in inner_error or "权限" in inner_error:
|
||||||
|
inner_error = "申万成分实时行情暂不可用,已避开无权限接口"
|
||||||
|
|
||||||
coverage = len(valid) / max(len(codes), 1) * 100
|
coverage = len(valid) / max(len(codes), 1) * 100
|
||||||
valid_codes = {str(item.get("ts_code") or "") for item in valid}
|
valid_codes = {str(item.get("ts_code") or "") for item in valid}
|
||||||
@@ -389,17 +530,25 @@ class ShenwanIndustryMixin:
|
|||||||
for item in members
|
for item in members
|
||||||
}
|
}
|
||||||
equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0
|
equal_change = sum(item["change"] for item in valid) / len(valid) if valid else 0
|
||||||
|
if not outer_precise and inner_precise:
|
||||||
|
official_change = equal_change
|
||||||
|
outer_precise = True
|
||||||
|
outer_source = "sw_member_equal_realtime"
|
||||||
|
outer_error = ""
|
||||||
|
quote_date = trade_date
|
||||||
amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000
|
amount_billion = sum(_number(item.get("amount")) for item in valid) / 100000000
|
||||||
|
market_rows: list[dict[str, Any]] = []
|
||||||
try:
|
try:
|
||||||
self._ensure_realtime_market_cache(trade_date)
|
market_rows = self._ensure_realtime_market_cache(trade_date)
|
||||||
with self._realtime_reference_lock:
|
|
||||||
market_rows = list(
|
|
||||||
(self._latest_realtime_market.get(trade_date) or {}).get("rows") or []
|
|
||||||
)
|
|
||||||
except TushareError as exc:
|
except TushareError as exc:
|
||||||
market_rows = []
|
message = str(exc)
|
||||||
|
if "rt_k" in message or "权限" in message:
|
||||||
|
market_error = "全市场实时行情暂不可用,已避开无权限接口"
|
||||||
|
else:
|
||||||
|
market_error = message
|
||||||
|
if not valid:
|
||||||
inner_precise = False
|
inner_precise = False
|
||||||
inner_error = inner_error or str(exc)
|
inner_error = inner_error or market_error
|
||||||
capital_map = {
|
capital_map = {
|
||||||
str(item.get("ts_code") or ""): item
|
str(item.get("ts_code") or ""): item
|
||||||
for item in reference.get("capital_rows") or []
|
for item in reference.get("capital_rows") or []
|
||||||
@@ -408,20 +557,42 @@ class ShenwanIndustryMixin:
|
|||||||
for item in valid:
|
for item in valid:
|
||||||
capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
||||||
float_share = _number(capital.get("float_share"))
|
float_share = _number(capital.get("float_share"))
|
||||||
if float_share:
|
previous = _number(
|
||||||
sector_turnovers.append(_number(item.get("vol")) / float_share / 100)
|
item.get("pre_close")
|
||||||
|
if item.get("pre_close") not in (None, "")
|
||||||
|
else item.get("previous_close")
|
||||||
|
)
|
||||||
|
if not float_share and previous:
|
||||||
|
float_share = _number(capital.get("circ_mv")) / previous
|
||||||
|
volume = _number(item.get("vol"))
|
||||||
|
if float_share and volume:
|
||||||
|
# 免费源成交量为股;daily_basic.float_share 为万股。
|
||||||
|
sector_turnovers.append(volume / float_share / 100)
|
||||||
market_turnovers = []
|
market_turnovers = []
|
||||||
for item in market_rows:
|
for item in market_rows:
|
||||||
capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
capital = capital_map.get(str(item.get("ts_code") or ""), {})
|
||||||
float_share = _number(capital.get("float_share"))
|
float_share = _number(capital.get("float_share"))
|
||||||
if float_share:
|
previous = _number(
|
||||||
market_turnovers.append(_number(item.get("vol")) / float_share / 100)
|
item.get("pre_close")
|
||||||
|
if item.get("pre_close") not in (None, "")
|
||||||
|
else item.get("previous_close")
|
||||||
|
)
|
||||||
|
if not float_share and previous:
|
||||||
|
float_share = _number(capital.get("circ_mv")) / previous
|
||||||
|
volume = _number(item.get("vol"))
|
||||||
|
if float_share and volume:
|
||||||
|
market_turnovers.append(volume / float_share / 100)
|
||||||
average_turnover = sum(sector_turnovers) / len(sector_turnovers) if sector_turnovers else 0
|
average_turnover = sum(sector_turnovers) / len(sector_turnovers) if sector_turnovers else 0
|
||||||
market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0
|
market_turnover = sum(market_turnovers) / len(market_turnovers) if market_turnovers else 0
|
||||||
relative_turnover = average_turnover / market_turnover if market_turnover else 0
|
relative_turnover = average_turnover / market_turnover if market_turnover else 0
|
||||||
if not relative_turnover:
|
delayed = "delayed" in str(inner_source) or any(item.get("delayed") for item in valid)
|
||||||
inner_precise = False
|
delay_seconds = max((int(item.get("delay_seconds") or 0) for item in valid), default=0)
|
||||||
inner_error = inner_error or "Shenwan member relative turnover is unavailable"
|
delay_notice = ""
|
||||||
|
if delayed:
|
||||||
|
delay_notice = next(
|
||||||
|
(str(item.get("delay_notice") or "") for item in valid if item.get("delay_notice")),
|
||||||
|
"",
|
||||||
|
) or f"主备免费行情均暂不可用,显示最近一次真实快照(延迟 {delay_seconds} 秒)"
|
||||||
return {
|
return {
|
||||||
"code": sector_code,
|
"code": sector_code,
|
||||||
"name": str(industry.get("l2_name") or sw_row.get("name") or ""),
|
"name": str(industry.get("l2_name") or sw_row.get("name") or ""),
|
||||||
@@ -447,9 +618,9 @@ class ShenwanIndustryMixin:
|
|||||||
"amount_billion": round(amount_billion, 2),
|
"amount_billion": round(amount_billion, 2),
|
||||||
"count": sum(item["change"] >= 9.5 for item in valid),
|
"count": sum(item["change"] >= 9.5 for item in valid),
|
||||||
"max_streak": 0,
|
"max_streak": 0,
|
||||||
"source": "tushare_rt_sw_k+sw_members_rt_k",
|
"source": f"{outer_source or 'unavailable'}+{inner_source}",
|
||||||
"inner_source": "tushare_sw_members+rt_k",
|
"inner_source": inner_source,
|
||||||
"outer_source": "tushare_rt_sw_k",
|
"outer_source": outer_source or "unavailable",
|
||||||
"taxonomy": "sw_l2",
|
"taxonomy": "sw_l2",
|
||||||
"industry": industry,
|
"industry": industry,
|
||||||
"trade_date": trade_date,
|
"trade_date": trade_date,
|
||||||
@@ -463,10 +634,101 @@ class ShenwanIndustryMixin:
|
|||||||
"precise": inner_precise and outer_precise,
|
"precise": inner_precise and outer_precise,
|
||||||
"inner_error": inner_error,
|
"inner_error": inner_error,
|
||||||
"outer_error": outer_error,
|
"outer_error": outer_error,
|
||||||
|
"delayed": delayed,
|
||||||
|
"delay_seconds": delay_seconds,
|
||||||
|
"delay_notice": delay_notice,
|
||||||
"schema_version": 6,
|
"schema_version": 6,
|
||||||
"methodology": "外显使用申万官方 rt_sw_k;内核独立使用申万成分 rt_k 宽度与相对换手聚合",
|
"methodology": "外显使用已发布 sw_daily 或免费申万实时;内核使用数据中枢/免费实时成分,不调用 rt_sw_k",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _sw_outer_realtime(
|
||||||
|
self,
|
||||||
|
sector_code: str,
|
||||||
|
sector_name: str,
|
||||||
|
trade_date: str,
|
||||||
|
finalized: bool = False,
|
||||||
|
) -> tuple[dict[str, Any], str, str]:
|
||||||
|
hub = getattr(self, "try_sector_quote", None)
|
||||||
|
if callable(hub):
|
||||||
|
try:
|
||||||
|
row = hub(sector_code, "" if finalized else trade_date)
|
||||||
|
except Exception as exc:
|
||||||
|
message = str(exc)
|
||||||
|
if finalized:
|
||||||
|
return {}, "", f"申万行业 {sector_code} 盘后正式数据待入库"
|
||||||
|
return {}, "", f"数据中枢申万实时暂不可用:{message[:180]}"
|
||||||
|
if row:
|
||||||
|
actual_name = str(row.get("name") or "").strip()
|
||||||
|
if sector_name and actual_name != str(sector_name).strip():
|
||||||
|
return {}, "", (
|
||||||
|
f"数据中枢返回的行业名称不匹配:期望 {sector_name},实际 {actual_name or '--'}"
|
||||||
|
)
|
||||||
|
return dict(row), str(row.get("source") or "datahub"), ""
|
||||||
|
if finalized:
|
||||||
|
return {}, "", f"申万行业 {sector_code} 当日盘后正式数据尚未入库"
|
||||||
|
return {}, "", f"申万行业 {sector_code} 当日外显待补充"
|
||||||
|
|
||||||
|
def _load_member_realtime_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
trade_date: str,
|
||||||
|
) -> tuple[list[dict[str, Any]], str]:
|
||||||
|
wanted = [str(code).strip() for code in codes if str(code or "").strip()]
|
||||||
|
if not wanted:
|
||||||
|
return [], "unavailable"
|
||||||
|
best_rows: list[dict[str, Any]] = []
|
||||||
|
best_source = "unavailable"
|
||||||
|
|
||||||
|
def consider(rows: list[dict[str, Any]] | None, source: str) -> list[dict[str, Any]]:
|
||||||
|
nonlocal best_rows, best_source
|
||||||
|
filtered = _filter_quotes_for_codes(rows, wanted)
|
||||||
|
if len(filtered) > len(best_rows):
|
||||||
|
best_rows = filtered
|
||||||
|
best_source = source
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
hub_market = getattr(self, "try_market_quotes", None)
|
||||||
|
if callable(hub_market):
|
||||||
|
filtered = consider(hub_market(trade_date) or [], "datahub")
|
||||||
|
if len(filtered) >= max(1, int(len(wanted) * 0.9)):
|
||||||
|
delayed = any(item.get("delayed") for item in filtered)
|
||||||
|
return filtered, "datahub_delayed" if delayed else "datahub"
|
||||||
|
|
||||||
|
hub = getattr(self, "try_quotes", None)
|
||||||
|
if callable(hub):
|
||||||
|
collected: list[dict[str, Any]] = []
|
||||||
|
for index in range(0, len(wanted), _QUOTE_BATCH):
|
||||||
|
collected.extend(hub(wanted[index:index + _QUOTE_BATCH]) or [])
|
||||||
|
filtered = consider(collected, "datahub")
|
||||||
|
if len(filtered) >= max(1, int(len(wanted) * 0.9)):
|
||||||
|
delayed = any(item.get("delayed") for item in filtered)
|
||||||
|
return filtered, "datahub_delayed" if delayed else "datahub"
|
||||||
|
|
||||||
|
if best_rows:
|
||||||
|
delayed = any(item.get("delayed") for item in best_rows)
|
||||||
|
if delayed and not str(best_source).endswith("_delayed"):
|
||||||
|
return best_rows, f"{best_source}_delayed"
|
||||||
|
return best_rows, best_source
|
||||||
|
return [], "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
_QUOTE_BATCH = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_quotes_for_codes(
|
||||||
|
rows: list[dict[str, Any]] | None,
|
||||||
|
codes: list[str],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
wanted = {str(code) for code in codes if code}
|
||||||
|
filtered: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for row in rows or []:
|
||||||
|
ts_code = str(row.get("ts_code") or "")
|
||||||
|
if ts_code in wanted and ts_code not in seen:
|
||||||
|
seen.add(ts_code)
|
||||||
|
filtered.append(row)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
def _filter_members_by_listing(
|
def _filter_members_by_listing(
|
||||||
members: list[dict[str, Any]],
|
members: list[dict[str, Any]],
|
||||||
@@ -568,6 +830,22 @@ def _reconcile_membership_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any
|
|||||||
return list(reconciled.values())
|
return list(reconciled.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _sw_member_path(sector_code: str) -> Path:
|
||||||
|
safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in str(sector_code or ""))
|
||||||
|
return _SW_MEMBER_DIR / f"{safe or 'unknown'}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _active_members(rows: list[dict[str, Any]], trade_date: str) -> list[dict[str, Any]]:
|
||||||
|
deduped: dict[str, dict[str, Any]] = {}
|
||||||
|
for row in rows:
|
||||||
|
code = str(row.get("ts_code") or "")
|
||||||
|
if code and _membership_active_on(row, trade_date):
|
||||||
|
current = deduped.get(code)
|
||||||
|
if current is None or str(row.get("in_date") or "") > str(current.get("in_date") or ""):
|
||||||
|
deduped[code] = dict(row)
|
||||||
|
return list(deduped.values())
|
||||||
|
|
||||||
|
|
||||||
def _match_sector_row(rows: list[dict[str, Any]], identifier: str) -> dict[str, Any] | None:
|
def _match_sector_row(rows: list[dict[str, Any]], identifier: str) -> dict[str, Any] | None:
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ from typing import Any
|
|||||||
|
|
||||||
from backend.bootstrap.config import display_compact_date as _display_date
|
from backend.bootstrap.config import display_compact_date as _display_date
|
||||||
from backend.data.numbers import finite_number as _number
|
from backend.data.numbers import finite_number as _number
|
||||||
|
from backend.data.providers.tushare_helpers import _moneyflow_payload
|
||||||
|
|
||||||
|
|
||||||
class StockMixin:
|
class StockMixin:
|
||||||
def stock_detail(self, ts_code: str, requested_date: str) -> dict[str, Any]:
|
def stock_detail(self, ts_code: str, requested_date: str) -> dict[str, Any]:
|
||||||
trade_date, _ = self.resolve_trade_context(requested_date)
|
trade_date, _ = self.resolve_trade_context(requested_date)
|
||||||
end = datetime.strptime(trade_date, "%Y%m%d")
|
end = datetime.strptime(trade_date, "%Y%m%d")
|
||||||
start_date = (end - timedelta(days=190)).strftime("%Y%m%d")
|
start_date = (end - timedelta(days=400)).strftime("%Y%m%d")
|
||||||
daily = self.query(
|
daily = self.query(
|
||||||
"daily",
|
"daily",
|
||||||
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
|
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
|
||||||
@@ -41,7 +42,7 @@ class StockMixin:
|
|||||||
factor_map = {row["trade_date"]: _number(row.get("adj_factor"), 1) for row in factors}
|
factor_map = {row["trade_date"]: _number(row.get("adj_factor"), 1) for row in factors}
|
||||||
latest_factor = max(factor_map.values(), default=1) or 1
|
latest_factor = max(factor_map.values(), default=1) or 1
|
||||||
prices = []
|
prices = []
|
||||||
for row in sorted(daily, key=lambda item: item.get("trade_date", ""))[-90:]:
|
for row in sorted(daily, key=lambda item: item.get("trade_date", ""))[-250:]:
|
||||||
factor = factor_map.get(row.get("trade_date"), latest_factor)
|
factor = factor_map.get(row.get("trade_date"), latest_factor)
|
||||||
ratio = factor / latest_factor
|
ratio = factor / latest_factor
|
||||||
prices.append(
|
prices.append(
|
||||||
@@ -56,7 +57,7 @@ class StockMixin:
|
|||||||
"amount_billion": round(_number(row.get("amount")) / 100000, 2),
|
"amount_billion": round(_number(row.get("amount")) / 100000, 2),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
flow = moneyflow[0] if moneyflow else {}
|
flow = moneyflow[0] if moneyflow else None
|
||||||
basic = basics[0] if basics else {}
|
basic = basics[0] if basics else {}
|
||||||
daily_basic = daily_basics[0] if daily_basics else {}
|
daily_basic = daily_basics[0] if daily_basics else {}
|
||||||
latest = prices[-1] if prices else {}
|
latest = prices[-1] if prices else {}
|
||||||
@@ -87,22 +88,7 @@ class StockMixin:
|
|||||||
"amount_billion": latest.get("amount_billion", 0),
|
"amount_billion": latest.get("amount_billion", 0),
|
||||||
},
|
},
|
||||||
"prices": prices,
|
"prices": prices,
|
||||||
"moneyflow": {
|
"moneyflow": _moneyflow_payload(flow),
|
||||||
"net_million": round(_number(flow.get("net_mf_amount")) / 100, 2),
|
|
||||||
"large_million": round(
|
|
||||||
(_number(flow.get("buy_lg_amount")) + _number(flow.get("buy_elg_amount"))
|
|
||||||
- _number(flow.get("sell_lg_amount")) - _number(flow.get("sell_elg_amount"))) / 100,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"medium_million": round(
|
|
||||||
(_number(flow.get("buy_md_amount")) - _number(flow.get("sell_md_amount"))) / 100,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
"small_million": round(
|
|
||||||
(_number(flow.get("buy_sm_amount")) - _number(flow.get("sell_sm_amount"))) / 100,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def stock_intraday(self, ts_code: str, requested_date: str) -> dict[str, Any]:
|
def stock_intraday(self, ts_code: str, requested_date: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class TushareTransportMixin:
|
|||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
fields: str = "",
|
fields: str = "",
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
if api_name == "rt_sw_k":
|
||||||
|
raise TushareError("rt_sw_k is disabled; use published sw_daily or free Shenwan realtime")
|
||||||
payload = json.dumps(
|
payload = json.dumps(
|
||||||
{
|
{
|
||||||
"api_name": api_name,
|
"api_name": api_name,
|
||||||
|
|||||||
+200
-7
@@ -20,8 +20,10 @@ class RealtimeAggregateError(RuntimeError):
|
|||||||
|
|
||||||
EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
|
EASTMONEY_INDEX_URL = "https://push2.eastmoney.com/api/qt/ulist.np/get"
|
||||||
EASTMONEY_STOCK_URL = "https://push2.eastmoney.com/api/qt/stock/get"
|
EASTMONEY_STOCK_URL = "https://push2.eastmoney.com/api/qt/stock/get"
|
||||||
EASTMONEY_STOCK_FIELDS = "f43,f44,f45,f46,f47,f48,f57,f58,f60,f86,f168"
|
EASTMONEY_STOCK_FIELDS = "f43,f44,f45,f46,f47,f48,f57,f58,f60,f86,f168,f62,f66,f72,f78,f84"
|
||||||
EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get"
|
EASTMONEY_SECTOR_URL = "https://push2.eastmoney.com/api/qt/clist/get"
|
||||||
|
EASTMONEY_ZT_POOL_URL = "https://push2ex.eastmoney.com/getTopicZTPool"
|
||||||
|
EASTMONEY_ZB_POOL_URL = "https://push2ex.eastmoney.com/getTopicZBPool"
|
||||||
EASTMONEY_A_SHARE_BOARDS = (
|
EASTMONEY_A_SHARE_BOARDS = (
|
||||||
"m:0+t:6",
|
"m:0+t:6",
|
||||||
"m:0+t:80",
|
"m:0+t:80",
|
||||||
@@ -234,6 +236,15 @@ class WebRealtimeAggregator:
|
|||||||
self,
|
self,
|
||||||
codes: list[str],
|
codes: list[str],
|
||||||
expected_date: str = "",
|
expected_date: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
quotes = self.tencent_stock_quotes(codes, expected_date="", minimum=200)
|
||||||
|
return self._filter_quotes_by_date(quotes, expected_date)
|
||||||
|
|
||||||
|
def tencent_stock_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
expected_date: str = "",
|
||||||
|
minimum: int | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
symbols: list[str] = []
|
symbols: list[str] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@@ -278,10 +289,11 @@ class WebRealtimeAggregator:
|
|||||||
quotes.extend(rows)
|
quotes.extend(rows)
|
||||||
elif not status.get("ok"):
|
elif not status.get("ok"):
|
||||||
errors.append(str(status.get("error") or "batch failed"))
|
errors.append(str(status.get("error") or "batch failed"))
|
||||||
if len(quotes) < 200:
|
floor = minimum if minimum is not None else max(1, int(len(symbols) * 0.5))
|
||||||
|
if len(quotes) < floor:
|
||||||
detail = f";{'; '.join(errors[:3])}" if errors else ""
|
detail = f";{'; '.join(errors[:3])}" if errors else ""
|
||||||
raise RealtimeAggregateError(
|
raise RealtimeAggregateError(
|
||||||
f"Tencent market snapshot too small: {len(quotes)}{detail}"
|
f"Tencent quotes too small: {len(quotes)}/{len(symbols)}{detail}"
|
||||||
)
|
)
|
||||||
return self._filter_quotes_by_date(quotes, expected_date)
|
return self._filter_quotes_by_date(quotes, expected_date)
|
||||||
|
|
||||||
@@ -321,6 +333,127 @@ class WebRealtimeAggregator:
|
|||||||
raise RealtimeAggregateError(f"Eastmoney stock quote unavailable for {ts_code}")
|
raise RealtimeAggregateError(f"Eastmoney stock quote unavailable for {ts_code}")
|
||||||
return _require_quote_date(quote, expected_date)
|
return _require_quote_date(quote, expected_date)
|
||||||
|
|
||||||
|
def eastmoney_stock_quotes(
|
||||||
|
self,
|
||||||
|
codes: list[str],
|
||||||
|
expected_date: str = "",
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
secids = []
|
||||||
|
for code in codes:
|
||||||
|
try:
|
||||||
|
_symbol, secid, _ts = _a_share_identity(code)
|
||||||
|
except RealtimeAggregateError:
|
||||||
|
continue
|
||||||
|
secids.append(secid)
|
||||||
|
quotes: list[dict[str, Any]] = []
|
||||||
|
for index in range(0, len(secids), 60):
|
||||||
|
payload = self._get_json(
|
||||||
|
EASTMONEY_INDEX_URL,
|
||||||
|
{
|
||||||
|
"secids": ",".join(secids[index:index + 60]),
|
||||||
|
"fltt": "2",
|
||||||
|
"invt": "2",
|
||||||
|
"fields": EASTMONEY_QUOTE_FIELDS,
|
||||||
|
},
|
||||||
|
referer="https://quote.eastmoney.com/",
|
||||||
|
)
|
||||||
|
for row in _diff_rows(payload.get("data") or {}):
|
||||||
|
quote = _normalize_eastmoney_quote(row)
|
||||||
|
if quote:
|
||||||
|
quotes.append(quote)
|
||||||
|
return self._filter_quotes_by_date(quotes, expected_date)
|
||||||
|
|
||||||
|
def eastmoney_shenwan_quote(
|
||||||
|
self,
|
||||||
|
ts_code: str,
|
||||||
|
expected_date: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
code = str(ts_code or "").split(".")[0]
|
||||||
|
if not code:
|
||||||
|
raise RealtimeAggregateError("Invalid Shenwan code")
|
||||||
|
payload = self._get_json(
|
||||||
|
EASTMONEY_INDEX_URL,
|
||||||
|
{
|
||||||
|
"secids": f"90.{code}",
|
||||||
|
"fltt": "2",
|
||||||
|
"invt": "2",
|
||||||
|
"fields": "f12,f14,f2,f3,f4,f15,f16,f17,f18,f6,f8,f104,f105,f128,f136,f140,f124",
|
||||||
|
},
|
||||||
|
referer="https://quote.eastmoney.com/",
|
||||||
|
)
|
||||||
|
row = next((item for item in _diff_rows(payload.get("data") or {}) if item), None)
|
||||||
|
if not row:
|
||||||
|
raise RealtimeAggregateError(f"Eastmoney Shenwan quote missing for {code}")
|
||||||
|
epoch = int(_number(row.get("f124")))
|
||||||
|
quote_time = (
|
||||||
|
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
||||||
|
if epoch
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
close = _number(row.get("f2"))
|
||||||
|
previous = _number(row.get("f18"))
|
||||||
|
if close <= 0 or previous <= 0:
|
||||||
|
raise RealtimeAggregateError(f"Eastmoney Shenwan quote empty for {code}")
|
||||||
|
result = {
|
||||||
|
"ts_code": f"{code}.SI",
|
||||||
|
"code": f"{code}.SI",
|
||||||
|
"name": row.get("f14") or code,
|
||||||
|
"price": close,
|
||||||
|
"close": close,
|
||||||
|
"pre_close": previous,
|
||||||
|
"previous_close": previous,
|
||||||
|
"open": _number(row.get("f17")),
|
||||||
|
"high": _number(row.get("f15")),
|
||||||
|
"low": _number(row.get("f16")),
|
||||||
|
"change": _number(row.get("f3")),
|
||||||
|
"pct_change": _number(row.get("f3")),
|
||||||
|
"amount": _number(row.get("f6")),
|
||||||
|
"leader": row.get("f128") or "--",
|
||||||
|
"leader_code": row.get("f140") or "",
|
||||||
|
"leading_pct": _number(row.get("f136")),
|
||||||
|
"up_count": int(_number(row.get("f104"))),
|
||||||
|
"down_count": int(_number(row.get("f105"))),
|
||||||
|
"quote_time": quote_time,
|
||||||
|
"trade_time": quote_time,
|
||||||
|
"quote_date": datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d") if epoch else "",
|
||||||
|
"quote_time_epoch": epoch,
|
||||||
|
"source": "eastmoney_sw",
|
||||||
|
}
|
||||||
|
return _require_quote_date(result, expected_date) if expected_date else result
|
||||||
|
|
||||||
|
def eastmoney_limit_pool(self, trade_date: str = "") -> list[dict[str, Any]]:
|
||||||
|
day = str(trade_date or "").replace("-", "")
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for url, limit_type in (
|
||||||
|
(EASTMONEY_ZT_POOL_URL, "U"),
|
||||||
|
(EASTMONEY_ZB_POOL_URL, "Z"),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
payload = self._get_json(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
"ut": "7eea3edcaed734bea9cbfc24409ed989",
|
||||||
|
"dpt": "wz.ztzt",
|
||||||
|
"PageIndex": "0",
|
||||||
|
"PageSize": "200",
|
||||||
|
"sort": "fbt:asc",
|
||||||
|
"date": day,
|
||||||
|
},
|
||||||
|
referer="https://quote.eastmoney.com/ztb/detail",
|
||||||
|
)
|
||||||
|
except RealtimeAggregateError:
|
||||||
|
continue
|
||||||
|
pool = (payload.get("data") or {}).get("pool") or []
|
||||||
|
if isinstance(pool, dict):
|
||||||
|
pool = list(pool.values())
|
||||||
|
for item in pool:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
parsed = _normalize_eastmoney_limit_row(item, limit_type)
|
||||||
|
if parsed:
|
||||||
|
rows.append(parsed)
|
||||||
|
return rows
|
||||||
|
|
||||||
def tencent_indices(self) -> list[dict[str, Any]]:
|
def tencent_indices(self) -> list[dict[str, Any]]:
|
||||||
raw, cache_age = self._get_text(
|
raw, cache_age = self._get_text(
|
||||||
TENCENT_INDEX_URL,
|
TENCENT_INDEX_URL,
|
||||||
@@ -372,11 +505,17 @@ class WebRealtimeAggregator:
|
|||||||
if not matched:
|
if not matched:
|
||||||
raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
|
raise RealtimeAggregateError(f"Eastmoney sector not found: {query}")
|
||||||
epoch = int(_number(matched.get("f124")))
|
epoch = int(_number(matched.get("f124")))
|
||||||
|
quote_time = (
|
||||||
|
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
||||||
|
if epoch else ""
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"code": matched.get("f12") or "",
|
"code": matched.get("f12") or "",
|
||||||
"name": matched.get("f14") or query,
|
"name": matched.get("f14") or query,
|
||||||
"price": _number(matched.get("f2")),
|
"price": _number(matched.get("f2")),
|
||||||
|
"close": _number(matched.get("f2")),
|
||||||
"change": _number(matched.get("f3")),
|
"change": _number(matched.get("f3")),
|
||||||
|
"pct_change": _number(matched.get("f3")),
|
||||||
"change_amount": _number(matched.get("f4")),
|
"change_amount": _number(matched.get("f4")),
|
||||||
"turnover_rate": _number(matched.get("f8")),
|
"turnover_rate": _number(matched.get("f8")),
|
||||||
"up_count": int(_number(matched.get("f104"))),
|
"up_count": int(_number(matched.get("f104"))),
|
||||||
@@ -385,10 +524,9 @@ class WebRealtimeAggregator:
|
|||||||
"leader_code": matched.get("f140") or "",
|
"leader_code": matched.get("f140") or "",
|
||||||
"leading_pct": _number(matched.get("f136")),
|
"leading_pct": _number(matched.get("f136")),
|
||||||
"quote_time_epoch": epoch,
|
"quote_time_epoch": epoch,
|
||||||
"quote_time": (
|
"quote_time": quote_time,
|
||||||
datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
|
"trade_time": quote_time,
|
||||||
if epoch else ""
|
"quote_date": datetime.fromtimestamp(epoch).astimezone().strftime("%Y%m%d") if epoch else "",
|
||||||
),
|
|
||||||
"source": "eastmoney_push2",
|
"source": "eastmoney_push2",
|
||||||
"match_query": query,
|
"match_query": query,
|
||||||
}
|
}
|
||||||
@@ -636,6 +774,10 @@ def _normalize_eastmoney_stock_quote(
|
|||||||
"quote_date": quote_date,
|
"quote_date": quote_date,
|
||||||
"quote_time_epoch": epoch,
|
"quote_time_epoch": epoch,
|
||||||
"turnover_rate": _number(row.get("f168")),
|
"turnover_rate": _number(row.get("f168")),
|
||||||
|
"net_mf_amount": _eastmoney_flow_wan(row.get("f62")),
|
||||||
|
"large_amount": _eastmoney_flow_wan(row.get("f62")),
|
||||||
|
"medium_amount": _eastmoney_flow_wan(row.get("f78")),
|
||||||
|
"small_amount": _eastmoney_flow_wan(row.get("f84")),
|
||||||
"source": "eastmoney_stock",
|
"source": "eastmoney_stock",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -721,6 +863,57 @@ def _normalize_eastmoney_quote(row: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _eastmoney_flow_wan(value: Any) -> float | None:
|
||||||
|
if value in (None, "", "-"):
|
||||||
|
return None
|
||||||
|
amount = _number(value, default=float("nan"))
|
||||||
|
if amount != amount:
|
||||||
|
return None
|
||||||
|
return amount / 10000
|
||||||
|
|
||||||
|
|
||||||
|
def _board_clock(value: Any) -> str:
|
||||||
|
digits = "".join(character for character in str(value or "") if character.isdigit())
|
||||||
|
if len(digits) >= 6:
|
||||||
|
return f"{digits[:2]}:{digits[2:4]}:{digits[4:6]}"
|
||||||
|
if len(digits) == 5:
|
||||||
|
digits = digits.zfill(6)
|
||||||
|
return f"{digits[:2]}:{digits[2:4]}:{digits[4:6]}"
|
||||||
|
if len(digits) == 4:
|
||||||
|
return f"{digits[:2]}:{digits[2:]}:00"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_eastmoney_limit_row(row: dict[str, Any], limit_type: str) -> dict[str, Any] | None:
|
||||||
|
symbol = str(row.get("c") or row.get("code") or "").strip()
|
||||||
|
if not symbol.isdigit() or len(symbol) != 6:
|
||||||
|
return None
|
||||||
|
market = int(_number(row.get("m") if row.get("m") not in (None, "") else row.get("market")))
|
||||||
|
if market == 1 or symbol.startswith(("5", "6", "9")):
|
||||||
|
ts_code = f"{symbol}.SH"
|
||||||
|
elif symbol.startswith(("4", "8")):
|
||||||
|
ts_code = f"{symbol}.BJ"
|
||||||
|
else:
|
||||||
|
ts_code = f"{symbol}.SZ"
|
||||||
|
first_time = _board_clock(row.get("fbt") if row.get("fbt") not in (None, "") else row.get("first_time"))
|
||||||
|
last_time = _board_clock(row.get("lbt") if row.get("lbt") not in (None, "") else row.get("last_time"))
|
||||||
|
fund = row.get("fund")
|
||||||
|
if fund in (None, ""):
|
||||||
|
fund = row.get("fd_amount")
|
||||||
|
return {
|
||||||
|
"ts_code": ts_code,
|
||||||
|
"name": row.get("n") or row.get("name") or symbol,
|
||||||
|
"limit_type": limit_type,
|
||||||
|
"first_time": first_time or None,
|
||||||
|
"last_time": last_time or None,
|
||||||
|
"open_times": int(_number(row.get("zbc") if row.get("zbc") not in (None, "") else row.get("open_times"))),
|
||||||
|
"limit_times": max(1, int(_number(row.get("lbc") if row.get("lbc") not in (None, "") else 1))),
|
||||||
|
"turnover_ratio": _number(row.get("hs") if row.get("hs") not in (None, "") else row.get("turnover_ratio")),
|
||||||
|
"fd_amount": _number(fund) if fund not in (None, "", "-") else None,
|
||||||
|
"source": "eastmoney_zt_pool",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_sector(value: Any) -> str:
|
def _normalize_sector(value: Any) -> str:
|
||||||
text = str(value or "").strip().replace(" ", "")
|
text = str(value or "").strip().replace(" ", "")
|
||||||
for suffix in ("板块", "概念", "行业", "Ⅱ", "Ⅲ", "(A股)", "(A股)"):
|
for suffix in ("板块", "概念", "行业", "Ⅱ", "Ⅲ", "(A股)", "(A股)"):
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from .m0002_job_runs import MIGRATION as M0002_JOB_RUNS
|
|||||||
from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT
|
from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT
|
||||||
from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES
|
from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES
|
||||||
from .m0005_account_switch_grants import MIGRATION as M0005_ACCOUNT_SWITCH_GRANTS
|
from .m0005_account_switch_grants import MIGRATION as M0005_ACCOUNT_SWITCH_GRANTS
|
||||||
|
from .m0006_invite_codes import MIGRATION as M0006_INVITE_CODES
|
||||||
from .runner import Migration, MigrationError, MigrationRunner
|
from .runner import Migration, MigrationError, MigrationRunner
|
||||||
|
|
||||||
MIGRATIONS = (
|
MIGRATIONS = (
|
||||||
@@ -11,6 +12,7 @@ MIGRATIONS = (
|
|||||||
M0003_LLM_AUDIT,
|
M0003_LLM_AUDIT,
|
||||||
M0004_MENTOR_NOTES,
|
M0004_MENTOR_NOTES,
|
||||||
M0005_ACCOUNT_SWITCH_GRANTS,
|
M0005_ACCOUNT_SWITCH_GRANTS,
|
||||||
|
M0006_INVITE_CODES,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
|
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from backend.database.migrations.runner import Migration
|
||||||
|
|
||||||
|
|
||||||
|
def create_invite_codes(connection: sqlite3.Connection) -> None:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||||
|
code TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unused',
|
||||||
|
note TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
used_by INTEGER,
|
||||||
|
used_at TEXT NOT NULL DEFAULT '',
|
||||||
|
revoked_at TEXT NOT NULL DEFAULT '',
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invite_codes_status
|
||||||
|
ON invite_codes(status, created_at DESC)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
MIGRATION = Migration(
|
||||||
|
version="0006",
|
||||||
|
name="create_invite_codes",
|
||||||
|
action=create_invite_codes,
|
||||||
|
signature="invite-codes:v1:code,status,note,created,used,revoked",
|
||||||
|
)
|
||||||
@@ -28,8 +28,14 @@ class AccountApplicationMixin:
|
|||||||
def update_membership(self, payload: dict[str, Any]) -> None:
|
def update_membership(self, payload: dict[str, Any]) -> None:
|
||||||
self.accounts.update_membership(payload)
|
self.accounts.update_membership(payload)
|
||||||
|
|
||||||
def register_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
def register_account(
|
||||||
return self.accounts.register(username, password, device_hash)
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
device_hash: str = "",
|
||||||
|
invite_code: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return self.accounts.register(username, password, device_hash, invite_code)
|
||||||
|
|
||||||
def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||||
return self.accounts.login(username, password, device_hash)
|
return self.accounts.login(username, password, device_hash)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class AccountHttpMixin:
|
|||||||
str(body.get("username") or ""),
|
str(body.get("username") or ""),
|
||||||
str(body.get("password") or ""),
|
str(body.get("password") or ""),
|
||||||
token_hash(device_raw),
|
token_hash(device_raw),
|
||||||
|
str(body.get("invite_code") or ""),
|
||||||
)
|
)
|
||||||
self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw)
|
self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw)
|
||||||
except (ValueError, json.JSONDecodeError) as exc:
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
INVITE_CONSUMED_MESSAGE = "邀请码无效或已被使用,请联系管理员重新获取。"
|
||||||
|
|
||||||
|
|
||||||
class AccountRepositoryMixin:
|
class AccountRepositoryMixin:
|
||||||
"""Original SQLite account persistence methods, moved without query changes."""
|
"""Original SQLite account persistence methods, moved without query changes."""
|
||||||
|
|
||||||
@@ -23,11 +26,14 @@ class AccountRepositoryMixin:
|
|||||||
username: str,
|
username: str,
|
||||||
password_salt: str,
|
password_salt: str,
|
||||||
password_hash: str,
|
password_hash: str,
|
||||||
|
invite_code: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
try:
|
try:
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
role = "admin" if int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) == 0 else "user"
|
role = "admin" if int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) == 0 else "user"
|
||||||
|
if invite_code and not self._consume_invite_code(connection, invite_code, now):
|
||||||
|
raise ValueError(INVITE_CONSUMED_MESSAGE)
|
||||||
cursor = connection.execute(
|
cursor = connection.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users
|
INSERT INTO users
|
||||||
@@ -37,10 +43,91 @@ class AccountRepositoryMixin:
|
|||||||
(username, password_salt, password_hash, role, now, now),
|
(username, password_salt, password_hash, role, now, now),
|
||||||
)
|
)
|
||||||
user_id = int(cursor.lastrowid)
|
user_id = int(cursor.lastrowid)
|
||||||
|
if invite_code:
|
||||||
|
connection.execute(
|
||||||
|
"UPDATE invite_codes SET used_by = ? WHERE code = ?",
|
||||||
|
(user_id, invite_code),
|
||||||
|
)
|
||||||
except sqlite3.IntegrityError as exc:
|
except sqlite3.IntegrityError as exc:
|
||||||
raise ValueError("该账号名已被使用。") from exc
|
raise ValueError("该账号名已被使用。") from exc
|
||||||
return {"id": user_id, "username": username, "role": role, "created_at": now}
|
return {"id": user_id, "username": username, "role": role, "created_at": now}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _consume_invite_code(
|
||||||
|
connection: sqlite3.Connection, code: str, used_at: str
|
||||||
|
) -> bool:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE invite_codes SET status = 'used', used_at = ?
|
||||||
|
WHERE code = ? AND status = 'unused'
|
||||||
|
""",
|
||||||
|
(used_at, code),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def invite_code(self, code: str) -> dict[str, Any] | None:
|
||||||
|
with self.connect() as connection:
|
||||||
|
row = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT code, status, note, created_at, used_at, revoked_at, used_by
|
||||||
|
FROM invite_codes WHERE code = ?
|
||||||
|
""",
|
||||||
|
(code,),
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
def create_invite_codes(
|
||||||
|
self, codes: list[str], note: str, created_by: int
|
||||||
|
) -> list[str]:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
for code in codes:
|
||||||
|
connection.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO invite_codes (code, status, note, created_by, created_at)
|
||||||
|
VALUES (?, 'unused', ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(code, note, created_by or None, now),
|
||||||
|
)
|
||||||
|
return list(codes)
|
||||||
|
|
||||||
|
def revoke_invite_code(self, code: str) -> bool:
|
||||||
|
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.connect() as connection:
|
||||||
|
cursor = connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE invite_codes SET status = 'revoked', revoked_at = ?
|
||||||
|
WHERE code = ? AND status = 'unused'
|
||||||
|
""",
|
||||||
|
(now, code),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def list_invite_codes(self, limit: int = 100) -> list[dict[str, Any]]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT c.code, c.status, c.note, c.created_at, c.used_at, c.revoked_at,
|
||||||
|
u.username AS used_by_username
|
||||||
|
FROM invite_codes AS c
|
||||||
|
LEFT JOIN users AS u ON u.id = c.used_by
|
||||||
|
ORDER BY c.created_at DESC, c.code
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(max(1, min(500, int(limit))),),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
def count_invite_codes(self) -> dict[str, int]:
|
||||||
|
with self.connect() as connection:
|
||||||
|
rows = connection.execute(
|
||||||
|
"SELECT status, COUNT(*) AS total FROM invite_codes GROUP BY status"
|
||||||
|
).fetchall()
|
||||||
|
counts = {"unused": 0, "used": 0, "revoked": 0}
|
||||||
|
for row in rows:
|
||||||
|
counts[str(row["status"])] = int(row["total"])
|
||||||
|
return counts
|
||||||
|
|
||||||
def user_by_username(self, username: str) -> dict[str, Any] | None:
|
def user_by_username(self, username: str) -> dict[str, Any] | None:
|
||||||
with self.connect() as connection:
|
with self.connect() as connection:
|
||||||
row = connection.execute(
|
row = connection.execute(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import secrets
|
import secrets
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
@@ -82,16 +83,123 @@ class AccountService:
|
|||||||
MAX_GRANTS_PER_DEVICE = 5
|
MAX_GRANTS_PER_DEVICE = 5
|
||||||
SWITCH_REAUTH_MESSAGE = "该账号需重新验证"
|
SWITCH_REAUTH_MESSAGE = "该账号需重新验证"
|
||||||
|
|
||||||
def register(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
INVITE_ALPHABET = "ACDEFGHJKLMNPQRTUVWXY34679"
|
||||||
|
INVITE_MAX_BATCH = 20
|
||||||
|
INVITE_LIST_LIMIT = 500
|
||||||
|
|
||||||
|
def register(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
device_hash: str = "",
|
||||||
|
invite_code: str = "",
|
||||||
|
) -> dict[str, Any]:
|
||||||
username = username.strip()
|
username = username.strip()
|
||||||
self.validate_input(username, password)
|
self.validate_input(username, password)
|
||||||
with self.auth_lock:
|
with self.auth_lock:
|
||||||
|
code = self.checked_invite_code(invite_code)
|
||||||
salt, password_digest = hash_password(password)
|
salt, password_digest = hash_password(password)
|
||||||
user = self.database.create_user(username, salt, password_digest)
|
user = self.database.create_user(username, salt, password_digest, code)
|
||||||
result = self.create_session(user)
|
result = self.create_session(user)
|
||||||
self.remember_account(device_hash, int(user["id"]), fresh=True)
|
self.remember_account(device_hash, int(user["id"]), fresh=True)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def normalize_invite_code(cls, value: str) -> str:
|
||||||
|
raw = "".join(
|
||||||
|
character
|
||||||
|
for character in str(value or "").upper()
|
||||||
|
if character.isalnum()
|
||||||
|
)
|
||||||
|
if raw.startswith("XB") and len(raw) == 14:
|
||||||
|
body = raw[2:]
|
||||||
|
return f"XB-{body[0:4]}-{body[4:8]}-{body[8:12]}"
|
||||||
|
return raw[:64]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mask_invite_code(code: str) -> str:
|
||||||
|
groups = str(code or "").split("-")
|
||||||
|
if len(groups) < 3:
|
||||||
|
return str(code or "")
|
||||||
|
return f"{groups[0]}-{groups[1]}-••••"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def invite_handle(code: str) -> str:
|
||||||
|
return hashlib.sha256(str(code or "").encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
def checked_invite_code(self, invite_code: str) -> str:
|
||||||
|
if self.database.count_users() == 0:
|
||||||
|
return ""
|
||||||
|
code = self.normalize_invite_code(invite_code)
|
||||||
|
if not code:
|
||||||
|
raise ValueError("请填写邀请码,注册需要管理员发放的一次性邀请码。")
|
||||||
|
record = self.database.invite_code(code)
|
||||||
|
status = str((record or {}).get("status") or "")
|
||||||
|
if not record:
|
||||||
|
raise ValueError("邀请码不存在,请向管理员确认。")
|
||||||
|
if status == "used":
|
||||||
|
raise ValueError("该邀请码已被使用。")
|
||||||
|
if status != "unused":
|
||||||
|
raise ValueError("该邀请码已作废。")
|
||||||
|
return code
|
||||||
|
|
||||||
|
def generate_invite_codes(
|
||||||
|
self, count: int, note: str = "", created_by: int = 0
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
try:
|
||||||
|
total = int(count or 1)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("生成数量不正确。") from exc
|
||||||
|
if total < 1 or total > self.INVITE_MAX_BATCH:
|
||||||
|
raise ValueError(f"每次最多生成 {self.INVITE_MAX_BATCH} 个邀请码。")
|
||||||
|
codes: list[str] = []
|
||||||
|
while len(codes) < total:
|
||||||
|
body = "".join(secrets.choice(self.INVITE_ALPHABET) for _ in range(12))
|
||||||
|
code = f"XB-{body[0:4]}-{body[4:8]}-{body[8:12]}"
|
||||||
|
if code in codes or self.database.invite_code(code):
|
||||||
|
continue
|
||||||
|
codes.append(code)
|
||||||
|
self.database.create_invite_codes(codes, str(note or "").strip()[:60], created_by)
|
||||||
|
return [{"code": code, "code_id": self.invite_handle(code)} for code in codes]
|
||||||
|
|
||||||
|
def _stored_invite_code(self, reference: str) -> str:
|
||||||
|
normalized = self.normalize_invite_code(reference)
|
||||||
|
if normalized and self.database.invite_code(normalized):
|
||||||
|
return normalized
|
||||||
|
handle = str(reference or "").strip().lower()
|
||||||
|
for row in self.database.list_invite_codes(self.INVITE_LIST_LIMIT):
|
||||||
|
if self.invite_handle(str(row["code"])) == handle:
|
||||||
|
return str(row["code"])
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def revoke_invite_code(self, reference: str) -> None:
|
||||||
|
code = self._stored_invite_code(reference)
|
||||||
|
record = self.database.invite_code(code) if code else None
|
||||||
|
if not record:
|
||||||
|
raise ValueError("邀请码不存在。")
|
||||||
|
if str(record.get("status")) == "used":
|
||||||
|
raise ValueError("该邀请码已被使用,无法作废。")
|
||||||
|
if not self.database.revoke_invite_code(code):
|
||||||
|
raise ValueError("该邀请码已作废。")
|
||||||
|
|
||||||
|
def invite_overview(self, limit: int = 100) -> dict[str, Any]:
|
||||||
|
codes = []
|
||||||
|
for row in self.database.list_invite_codes(limit):
|
||||||
|
code = str(row["code"])
|
||||||
|
codes.append(
|
||||||
|
{
|
||||||
|
"code_id": self.invite_handle(code),
|
||||||
|
"code_masked": self.mask_invite_code(code),
|
||||||
|
"status": str(row["status"]),
|
||||||
|
"note": str(row.get("note") or ""),
|
||||||
|
"created_at": str(row.get("created_at") or ""),
|
||||||
|
"used_at": str(row.get("used_at") or ""),
|
||||||
|
"revoked_at": str(row.get("revoked_at") or ""),
|
||||||
|
"used_by_username": str(row.get("used_by_username") or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"summary": self.database.count_invite_codes(), "codes": codes}
|
||||||
|
|
||||||
def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]:
|
||||||
username = username.strip()
|
username = username.strip()
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
|
|||||||
@@ -802,7 +802,11 @@ def _market_line_scores(
|
|||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
sector_quantitative_mode = str(sector.get("_quantitative_mode") or "")
|
sector_quantitative_mode = str(sector.get("_quantitative_mode") or "")
|
||||||
actual_sector_source = str(sector.get("source") or "").startswith("tushare_")
|
sector_source = str(sector.get("source") or "")
|
||||||
|
actual_sector_source = (
|
||||||
|
sector_source.startswith("tushare_")
|
||||||
|
or "sw_member_equal_" in sector_source
|
||||||
|
)
|
||||||
if (sector.get("realtime") and actual_sector_source) or sector_quantitative_mode == "intraday":
|
if (sector.get("realtime") and actual_sector_source) or sector_quantitative_mode == "intraday":
|
||||||
sector_change = float(sector.get("change") or 0)
|
sector_change = float(sector.get("change") or 0)
|
||||||
sector_change_score = _clamp(sector_change / 5, -1, 1)
|
sector_change_score = _clamp(sector_change / 5, -1, 1)
|
||||||
@@ -831,8 +835,13 @@ def _market_line_scores(
|
|||||||
f"成分上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家",
|
f"成分上涨 {int(sector_up)} 家、下跌 {int(sector_down)} 家",
|
||||||
f"平均换手 {float(sector.get('turnover_rate') or 0):.2f}%,相对市场 {float(sector.get('relative_turnover') or 0):.2f} 倍",
|
f"平均换手 {float(sector.get('turnover_rate') or 0):.2f}%,相对市场 {float(sector.get('relative_turnover') or 0):.2f} 倍",
|
||||||
]
|
]
|
||||||
|
sector_change_label = (
|
||||||
|
"申万二级行业成分等权涨跌"
|
||||||
|
if "sw_member_equal_" in sector_source
|
||||||
|
else "申万二级行业官方涨跌"
|
||||||
|
)
|
||||||
sector_outer_evidence = [
|
sector_outer_evidence = [
|
||||||
f"申万二级行业官方涨跌 {sector_change:+.2f}%",
|
f"{sector_change_label} {sector_change:+.2f}%",
|
||||||
f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%",
|
f"领涨 {sector.get('leader') or '--'} {float(sector.get('leading_pct') or 0):+.2f}%",
|
||||||
]
|
]
|
||||||
elif actual_sector_source or sector_quantitative_mode == "historical":
|
elif actual_sector_source or sector_quantitative_mode == "historical":
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class HeavenManualMixin:
|
|||||||
"sector_coverage": {"line": 3, "label": "成分行情覆盖率", "unit": "%", "min": 0, "max": 100},
|
"sector_coverage": {"line": 3, "label": "成分行情覆盖率", "unit": "%", "min": 0, "max": 100},
|
||||||
"sector_relative_turnover": {"line": 3, "label": "行业相对市场换手", "unit": "倍", "min": 0, "max": 20},
|
"sector_relative_turnover": {"line": 3, "label": "行业相对市场换手", "unit": "倍", "min": 0, "max": 20},
|
||||||
"sector_member_equal_change": {"line": 3, "label": "成分等权涨跌幅", "unit": "%", "min": -100, "max": 100},
|
"sector_member_equal_change": {"line": 3, "label": "成分等权涨跌幅", "unit": "%", "min": -100, "max": 100},
|
||||||
"sector_change": {"line": 4, "label": "申万官方涨跌幅", "unit": "%", "min": -100, "max": 100},
|
"sector_change": {"line": 4, "label": "申万行业涨跌幅", "unit": "%", "min": -100, "max": 100},
|
||||||
"sector_leading_pct": {"line": [3, 4], "label": "行业领涨股涨跌幅", "unit": "%", "min": -100, "max": 100},
|
"sector_leading_pct": {"line": [3, 4], "label": "行业领涨股涨跌幅", "unit": "%", "min": -100, "max": 100},
|
||||||
"market_sentiment_score": {"line": 5, "label": "市场情绪温度", "unit": "分", "min": 0, "max": 100},
|
"market_sentiment_score": {"line": 5, "label": "市场情绪温度", "unit": "分", "min": 0, "max": 100},
|
||||||
"market_seal_rate": {"line": 5, "label": "封板率", "unit": "%", "min": 0, "max": 100},
|
"market_seal_rate": {"line": 5, "label": "封板率", "unit": "%", "min": 0, "max": 100},
|
||||||
|
|||||||
@@ -202,8 +202,7 @@ class HeavenMarketContextMixin:
|
|||||||
}
|
}
|
||||||
if len(quotes) != 3 or quote_dates != {trade_date}:
|
if len(quotes) != 3 or quote_dates != {trade_date}:
|
||||||
raise ValueError("腾讯三大指数日期与目标交易日不一致")
|
raise ValueError("腾讯三大指数日期与目标交易日不一致")
|
||||||
now = datetime.now().astimezone()
|
max_skew = 120
|
||||||
max_skew = 120 if now.hour >= 15 else 15
|
|
||||||
if max(epochs) - min(epochs) > max_skew:
|
if max(epochs) - min(epochs) > max_skew:
|
||||||
raise ValueError(f"腾讯三大指数时间差超过{max_skew}秒")
|
raise ValueError(f"腾讯三大指数时间差超过{max_skew}秒")
|
||||||
|
|
||||||
@@ -217,11 +216,14 @@ class HeavenMarketContextMixin:
|
|||||||
start_date = (
|
start_date = (
|
||||||
datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)
|
datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=20)
|
||||||
).strftime("%Y%m%d")
|
).strftime("%Y%m%d")
|
||||||
|
history_end = (
|
||||||
|
datetime.strptime(trade_date, "%Y%m%d") - timedelta(days=1)
|
||||||
|
).strftime("%Y%m%d")
|
||||||
for quote in quotes:
|
for quote in quotes:
|
||||||
ts_code = code_map[str(quote.get("code") or "")]
|
ts_code = code_map[str(quote.get("code") or "")]
|
||||||
history = client.query(
|
history = client.query(
|
||||||
"index_daily",
|
"index_daily",
|
||||||
{"ts_code": ts_code, "start_date": start_date, "end_date": trade_date},
|
{"ts_code": ts_code, "start_date": start_date, "end_date": history_end},
|
||||||
"ts_code,trade_date,close,pct_chg",
|
"ts_code,trade_date,close,pct_chg",
|
||||||
)
|
)
|
||||||
history.sort(key=lambda item: str(item.get("trade_date") or ""))
|
history.sort(key=lambda item: str(item.get("trade_date") or ""))
|
||||||
@@ -283,9 +285,9 @@ class HeavenMarketContextMixin:
|
|||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Return the Shenwan L2 sector context for heaven trend.
|
"""Return the Shenwan L2 sector context for heaven trend.
|
||||||
|
|
||||||
观势行业层只使用申万二级行业。外显盘中使用 rt_sw_k、历史使用
|
观势行业层只使用申万二级行业。外显优先使用已发布的 sw_daily,
|
||||||
sw_daily;内核独立使用目标日期成分股行情聚合。收盘过渡期在
|
盘中及收盘过渡期使用免费申万实时行情;内核使用数据中枢或免费
|
||||||
sw_daily 入库前接受同日15:00后的 rt_sw_k 收盘快照。
|
实时成分行情。不再调用无权限的 rt_sw_k / rt_k。
|
||||||
"""
|
"""
|
||||||
cache_key = f"{trade_date}:{identifier.strip().lower()}"
|
cache_key = f"{trade_date}:{identifier.strip().lower()}"
|
||||||
cached = self.database.get_data_snapshot("heaven_sector", cache_key)
|
cached = self.database.get_data_snapshot("heaven_sector", cache_key)
|
||||||
@@ -299,6 +301,14 @@ class HeavenMarketContextMixin:
|
|||||||
and not cached.get("realtime")
|
and not cached.get("realtime")
|
||||||
and int(cached.get("schema_version") or 0) >= 6
|
and int(cached.get("schema_version") or 0) >= 6
|
||||||
)
|
)
|
||||||
|
cached_quotes = int((cached or {}).get("quote_count") or 0)
|
||||||
|
cached_lkg = bool(
|
||||||
|
cached
|
||||||
|
and cached_date == trade_date
|
||||||
|
and cached.get("taxonomy") == "sw_l2"
|
||||||
|
and cached_quotes > 0
|
||||||
|
and int(cached.get("schema_version") or 0) >= 6
|
||||||
|
)
|
||||||
if market_mode != "intraday" and cached_valid:
|
if market_mode != "intraday" and cached_valid:
|
||||||
return cached
|
return cached
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
@@ -311,8 +321,12 @@ class HeavenMarketContextMixin:
|
|||||||
allow_realtime_close=market_mode == "closed",
|
allow_realtime_close=market_mode == "closed",
|
||||||
)
|
)
|
||||||
except TushareError as exc:
|
except TushareError as exc:
|
||||||
if cached_valid:
|
if cached_lkg:
|
||||||
return cached
|
delayed = dict(cached)
|
||||||
|
delayed["delayed"] = True
|
||||||
|
delayed["delay_notice"] = "主备免费行情均暂不可用,显示最近一次真实快照"
|
||||||
|
delayed["realtime"] = market_mode == "intraday"
|
||||||
|
return delayed
|
||||||
return {
|
return {
|
||||||
"name": "",
|
"name": "",
|
||||||
"code": "",
|
"code": "",
|
||||||
@@ -323,12 +337,16 @@ class HeavenMarketContextMixin:
|
|||||||
"precise": False,
|
"precise": False,
|
||||||
"inner_precise": False,
|
"inner_precise": False,
|
||||||
"outer_precise": False,
|
"outer_precise": False,
|
||||||
"coverage": 0,
|
|
||||||
"member_count": 0,
|
|
||||||
"quote_count": 0,
|
|
||||||
"error": f"申万二级行业数据获取失败:{exc}",
|
"error": f"申万二级行业数据获取失败:{exc}",
|
||||||
}
|
}
|
||||||
if not payload.get("realtime") and payload.get("precise"):
|
if int(payload.get("quote_count") or 0) > 0:
|
||||||
|
self.database.save_data_snapshot(
|
||||||
|
"heaven_sector",
|
||||||
|
cache_key,
|
||||||
|
str(payload.get("source") or "tushare"),
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
elif not payload.get("realtime") and payload.get("precise"):
|
||||||
self.database.save_data_snapshot(
|
self.database.save_data_snapshot(
|
||||||
"heaven_sector",
|
"heaven_sector",
|
||||||
cache_key,
|
cache_key,
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ class HeavenTrendMixin:
|
|||||||
"detail": (
|
"detail": (
|
||||||
f"申万二级 {sector.get('name') or '--'} {sector.get('code') or '--'} "
|
f"申万二级 {sector.get('name') or '--'} {sector.get('code') or '--'} "
|
||||||
f"成分覆盖 {int(sector.get('quote_count') or 0)}/{int(sector.get('member_count') or 0)}"
|
f"成分覆盖 {int(sector.get('quote_count') or 0)}/{int(sector.get('member_count') or 0)}"
|
||||||
|
+ (";延迟快照" if sector.get("delayed") or sector.get("delay_notice") else "")
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -341,7 +342,9 @@ class HeavenTrendMixin:
|
|||||||
issues.append("行业外显缺少申万官方行情")
|
issues.append("行业外显缺少申万官方行情")
|
||||||
if sector and sector_coverage_issue:
|
if sector and sector_coverage_issue:
|
||||||
issues.append(sector_coverage_issue)
|
issues.append(sector_coverage_issue)
|
||||||
if sector.get("realtime") and not sector.get("relative_turnover"):
|
if sector.get("delay_notice"):
|
||||||
|
issues.append(str(sector.get("delay_notice")))
|
||||||
|
if sector.get("realtime") and not sector.get("relative_turnover") and not sector.get("delayed"):
|
||||||
issues.append("行业内核缺少相对全市场换手活跃度")
|
issues.append("行业内核缺少相对全市场换手活跃度")
|
||||||
|
|
||||||
stock = stock or {}
|
stock = stock or {}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from threading import Lock
|
|||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
from backend.bootstrap.config import tushare_code as _stock_market_code
|
from backend.bootstrap.config import tushare_code as _stock_market_code
|
||||||
from backend.data.providers.ifind_client import IfindError, IfindHttpClient
|
from backend.data.providers.ifind_client import IfindError
|
||||||
|
|
||||||
LOGGER = logging.getLogger("xiaobai.charts")
|
LOGGER = logging.getLogger("xiaobai.charts")
|
||||||
|
|
||||||
@@ -23,6 +23,9 @@ class ChartDataError(RuntimeError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
DAILY_CHART_LIMIT = 45
|
||||||
|
|
||||||
|
|
||||||
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
|
TRENDS_URL = "https://push2delay.eastmoney.com/api/qt/stock/trends2/get"
|
||||||
HIS_TRENDS_URL = "https://push2his.eastmoney.com/api/qt/stock/trends2/get"
|
HIS_TRENDS_URL = "https://push2his.eastmoney.com/api/qt/stock/trends2/get"
|
||||||
BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get"
|
BOARD_LIST_URL = "https://push2delay.eastmoney.com/api/qt/clist/get"
|
||||||
@@ -39,17 +42,12 @@ INDEX_SECIDS = {
|
|||||||
|
|
||||||
|
|
||||||
class MarketChartClient:
|
class MarketChartClient:
|
||||||
"""Prefer iFinD for display charts and retain Eastmoney as a last resort."""
|
"""Display charts are served by the data hub only."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, datahub: Any = None) -> None:
|
||||||
self,
|
|
||||||
ifind: IfindHttpClient,
|
|
||||||
fallback: "EastmoneyChartClient",
|
|
||||||
datahub: Any = None,
|
|
||||||
) -> None:
|
|
||||||
self.ifind = ifind
|
|
||||||
self.fallback = fallback
|
|
||||||
self.datahub = datahub
|
self.datahub = datahub
|
||||||
|
self.ifind = None
|
||||||
|
self.fallback = None
|
||||||
|
|
||||||
def stock_intraday(self, code: str) -> dict[str, Any]:
|
def stock_intraday(self, code: str) -> dict[str, Any]:
|
||||||
normalized = str(code or "").strip()
|
normalized = str(code or "").strip()
|
||||||
@@ -58,35 +56,34 @@ class MarketChartClient:
|
|||||||
hub_chart = self._datahub_intraday(normalized)
|
hub_chart = self._datahub_intraday(normalized)
|
||||||
if hub_chart is not None:
|
if hub_chart is not None:
|
||||||
return hub_chart
|
return hub_chart
|
||||||
ifind_code = _stock_market_code(normalized)
|
raise ChartDataError("分时图数据中枢暂不可用")
|
||||||
try:
|
|
||||||
return self._ifind_intraday(ifind_code, "stock", normalized)
|
|
||||||
except (IfindError, ChartDataError):
|
|
||||||
return self.fallback.stock_intraday(normalized)
|
|
||||||
|
|
||||||
def stock_daily(self, code: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
def stock_daily(self, code: str, end_date: str, limit: int = DAILY_CHART_LIMIT) -> list[dict[str, Any]]:
|
||||||
normalized = str(code or "").strip()
|
normalized = str(code or "").strip()
|
||||||
if not re.fullmatch(r"\d{6}", normalized):
|
if not re.fullmatch(r"\d{6}", normalized):
|
||||||
raise ChartDataError("Invalid stock code")
|
raise ChartDataError("Invalid stock code")
|
||||||
hub_rows = self._datahub_daily(normalized, end_date, limit, "daily")
|
hub_rows = self._datahub_daily(normalized, end_date, limit, "daily")
|
||||||
if hub_rows:
|
if hub_rows:
|
||||||
return hub_rows
|
return hub_rows
|
||||||
return self._ifind_daily(_stock_market_code(normalized), end_date, limit)
|
raise ChartDataError("日K数据中枢暂不可用")
|
||||||
|
|
||||||
def index_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
def index_daily(self, identifier: str, end_date: str, limit: int = DAILY_CHART_LIMIT) -> list[dict[str, Any]]:
|
||||||
normalized = str(identifier or "").strip().upper()
|
normalized = str(identifier or "").strip().upper()
|
||||||
if normalized not in INDEX_SECIDS:
|
if normalized not in INDEX_SECIDS:
|
||||||
raise ChartDataError("Unsupported index")
|
raise ChartDataError("Unsupported index")
|
||||||
hub_rows = self._datahub_daily(normalized, end_date, limit, "index_daily")
|
hub_rows = self._datahub_daily(normalized, end_date, limit, "index_daily")
|
||||||
if hub_rows:
|
if hub_rows:
|
||||||
return hub_rows
|
return hub_rows
|
||||||
return self._ifind_daily(normalized, end_date, limit)
|
raise ChartDataError("指数日K数据中枢暂不可用")
|
||||||
|
|
||||||
def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
def board_daily(self, identifier: str, end_date: str, limit: int = 90) -> list[dict[str, Any]]:
|
||||||
normalized = str(identifier or "").strip().upper()
|
normalized = str(identifier or "").strip().upper()
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise ChartDataError("Invalid board code")
|
raise ChartDataError("Invalid board code")
|
||||||
return self._ifind_daily(normalized, end_date, limit)
|
hub_rows = self._datahub_daily(normalized, end_date, limit, "sector_daily")
|
||||||
|
if hub_rows:
|
||||||
|
return hub_rows
|
||||||
|
raise ChartDataError("板块日K数据中枢暂不可用")
|
||||||
|
|
||||||
def index_intraday(self, identifier: str) -> dict[str, Any]:
|
def index_intraday(self, identifier: str) -> dict[str, Any]:
|
||||||
normalized = str(identifier or "").strip().upper()
|
normalized = str(identifier or "").strip().upper()
|
||||||
@@ -95,10 +92,7 @@ class MarketChartClient:
|
|||||||
hub_chart = self._datahub_intraday(normalized)
|
hub_chart = self._datahub_intraday(normalized)
|
||||||
if hub_chart is not None:
|
if hub_chart is not None:
|
||||||
return hub_chart
|
return hub_chart
|
||||||
try:
|
raise ChartDataError("指数分时数据中枢暂不可用")
|
||||||
return self._ifind_intraday(normalized, "index", normalized)
|
|
||||||
except (IfindError, ChartDataError):
|
|
||||||
return self.fallback.index_intraday(normalized)
|
|
||||||
|
|
||||||
def _datahub_intraday(self, code: str) -> dict[str, Any] | None:
|
def _datahub_intraday(self, code: str) -> dict[str, Any] | None:
|
||||||
if self.datahub is None:
|
if self.datahub is None:
|
||||||
@@ -130,8 +124,6 @@ class MarketChartClient:
|
|||||||
LOGGER.warning("datahub daily unexpected error: %s", exc)
|
LOGGER.warning("datahub daily unexpected error: %s", exc)
|
||||||
rows = None
|
rows = None
|
||||||
if not rows:
|
if not rows:
|
||||||
if hasattr(self.datahub, "record_legacy"):
|
|
||||||
self.datahub.record_legacy(dataset, "ifind")
|
|
||||||
return None
|
return None
|
||||||
compact_end = str(end_date or "").replace("-", "")
|
compact_end = str(end_date or "").replace("-", "")
|
||||||
market_now = datetime.now().astimezone()
|
market_now = datetime.now().astimezone()
|
||||||
@@ -223,10 +215,13 @@ class MarketChartClient:
|
|||||||
|
|
||||||
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
|
def board_intraday(self, identifier: str, name: str = "") -> dict[str, Any]:
|
||||||
normalized = str(identifier or "").strip().upper()
|
normalized = str(identifier or "").strip().upper()
|
||||||
try:
|
hub_chart = self._datahub_intraday(normalized)
|
||||||
return self._ifind_intraday(normalized, "board", normalized, name)
|
if hub_chart is not None:
|
||||||
except (IfindError, ChartDataError):
|
if name:
|
||||||
return self.fallback.board_intraday(normalized, name)
|
hub_chart = dict(hub_chart)
|
||||||
|
hub_chart["name"] = name
|
||||||
|
return hub_chart
|
||||||
|
raise ChartDataError("板块分时数据中枢暂不可用")
|
||||||
|
|
||||||
def _ifind_intraday(
|
def _ifind_intraday(
|
||||||
self,
|
self,
|
||||||
@@ -235,7 +230,7 @@ class MarketChartClient:
|
|||||||
identifier: str,
|
identifier: str,
|
||||||
name: str = "",
|
name: str = "",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not self.ifind.configured:
|
if not self.ifind or not self.ifind.configured:
|
||||||
raise ChartDataError("iFinD is not configured")
|
raise ChartDataError("iFinD is not configured")
|
||||||
now = datetime.now().astimezone()
|
now = datetime.now().astimezone()
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
@@ -272,7 +267,7 @@ class MarketChartClient:
|
|||||||
def _ifind_daily(
|
def _ifind_daily(
|
||||||
self, ifind_code: str, end_date: str, limit: int
|
self, ifind_code: str, end_date: str, limit: int
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
if not self.ifind.configured:
|
if not self.ifind or not self.ifind.configured:
|
||||||
raise ChartDataError("iFinD is not configured")
|
raise ChartDataError("iFinD is not configured")
|
||||||
compact_end = str(end_date or "").replace("-", "")
|
compact_end = str(end_date or "").replace("-", "")
|
||||||
if not re.fullmatch(r"\d{8}", compact_end):
|
if not re.fullmatch(r"\d{8}", compact_end):
|
||||||
@@ -374,9 +369,11 @@ class MarketChartClient:
|
|||||||
pass
|
pass
|
||||||
if not normalized:
|
if not normalized:
|
||||||
raise ChartDataError("No iFinD daily chart data returned")
|
raise ChartDataError("No iFinD daily chart data returned")
|
||||||
return normalized[-max(20, min(180, int(limit))):]
|
return normalized[-max(1, int(limit)):]
|
||||||
|
|
||||||
def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
|
def _previous_close(self, code: str, trade_date: str, fallback: float) -> float:
|
||||||
|
if not self.ifind:
|
||||||
|
return fallback
|
||||||
today = datetime.now().astimezone().date().isoformat()
|
today = datetime.now().astimezone().date().isoformat()
|
||||||
if trade_date == today:
|
if trade_date == today:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ from backend.bootstrap.config import (
|
|||||||
validate_text,
|
validate_text,
|
||||||
)
|
)
|
||||||
from backend.data.providers.ifind_client import IfindError
|
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.data.realtime import RealtimeAggregateError
|
||||||
from backend.features.market.backfill_history import (
|
from backend.features.market.backfill_history import (
|
||||||
DEFAULT_RECENT_TRADING_DAYS,
|
DEFAULT_RECENT_TRADING_DAYS,
|
||||||
@@ -27,7 +28,7 @@ from backend.features.market.backfill_history import (
|
|||||||
select_open_trade_dates,
|
select_open_trade_dates,
|
||||||
select_open_trade_dates_in_range,
|
select_open_trade_dates_in_range,
|
||||||
)
|
)
|
||||||
from backend.features.market.charts import ChartDataError
|
from backend.features.market.charts import ChartDataError, DAILY_CHART_LIMIT
|
||||||
from backend.features.market.insights import MarketInsightsService
|
from backend.features.market.insights import MarketInsightsService
|
||||||
from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION
|
from backend.features.sentiment.engine import SENTIMENT_ENGINE_VERSION
|
||||||
|
|
||||||
@@ -60,16 +61,14 @@ class MarketServiceMixin:
|
|||||||
self._tushare_client(),
|
self._tushare_client(),
|
||||||
ifind=self.ifind,
|
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)
|
gateway = getattr(self, "data_gateway", None)
|
||||||
if gateway is not None:
|
if gateway is None:
|
||||||
|
raise RuntimeError("数据中枢尚未装配。")
|
||||||
return gateway.tushare()
|
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
|
|
||||||
|
|
||||||
def _now(self) -> datetime:
|
def _now(self) -> datetime:
|
||||||
clock = getattr(self, "clock", None)
|
clock = getattr(self, "clock", None)
|
||||||
@@ -177,11 +176,10 @@ class MarketServiceMixin:
|
|||||||
return "最近可用交易日"
|
return "最近可用交易日"
|
||||||
return f"{int(compact[4:6])} 月 {int(compact[6:8])} 日"
|
return f"{int(compact[4:6])} 月 {int(compact[6:8])} 日"
|
||||||
|
|
||||||
@classmethod
|
def _preparing_display_notice(self, actual_date: str, requested_date: str) -> str:
|
||||||
def _preparing_display_notice(cls, actual_date: str, requested_date: str) -> str:
|
shown = self._chinese_month_day(actual_date)
|
||||||
shown = cls._chinese_month_day(actual_date)
|
|
||||||
requested = str(requested_date or "").replace("-", "")
|
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}"
|
||||||
return f"所选日期数据尚未到齐,当前展示 {shown}"
|
return f"所选日期数据尚未到齐,当前展示 {shown}"
|
||||||
|
|
||||||
@@ -677,7 +675,7 @@ class MarketServiceMixin:
|
|||||||
"index_daily",
|
"index_daily",
|
||||||
{
|
{
|
||||||
"ts_code": basic["id"],
|
"ts_code": basic["id"],
|
||||||
"start_date": (end - timedelta(days=190)).strftime("%Y%m%d"),
|
"start_date": (end - timedelta(days=400)).strftime("%Y%m%d"),
|
||||||
"end_date": resolved_date,
|
"end_date": resolved_date,
|
||||||
},
|
},
|
||||||
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
"ts_code,trade_date,open,high,low,close,pct_chg,vol,amount",
|
||||||
@@ -693,10 +691,10 @@ class MarketServiceMixin:
|
|||||||
"change": float(row.get("pct_chg") or 0),
|
"change": float(row.get("pct_chg") or 0),
|
||||||
"volume": float(row.get("vol") or 0),
|
"volume": float(row.get("vol") or 0),
|
||||||
}
|
}
|
||||||
for row in rows[-90:]
|
for row in rows[-DAILY_CHART_LIMIT:]
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, 90)
|
chart_series = self.chart_data.index_daily(str(basic["id"]), resolved_date, DAILY_CHART_LIMIT)
|
||||||
if chart_series:
|
if chart_series:
|
||||||
series = chart_series
|
series = chart_series
|
||||||
except (AttributeError, ChartDataError):
|
except (AttributeError, ChartDataError):
|
||||||
@@ -804,7 +802,7 @@ class MarketServiceMixin:
|
|||||||
result = copy.deepcopy(payload)
|
result = copy.deepcopy(payload)
|
||||||
now = datetime.now().astimezone()
|
now = datetime.now().astimezone()
|
||||||
try:
|
try:
|
||||||
result["prices"] = self.chart_data.stock_daily(code, requested_date, 90)
|
result["prices"] = self.chart_data.stock_daily(code, requested_date, DAILY_CHART_LIMIT)
|
||||||
result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"}
|
result["meta"] = {**(result.get("meta") or {}), "chart_source": "market_chart"}
|
||||||
except (AttributeError, ChartDataError):
|
except (AttributeError, ChartDataError):
|
||||||
pass
|
pass
|
||||||
@@ -837,7 +835,7 @@ class MarketServiceMixin:
|
|||||||
**(result.get("meta") or {}),
|
**(result.get("meta") or {}),
|
||||||
"notice": TODAY_DAILY_UNAVAILABLE_NOTICE,
|
"notice": TODAY_DAILY_UNAVAILABLE_NOTICE,
|
||||||
}
|
}
|
||||||
return self._enrich_stock_detail(result)
|
return self._enrich_stock_detail(result, requested_date)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sanitize_stock_detail_prices(
|
def _sanitize_stock_detail_prices(
|
||||||
@@ -1012,7 +1010,7 @@ class MarketServiceMixin:
|
|||||||
else:
|
else:
|
||||||
quote_date = str(row.get("quote_date") or today)
|
quote_date = str(row.get("quote_date") or today)
|
||||||
quote_time = f"{quote_date[:4]}-{quote_date[4:6]}-{quote_date[6:]}"
|
quote_time = f"{quote_date[:4]}-{quote_date[4:6]}-{quote_date[6:]}"
|
||||||
return {
|
quote = {
|
||||||
"name": str(row.get("name") or name or "--"),
|
"name": str(row.get("name") or name or "--"),
|
||||||
"sector": sector,
|
"sector": sector,
|
||||||
"price": price,
|
"price": price,
|
||||||
@@ -1025,6 +1023,10 @@ class MarketServiceMixin:
|
|||||||
"turnover_rate": float(row.get("turnover_rate") or 0),
|
"turnover_rate": float(row.get("turnover_rate") or 0),
|
||||||
"quote_time": quote_time,
|
"quote_time": quote_time,
|
||||||
}
|
}
|
||||||
|
flow = _moneyflow_payload(row)
|
||||||
|
if flow.get("available"):
|
||||||
|
quote["moneyflow"] = flow
|
||||||
|
return quote
|
||||||
|
|
||||||
def _intraday_realtime_stock_quote(
|
def _intraday_realtime_stock_quote(
|
||||||
self, code: str, today: str, payload: dict[str, Any]
|
self, code: str, today: str, payload: dict[str, Any]
|
||||||
@@ -1100,19 +1102,24 @@ class MarketServiceMixin:
|
|||||||
prices[-1] = realtime_bar
|
prices[-1] = realtime_bar
|
||||||
else:
|
else:
|
||||||
prices.append(realtime_bar)
|
prices.append(realtime_bar)
|
||||||
payload["prices"] = prices[-90:]
|
payload["prices"] = prices[-DAILY_CHART_LIMIT:]
|
||||||
stock = dict(payload.get("stock") or {})
|
stock = dict(payload.get("stock") or {})
|
||||||
stock.update(
|
updates = {
|
||||||
{
|
|
||||||
"name": quote["name"],
|
"name": quote["name"],
|
||||||
"industry": quote["sector"],
|
"industry": quote["sector"],
|
||||||
"price": quote["price"],
|
"price": quote["price"],
|
||||||
"change": quote["change"],
|
"change": quote["change"],
|
||||||
"amount_billion": quote["amount_billion"],
|
"amount_billion": quote["amount_billion"],
|
||||||
"turnover_rate": quote["turnover_rate"],
|
|
||||||
}
|
}
|
||||||
)
|
quote_turnover = _optional_number(quote.get("turnover_rate"))
|
||||||
|
if quote_turnover:
|
||||||
|
updates["turnover_rate"] = quote_turnover
|
||||||
|
stock.update(updates)
|
||||||
payload["stock"] = stock
|
payload["stock"] = stock
|
||||||
|
quote_flow = quote.get("moneyflow")
|
||||||
|
current_flow = payload.get("moneyflow") or {}
|
||||||
|
if isinstance(quote_flow, dict) and quote_flow.get("available") and not current_flow.get("available"):
|
||||||
|
payload["moneyflow"] = quote_flow
|
||||||
payload["meta"] = {
|
payload["meta"] = {
|
||||||
**(payload.get("meta") or {}),
|
**(payload.get("meta") or {}),
|
||||||
"trade_date": display_date,
|
"trade_date": display_date,
|
||||||
@@ -1151,7 +1158,7 @@ class MarketServiceMixin:
|
|||||||
intraday_status = "unavailable"
|
intraday_status = "unavailable"
|
||||||
intraday_notice = "分时行情暂不可用,请稍后重试。"
|
intraday_notice = "分时行情暂不可用,请稍后重试。"
|
||||||
|
|
||||||
prices = list(detail.get("prices") or [])[-60:]
|
prices = list(detail.get("prices") or [])[-DAILY_CHART_LIMIT:]
|
||||||
stock = dict(detail.get("stock") or {"code": code})
|
stock = dict(detail.get("stock") or {"code": code})
|
||||||
realtime = bool(detail_meta.get("realtime"))
|
realtime = bool(detail_meta.get("realtime"))
|
||||||
return {
|
return {
|
||||||
@@ -1403,10 +1410,40 @@ class MarketServiceMixin:
|
|||||||
return item["name"], item["sector"] or "其他"
|
return item["name"], item["sector"] or "其他"
|
||||||
return "--", "其他"
|
return "--", "其他"
|
||||||
|
|
||||||
def _enrich_stock_detail(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def _enrich_stock_detail(
|
||||||
|
self, payload: dict[str, Any], trade_date: str = ""
|
||||||
|
) -> dict[str, Any]:
|
||||||
result = dict(payload)
|
result = dict(payload)
|
||||||
stock = dict(payload.get("stock") or {})
|
stock = dict(payload.get("stock") or {})
|
||||||
code = str(stock.get("code") or "")
|
code = str(stock.get("code") or "")
|
||||||
|
compact_date = normalize_date(
|
||||||
|
str((payload.get("meta") or {}).get("trade_date") or trade_date)
|
||||||
|
)
|
||||||
|
board = self._limit_event_for_stock(code, compact_date)
|
||||||
|
if board:
|
||||||
|
if not stock.get("first_time") or stock.get("first_time") == "--":
|
||||||
|
stock["first_time"] = board.get("first_time") or "--"
|
||||||
|
if not stock.get("last_time") or stock.get("last_time") == "--":
|
||||||
|
stock["last_time"] = board.get("last_time") or "--"
|
||||||
|
if not stock.get("open_times"):
|
||||||
|
stock["open_times"] = board.get("open_times") or 0
|
||||||
|
if _optional_number(stock.get("seal_amount_million")) is None:
|
||||||
|
stock["seal_amount_million"] = board.get("seal_amount_million")
|
||||||
|
if not _optional_number(stock.get("turnover_rate")) and _optional_number(board.get("turnover_rate")):
|
||||||
|
stock["turnover_rate"] = board.get("turnover_rate")
|
||||||
|
flow = result.get("moneyflow") or {}
|
||||||
|
if not flow.get("available"):
|
||||||
|
live_flow = self._live_moneyflow_for_stock(code, compact_date)
|
||||||
|
if live_flow.get("available"):
|
||||||
|
result["moneyflow"] = live_flow
|
||||||
|
else:
|
||||||
|
result["moneyflow"] = {
|
||||||
|
"available": False,
|
||||||
|
"net_million": None,
|
||||||
|
"large_million": None,
|
||||||
|
"medium_million": None,
|
||||||
|
"small_million": None,
|
||||||
|
}
|
||||||
watched = {
|
watched = {
|
||||||
item["code"]: item
|
item["code"]: item
|
||||||
for item in self.database.list_watchlist(self.current_user_id)
|
for item in self.database.list_watchlist(self.current_user_id)
|
||||||
@@ -1416,6 +1453,45 @@ class MarketServiceMixin:
|
|||||||
result["notes"] = self.database.list_notes(self.current_user_id, code=code)
|
result["notes"] = self.database.list_notes(self.current_user_id, code=code)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def _limit_event_for_stock(self, code: str, trade_date: str) -> dict[str, Any]:
|
||||||
|
if not code or not trade_date:
|
||||||
|
return {}
|
||||||
|
ts_code = tushare_code(code)
|
||||||
|
client = self._tushare_client() if self.configured else None
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
rows = client._load_limit_type(trade_date, "U") + client._load_limit_type(trade_date, "Z")
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
if not rows:
|
||||||
|
try:
|
||||||
|
rows = list((client._free_board_map(trade_date) or {}).values())
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
match = next((row for row in rows if str(row.get("ts_code") or "") == ts_code), None)
|
||||||
|
if not match:
|
||||||
|
return {}
|
||||||
|
fd = _optional_number(match.get("fd_amount"))
|
||||||
|
return {
|
||||||
|
"first_time": match.get("first_time") or "--",
|
||||||
|
"last_time": match.get("last_time") or "--",
|
||||||
|
"open_times": match.get("open_times") or 0,
|
||||||
|
"seal_amount_million": None if fd is None else round(fd / 10000, 0),
|
||||||
|
"turnover_rate": _optional_number(match.get("turnover_ratio")),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _live_moneyflow_for_stock(self, code: str, trade_date: str) -> dict[str, Any]:
|
||||||
|
aggregator = getattr(self, "realtime_aggregator", None)
|
||||||
|
loader = getattr(aggregator, "eastmoney_stock_quote", None) if aggregator else None
|
||||||
|
if not callable(loader) or not code:
|
||||||
|
return _moneyflow_payload(None)
|
||||||
|
try:
|
||||||
|
quote = loader(tushare_code(code), expected_date=trade_date)
|
||||||
|
except Exception:
|
||||||
|
return _moneyflow_payload(None)
|
||||||
|
return _moneyflow_payload(quote)
|
||||||
|
|
||||||
def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]:
|
def _with_storage(self, dashboard: dict[str, Any], cached: bool) -> dict[str, Any]:
|
||||||
result = dict(dashboard)
|
result = dict(dashboard)
|
||||||
result["meta"] = {
|
result["meta"] = {
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
from typing import Any
|
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:
|
class SystemServiceMixin:
|
||||||
@@ -18,9 +25,6 @@ class SystemServiceMixin:
|
|||||||
first_encrypted = self.database.get_user_credentials(first_user_id)
|
first_encrypted = self.database.get_user_credentials(first_user_id)
|
||||||
first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {}
|
first_personal = self.vault.decrypt_json(first_encrypted) if first_encrypted else {}
|
||||||
defaults = {
|
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_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_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 "",
|
"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:
|
if key not in current:
|
||||||
current[key] = value
|
current[key] = value
|
||||||
changed = True
|
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):
|
if not isinstance(current.get("llm_models"), list):
|
||||||
migrated_models: list[dict[str, str]] = []
|
migrated_models: list[dict[str, str]] = []
|
||||||
for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")):
|
for role, label in (("primary", "原主模型"), ("fallback", "原辅助模型")):
|
||||||
@@ -56,26 +64,27 @@ class SystemServiceMixin:
|
|||||||
self.database.save_system_setting("credentials", self.vault.encrypt_json(current))
|
self.database.save_system_setting("credentials", self.vault.encrypt_json(current))
|
||||||
for row in self.database.list_user_credentials():
|
for row in self.database.list_user_credentials():
|
||||||
personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or ""))
|
personal = self.vault.decrypt_json(str(row.get("encrypted_payload") or ""))
|
||||||
if "tushare_token" in personal:
|
if any(key in personal for key in MARKET_SOURCE_SECRET_KEYS):
|
||||||
personal.pop("tushare_token", None)
|
for key in MARKET_SOURCE_SECRET_KEYS:
|
||||||
|
personal.pop(key, None)
|
||||||
self.database.save_user_credentials(
|
self.database.save_user_credentials(
|
||||||
int(row["user_id"]), self.vault.encrypt_json(personal)
|
int(row["user_id"]), self.vault.encrypt_json(personal)
|
||||||
)
|
)
|
||||||
return current
|
return current
|
||||||
|
|
||||||
def _save_system_credentials(self, credentials: dict[str, Any]) -> None:
|
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:
|
with self.system_lock:
|
||||||
self.database.save_system_setting("credentials", self.vault.encrypt_json(credentials))
|
self.database.save_system_setting("credentials", self.vault.encrypt_json(sanitized))
|
||||||
self._system_credentials = dict(credentials)
|
self._system_credentials = dict(sanitized)
|
||||||
if hasattr(self, "ifind"):
|
|
||||||
self.ifind.set_credentials(
|
|
||||||
str(credentials.get("ifind_refresh_token") or ""),
|
|
||||||
str(credentials.get("ifind_access_token") or ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.token)
|
return bool(self._datahub_status().get("configured"))
|
||||||
|
|
||||||
def _credentials(self) -> dict[str, str]:
|
def _credentials(self) -> dict[str, str]:
|
||||||
credentials = getattr(self._request_context, "credentials", {})
|
credentials = getattr(self._request_context, "credentials", {})
|
||||||
@@ -99,7 +108,7 @@ class SystemServiceMixin:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def token(self) -> str:
|
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]:
|
def system_status(self) -> dict[str, Any]:
|
||||||
platform = self._platform_llm_profile()
|
platform = self._platform_llm_profile()
|
||||||
@@ -119,6 +128,7 @@ class SystemServiceMixin:
|
|||||||
"base_url": profile["base_url"],
|
"base_url": profile["base_url"],
|
||||||
"model": profile["model"],
|
"model": profile["model"],
|
||||||
"configured": self._profile_configured(profile),
|
"configured": self._profile_configured(profile),
|
||||||
|
"api_key_last4": profile["api_key"][-4:],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -164,19 +174,6 @@ class SystemServiceMixin:
|
|||||||
|
|
||||||
def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]:
|
def save_system_settings(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
current = dict(self._system_credentials)
|
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 = {
|
existing_models = {
|
||||||
str(item.get("id") or ""): item
|
str(item.get("id") or ""): item
|
||||||
for item in current.get("llm_models") or []
|
for item in current.get("llm_models") or []
|
||||||
@@ -238,8 +235,6 @@ class SystemServiceMixin:
|
|||||||
raise ValueError("会员每日额度应为 1 至 1000。") from exc
|
raise ValueError("会员每日额度应为 1 至 1000。") from exc
|
||||||
current.update(
|
current.update(
|
||||||
{
|
{
|
||||||
"tushare_token": token,
|
|
||||||
"ifind_refresh_token": ifind_refresh_token,
|
|
||||||
"llm_models": models,
|
"llm_models": models,
|
||||||
"primary_model_id": primary_model_id,
|
"primary_model_id": primary_model_id,
|
||||||
"fallback_model_id": fallback_model_id,
|
"fallback_model_id": fallback_model_id,
|
||||||
@@ -259,7 +254,7 @@ class SystemServiceMixin:
|
|||||||
llm_access = self.llm_access_status()
|
llm_access = self.llm_access_status()
|
||||||
return {
|
return {
|
||||||
"configured": self.configured,
|
"configured": self.configured,
|
||||||
"mode": "tushare" if self.configured else "unavailable",
|
"mode": "datahub" if self.configured else "unavailable",
|
||||||
"llm_configured": self.llm_configured,
|
"llm_configured": self.llm_configured,
|
||||||
"llm_model": self.llm_primary_model if self.llm_configured else "",
|
"llm_model": self.llm_primary_model if self.llm_configured else "",
|
||||||
"llm_fallback_configured": self.llm_fallback_configured,
|
"llm_fallback_configured": self.llm_fallback_configured,
|
||||||
|
|||||||
@@ -11,6 +11,24 @@ PUBLIC_POST_HANDLERS = {
|
|||||||
"/api/auth/forget": "auth_forget",
|
"/api/auth/forget": "auth_forget",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Service-to-service bridge for the data hub console. These paths are guarded by
|
||||||
|
# the shared HUB_ADMIN_TOKEN header instead of a browser session, so they stay
|
||||||
|
# out of the user-facing route registry on purpose.
|
||||||
|
HUB_SERVICE_HANDLERS = {
|
||||||
|
"/api/hub-admin/session": "hub_session_check",
|
||||||
|
"/api/hub-admin/session/logout": "hub_session_logout",
|
||||||
|
"/api/hub-admin/password/check": "hub_password_check",
|
||||||
|
"/api/hub-admin/status": "hub_system_status",
|
||||||
|
"/api/hub-admin/settings/save": "hub_save_settings",
|
||||||
|
"/api/hub-admin/settings/test": "hub_test_model",
|
||||||
|
"/api/hub-admin/models/fetch": "hub_fetch_models",
|
||||||
|
"/api/hub-admin/members": "hub_members",
|
||||||
|
"/api/hub-admin/membership/save": "hub_save_membership",
|
||||||
|
"/api/hub-admin/invites": "hub_invites",
|
||||||
|
"/api/hub-admin/invites/create": "hub_create_invites",
|
||||||
|
"/api/hub-admin/invites/revoke": "hub_revoke_invite",
|
||||||
|
}
|
||||||
|
|
||||||
AUTHENTICATED_POST_HANDLERS = {
|
AUTHENTICATED_POST_HANDLERS = {
|
||||||
"/api/auth/logout": "auth_logout",
|
"/api/auth/logout": "auth_logout",
|
||||||
"/api/account/birth-profile": "save_birth_profile",
|
"/api/account/birth-profile": "save_birth_profile",
|
||||||
@@ -81,6 +99,10 @@ class ApplicationHttpDispatchMixin:
|
|||||||
|
|
||||||
def do_POST(self) -> None:
|
def do_POST(self) -> None:
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path in HUB_SERVICE_HANDLERS:
|
||||||
|
if self.require_service_token():
|
||||||
|
self._dispatch_named_handler(parsed.path, HUB_SERVICE_HANDLERS)
|
||||||
|
return
|
||||||
if self._dispatch_named_handler(parsed.path, PUBLIC_POST_HANDLERS):
|
if self._dispatch_named_handler(parsed.path, PUBLIC_POST_HANDLERS):
|
||||||
return
|
return
|
||||||
if not self.require_auth() or not self.require_csrf():
|
if not self.require_auth() or not self.require_csrf():
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
@@ -62,6 +63,14 @@ class HttpTransportMixin:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def require_service_token(self) -> bool:
|
||||||
|
expected = str(os.environ.get("HUB_ADMIN_TOKEN") or "").strip()
|
||||||
|
supplied = self.headers.get("X-Hub-Admin-Token", "")
|
||||||
|
if not expected or not supplied or not secrets.compare_digest(supplied, expected):
|
||||||
|
self.send_json({"error": "服务令牌校验失败。"}, HTTPStatus.UNAUTHORIZED)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def require_admin(self) -> bool:
|
def require_admin(self) -> bool:
|
||||||
if str(getattr(self, "auth_user", {}).get("role") or "user") != "admin":
|
if str(getattr(self, "auth_user", {}).get("role") or "user") != "admin":
|
||||||
self.send_json({"error": "需要管理员权限。"}, HTTPStatus.FORBIDDEN)
|
self.send_json({"error": "需要管理员权限。"}, HTTPStatus.FORBIDDEN)
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
|
|
||||||
|
from backend.features.accounts.security import token_hash, verify_password
|
||||||
|
|
||||||
|
|
||||||
|
class HubAdminHttpMixin:
|
||||||
|
"""Service-to-service bridge used by the data hub console (port 8766).
|
||||||
|
|
||||||
|
Every handler here is reached only after `require_service_token`, so the
|
||||||
|
shared `HUB_ADMIN_TOKEN` is the single trust boundary and no browser
|
||||||
|
session or CSRF token is involved. The data hub still verifies the site
|
||||||
|
session of the operator through `hub_session_check` before it exposes any
|
||||||
|
of these results to a page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _hub_body(self) -> dict:
|
||||||
|
return self.read_json_body(allow_empty=True)
|
||||||
|
|
||||||
|
def _hub_failure(self, exc: Exception) -> None:
|
||||||
|
self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
|
||||||
|
|
||||||
|
def hub_session_check(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
return
|
||||||
|
raw_token = str(body.get("session_token") or "")
|
||||||
|
user = (
|
||||||
|
self.application_service.database.session_user(token_hash(raw_token))
|
||||||
|
if raw_token
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
self.send_json({"ok": True, "authenticated": False})
|
||||||
|
return
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"authenticated": True,
|
||||||
|
"user": {
|
||||||
|
"id": int(user["id"]),
|
||||||
|
"username": str(user["username"]),
|
||||||
|
"role": str(user.get("role") or "user"),
|
||||||
|
"is_admin": str(user.get("role") or "user") == "admin",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def hub_session_logout(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
return
|
||||||
|
raw_token = str(body.get("session_token") or "")
|
||||||
|
if raw_token:
|
||||||
|
self.application_service.database.delete_session(token_hash(raw_token))
|
||||||
|
self.send_json({"ok": True})
|
||||||
|
|
||||||
|
def hub_password_check(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
user_id = int(body.get("user_id") or 0)
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
return
|
||||||
|
stored = self.application_service.database.user_password(user_id)
|
||||||
|
verified = bool(
|
||||||
|
stored
|
||||||
|
and verify_password(
|
||||||
|
str(body.get("password") or ""),
|
||||||
|
str(stored.get("password_salt") or ""),
|
||||||
|
str(stored.get("password_hash") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.send_json({"ok": True, "verified": verified})
|
||||||
|
|
||||||
|
def hub_system_status(self) -> None:
|
||||||
|
service = self.application_service
|
||||||
|
self.send_json({"ok": True, **service.system_status(), "users": service.admin_users()})
|
||||||
|
|
||||||
|
def hub_save_settings(self) -> None:
|
||||||
|
try:
|
||||||
|
result = self.application_service.save_system_settings(self._hub_body())
|
||||||
|
self.send_json({"ok": True, **result})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_test_model(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
result = self.application_service.test_system_llm_profile(
|
||||||
|
str(body.get("model_id") or ""), body.get("profile") or {}
|
||||||
|
)
|
||||||
|
self.send_json({"ok": True, "result": result})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_fetch_models(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
models = self.application_service.fetch_llm_models(
|
||||||
|
str(body.get("base_url") or ""),
|
||||||
|
str(body.get("api_key") or ""),
|
||||||
|
)
|
||||||
|
self.send_json({"ok": True, "models": models})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_members(self) -> None:
|
||||||
|
service = self.application_service
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"users": service.admin_users(),
|
||||||
|
"membership": service.system_status()["membership"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def hub_save_membership(self) -> None:
|
||||||
|
try:
|
||||||
|
service = self.application_service
|
||||||
|
service.update_membership(self._hub_body())
|
||||||
|
self.send_json({"ok": True, "users": service.admin_users()})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_invites(self) -> None:
|
||||||
|
self.send_json({"ok": True, **self.application_service.accounts.invite_overview()})
|
||||||
|
|
||||||
|
def hub_create_invites(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
accounts = self.application_service.accounts
|
||||||
|
codes = accounts.generate_invite_codes(
|
||||||
|
body.get("count") or 1,
|
||||||
|
str(body.get("note") or ""),
|
||||||
|
int(body.get("created_by") or 0),
|
||||||
|
)
|
||||||
|
self.send_json({"ok": True, "created": codes, **accounts.invite_overview()})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_revoke_invite(self) -> None:
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
accounts = self.application_service.accounts
|
||||||
|
accounts.revoke_invite_code(str(body.get("code_id") or body.get("code") or ""))
|
||||||
|
self.send_json({"ok": True, **accounts.invite_overview()})
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
@@ -6,6 +6,7 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
from backend.bootstrap.config import validate_text
|
from backend.bootstrap.config import validate_text
|
||||||
from backend.features.screener.compiler import LLMCompilerError, test_llm_connection
|
from backend.features.screener.compiler import LLMCompilerError, test_llm_connection
|
||||||
|
from backend.llm import transport as llm_transport
|
||||||
|
|
||||||
|
|
||||||
class LLMServiceMixin:
|
class LLMServiceMixin:
|
||||||
@@ -208,6 +209,37 @@ class LLMServiceMixin:
|
|||||||
start.isoformat(timespec="seconds"),
|
start.isoformat(timespec="seconds"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def fetch_llm_models(self, base_url: str, api_key: str) -> list[str]:
|
||||||
|
base_url = str(base_url or "").strip().rstrip("/")
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||||
|
raise ValueError("Base URL 格式不正确。")
|
||||||
|
key = str(api_key or "").strip()
|
||||||
|
if not key:
|
||||||
|
key = self._stored_api_key(base_url)
|
||||||
|
if not key:
|
||||||
|
raise ValueError("该供应商尚未保存 API Key,请先填写后再拉取模型列表。")
|
||||||
|
try:
|
||||||
|
return llm_transport.list_models(
|
||||||
|
api_key=key,
|
||||||
|
base_url=base_url,
|
||||||
|
timeout=15,
|
||||||
|
user_agent="XiaobaiReviewWeb/0.5",
|
||||||
|
)
|
||||||
|
except llm_transport.OpenAIHTTPError as exc:
|
||||||
|
raise ValueError(exc.describe("模型列表拉取失败")) from exc
|
||||||
|
except llm_transport.OpenAITransportError as exc:
|
||||||
|
raise ValueError(f"模型列表拉取失败:{exc}") from exc
|
||||||
|
|
||||||
|
def _stored_api_key(self, base_url: str) -> str:
|
||||||
|
for item in self._system_credentials.get("llm_models") or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
stored = str(item.get("base_url") or "").strip().rstrip("/")
|
||||||
|
if stored == base_url and item.get("api_key"):
|
||||||
|
return str(item["api_key"])
|
||||||
|
return ""
|
||||||
|
|
||||||
def test_system_llm_profile(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
def test_system_llm_profile(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
current = next(
|
current = next(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -67,6 +67,37 @@ def chat_completion(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_models(
|
||||||
|
*,
|
||||||
|
api_key: str,
|
||||||
|
base_url: str,
|
||||||
|
timeout: int,
|
||||||
|
user_agent: str,
|
||||||
|
) -> list[str]:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{base_url.rstrip('/')}/models",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"User-Agent": user_agent,
|
||||||
|
},
|
||||||
|
method="GET",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||||
|
payload = json.loads(response.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raise OpenAIHTTPError(exc.code, _http_error_detail(exc)) from exc
|
||||||
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc:
|
||||||
|
raise OpenAITransportError(str(exc)) from exc
|
||||||
|
items = payload.get("data") if isinstance(payload, dict) else payload
|
||||||
|
models = []
|
||||||
|
for item in items or []:
|
||||||
|
name = str((item or {}).get("id") or "") if isinstance(item, dict) else str(item or "")
|
||||||
|
if name and name not in models:
|
||||||
|
models.append(name)
|
||||||
|
return models
|
||||||
|
|
||||||
|
|
||||||
def stream_chat_completion(
|
def stream_chat_completion(
|
||||||
*,
|
*,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
# Start later (总工部署时) with:
|
# Start later (总工部署时) with:
|
||||||
# docker compose -f compose.yaml -f compose.datahub.yaml up -d
|
# docker compose -f compose.yaml -f compose.datahub.yaml up -d
|
||||||
#
|
#
|
||||||
# Required .env keys: DATAHUB_ENCRYPTION_KEY, DATAHUB_TOKEN, DATAHUB_ADMIN_PASSWORD, TUSHARE_TOKEN
|
# Required .env keys: DATAHUB_ENCRYPTION_KEY, DATAHUB_TOKEN, HUB_ADMIN_TOKEN, TUSHARE_TOKEN
|
||||||
|
# HUB_ADMIN_TOKEN 必须与主站 .env 里的同名变量一致:控制台靠它验证主站管理员会话。
|
||||||
|
|
||||||
services:
|
services:
|
||||||
xiaobai-datahub:
|
xiaobai-datahub:
|
||||||
@@ -18,8 +19,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATAHUB_ENCRYPTION_KEY: "${DATAHUB_ENCRYPTION_KEY:?DATAHUB_ENCRYPTION_KEY must be set}"
|
DATAHUB_ENCRYPTION_KEY: "${DATAHUB_ENCRYPTION_KEY:?DATAHUB_ENCRYPTION_KEY must be set}"
|
||||||
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
|
DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}"
|
||||||
DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}"
|
HUB_ADMIN_TOKEN: "${HUB_ADMIN_TOKEN:?HUB_ADMIN_TOKEN must be set}"
|
||||||
|
REVIEW_BASE_URL: "${REVIEW_BASE_URL:-http://xiaobai-review:8765}"
|
||||||
|
REVIEW_PUBLIC_URL: "${REVIEW_PUBLIC_URL:-}"
|
||||||
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
|
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
|
||||||
|
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
|
||||||
|
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
|
||||||
DATAHUB_DB_PATH: /app/data/datahub.db
|
DATAHUB_DB_PATH: /app/data/datahub.db
|
||||||
DATAHUB_BACKUP_DIR: /app/data/backups
|
DATAHUB_BACKUP_DIR: /app/data/backups
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ services:
|
|||||||
- ./.env
|
- ./.env
|
||||||
environment:
|
environment:
|
||||||
APP_ENCRYPTION_KEY: "${APP_ENCRYPTION_KEY:?APP_ENCRYPTION_KEY must be set in .env}"
|
APP_ENCRYPTION_KEY: "${APP_ENCRYPTION_KEY:?APP_ENCRYPTION_KEY must be set in .env}"
|
||||||
|
# Shared secret for /api/hub-admin/*: the data hub console (8766) uses it to
|
||||||
|
# verify this site's admin sessions and to read/write the model pool,
|
||||||
|
# members and invite codes. Unset means the bridge refuses every call.
|
||||||
|
HUB_ADMIN_TOKEN: "${HUB_ADMIN_TOKEN:-}"
|
||||||
|
# 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_BASE_URL: "${DATAHUB_BASE_URL:-http://192.168.200.11:8766}"
|
||||||
DATAHUB_READ_CALENDAR: "1"
|
DATAHUB_READ_CALENDAR: "1"
|
||||||
DATAHUB_READ_STOCKS: "1"
|
DATAHUB_READ_STOCKS: "1"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"api_exact_paths": 56,
|
"api_exact_paths": 56,
|
||||||
"api_prefixes": 0,
|
"api_prefixes": 0,
|
||||||
"api_patterns": 11,
|
"api_patterns": 11,
|
||||||
"database_tables": 37,
|
"database_tables": 38,
|
||||||
"frontend_page_fragments": 12
|
"frontend_page_fragments": 12
|
||||||
},
|
},
|
||||||
"pages": [
|
"pages": [
|
||||||
@@ -193,6 +193,7 @@
|
|||||||
"heaven_readings",
|
"heaven_readings",
|
||||||
"job_runs",
|
"job_runs",
|
||||||
"account_switch_grants",
|
"account_switch_grants",
|
||||||
|
"invite_codes",
|
||||||
"schema_migrations"
|
"schema_migrations"
|
||||||
],
|
],
|
||||||
"background_job_methods": [
|
"background_job_methods": [
|
||||||
@@ -207,27 +208,22 @@
|
|||||||
{
|
{
|
||||||
"provider": "datahub",
|
"provider": "datahub",
|
||||||
"path": "backend/data/datahub/client.py",
|
"path": "backend/data/datahub/client.py",
|
||||||
"runtime_role": "optional official EOD read path behind per-dataset flags"
|
"runtime_role": "website-only read path; official EOD, live quotes, and licensed iFinD"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"provider": "ifind",
|
"provider": "ifind",
|
||||||
"path": "backend/data/providers/ifind_client.py",
|
"path": "xiaobai-datahub/datahub/adapters/ifind.py",
|
||||||
"runtime_role": "realtime, charts, snapshots, enrichment"
|
"runtime_role": "licensed iFinD source inside the data hub"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"provider": "eastmoney",
|
"provider": "eastmoney",
|
||||||
"path": "backend/features/market/charts.py",
|
"path": "xiaobai-datahub/datahub/adapters/eastmoney.py",
|
||||||
"runtime_role": "display chart fallback"
|
"runtime_role": "free realtime quotes and shenwan inside the data hub"
|
||||||
},
|
|
||||||
{
|
|
||||||
"provider": "eastmoney",
|
|
||||||
"path": "backend/data/realtime.py",
|
|
||||||
"runtime_role": "isolated realtime observation and intraday dashboard fallback"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"provider": "tencent",
|
"provider": "tencent",
|
||||||
"path": "backend/data/realtime.py",
|
"path": "xiaobai-datahub/datahub/adapters/tencent.py",
|
||||||
"runtime_role": "index observation and intraday quote fallback"
|
"runtime_role": "free index and stock quotes inside the data hub"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"provider_domains": [
|
"provider_domains": [
|
||||||
@@ -296,16 +292,16 @@
|
|||||||
"owner": "backend/data/gateway.py"
|
"owner": "backend/data/gateway.py"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"client": "IfindHttpClient",
|
"client": "HubIfindProxy",
|
||||||
|
"owner": "backend/data/gateway.py"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"client": "HubRealtimeProxy",
|
||||||
"owner": "backend/data/gateway.py"
|
"owner": "backend/data/gateway.py"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"client": "MarketChartClient",
|
"client": "MarketChartClient",
|
||||||
"owner": "backend/data/gateway.py"
|
"owner": "backend/data/gateway.py"
|
||||||
},
|
|
||||||
{
|
|
||||||
"client": "WebRealtimeAggregator",
|
|
||||||
"owner": "backend/data/gateway.py"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"heaven_service_owners": {
|
"heaven_service_owners": {
|
||||||
@@ -463,28 +459,33 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/screener/foundation.css",
|
"path": "frontend/pages/screener/foundation.css",
|
||||||
"bytes": 103547,
|
"bytes": 103683,
|
||||||
"lines": 6576
|
"lines": 6582
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/heaven/page.js",
|
"path": "frontend/pages/heaven/page.js",
|
||||||
"bytes": 97268,
|
"bytes": 97770,
|
||||||
"lines": 2070
|
"lines": 2079
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/shared/shell.css",
|
"path": "frontend/shared/shell.css",
|
||||||
"bytes": 63733,
|
"bytes": 64839,
|
||||||
"lines": 3767
|
"lines": 3794
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/heaven/engine.py",
|
"path": "backend/features/heaven/engine.py",
|
||||||
"bytes": 51764,
|
"bytes": 52046,
|
||||||
"lines": 1183
|
"lines": 1192
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/index.html",
|
"path": "frontend/index.html",
|
||||||
"bytes": 48447,
|
"bytes": 46900,
|
||||||
"lines": 665
|
"lines": 638
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_industries.py",
|
||||||
|
"bytes": 38936,
|
||||||
|
"lines": 894
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/screener/catalog.py",
|
"path": "backend/features/screener/catalog.py",
|
||||||
@@ -498,8 +499,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/data/providers/tushare_dashboard.py",
|
"path": "backend/data/providers/tushare_dashboard.py",
|
||||||
"bytes": 33603,
|
"bytes": 35001,
|
||||||
"lines": 784
|
"lines": 805
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "database.py",
|
"path": "database.py",
|
||||||
@@ -511,11 +512,6 @@
|
|||||||
"bytes": 31756,
|
"bytes": 31756,
|
||||||
"lines": 562
|
"lines": 562
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/data/providers/tushare_industries.py",
|
|
||||||
"bytes": 26540,
|
|
||||||
"lines": 616
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/features/heaven/manual.py",
|
"path": "backend/features/heaven/manual.py",
|
||||||
"bytes": 24521,
|
"bytes": 24521,
|
||||||
@@ -533,13 +529,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/market/preview.js",
|
"path": "frontend/pages/market/preview.js",
|
||||||
"bytes": 18339,
|
"bytes": 18230,
|
||||||
"lines": 450
|
"lines": 447
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/heaven/trend.py",
|
"path": "backend/features/heaven/trend.py",
|
||||||
"bytes": 16772,
|
"bytes": 17005,
|
||||||
"lines": 370
|
"lines": 373
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_auction_scoring.py",
|
"path": "backend/features/market/insights_auction_scoring.py",
|
||||||
@@ -548,13 +544,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/market/charts.js",
|
"path": "frontend/pages/market/charts.js",
|
||||||
"bytes": 15311,
|
"bytes": 15743,
|
||||||
"lines": 387
|
"lines": 401
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "frontend/shared/admin.js",
|
|
||||||
"bytes": 15235,
|
|
||||||
"lines": 289
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/shared/dashboard.js",
|
"path": "frontend/shared/dashboard.js",
|
||||||
@@ -573,18 +564,13 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/heaven/market_context.py",
|
"path": "backend/features/heaven/market_context.py",
|
||||||
"bytes": 13681,
|
"bytes": 14467,
|
||||||
"lines": 338
|
"lines": 356
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/shared/session.js",
|
"path": "frontend/shared/session.js",
|
||||||
"bytes": 13219,
|
"bytes": 13633,
|
||||||
"lines": 289
|
"lines": 296
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/features/system/service.py",
|
|
||||||
"bytes": 12937,
|
|
||||||
"lines": 271
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_auction_data.py",
|
"path": "backend/features/market/insights_auction_data.py",
|
||||||
@@ -592,9 +578,9 @@
|
|||||||
"lines": 318
|
"lines": 318
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/data/providers/tushare_indices.py",
|
"path": "backend/features/system/service.py",
|
||||||
"bytes": 10956,
|
"bytes": 12242,
|
||||||
"lines": 248
|
"lines": 266
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_auction.py",
|
"path": "backend/features/market/insights_auction.py",
|
||||||
@@ -611,6 +597,11 @@
|
|||||||
"bytes": 9876,
|
"bytes": 9876,
|
||||||
"lines": 224
|
"lines": 224
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_daily.py",
|
||||||
|
"bytes": 9539,
|
||||||
|
"lines": 241
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_themes.py",
|
"path": "backend/features/market/insights_themes.py",
|
||||||
"bytes": 9348,
|
"bytes": 9348,
|
||||||
@@ -618,7 +609,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/market/entity-detail.js",
|
"path": "frontend/pages/market/entity-detail.js",
|
||||||
"bytes": 9119,
|
"bytes": 9139,
|
||||||
"lines": 199
|
"lines": 199
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -631,6 +622,11 @@
|
|||||||
"bytes": 8562,
|
"bytes": 8562,
|
||||||
"lines": 238
|
"lines": 238
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_indices.py",
|
||||||
|
"bytes": 8447,
|
||||||
|
"lines": 189
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/mentor/page.html",
|
"path": "frontend/pages/mentor/page.html",
|
||||||
"bytes": 8357,
|
"bytes": 8357,
|
||||||
@@ -641,16 +637,6 @@
|
|||||||
"bytes": 6983,
|
"bytes": 6983,
|
||||||
"lines": 146
|
"lines": 146
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/data/providers/tushare_daily.py",
|
|
||||||
"bytes": 6949,
|
|
||||||
"lines": 168
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/application.py",
|
|
||||||
"bytes": 6751,
|
|
||||||
"lines": 178
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_popularity.py",
|
"path": "backend/features/market/insights_popularity.py",
|
||||||
"bytes": 6739,
|
"bytes": 6739,
|
||||||
@@ -661,14 +647,19 @@
|
|||||||
"bytes": 6547,
|
"bytes": 6547,
|
||||||
"lines": 220
|
"lines": 220
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/application.py",
|
||||||
|
"bytes": 6500,
|
||||||
|
"lines": 164
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/sentiment/page.html",
|
"path": "frontend/pages/sentiment/page.html",
|
||||||
"bytes": 6488,
|
"bytes": 6488,
|
||||||
"lines": 81
|
"lines": 81
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/data/providers/tushare_stocks.py",
|
"path": "frontend/pages/market/stock-detail.js",
|
||||||
"bytes": 6244,
|
"bytes": 6325,
|
||||||
"lines": 137
|
"lines": 137
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -681,16 +672,16 @@
|
|||||||
"bytes": 6092,
|
"bytes": 6092,
|
||||||
"lines": 138
|
"lines": 138
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "frontend/pages/market/stock-detail.js",
|
|
||||||
"bytes": 6041,
|
|
||||||
"lines": 134
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/dragon-tiger/page.html",
|
"path": "frontend/pages/dragon-tiger/page.html",
|
||||||
"bytes": 5754,
|
"bytes": 5754,
|
||||||
"lines": 85
|
"lines": 85
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_stocks.py",
|
||||||
|
"bytes": 5592,
|
||||||
|
"lines": 123
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages.config.js",
|
"path": "frontend/pages.config.js",
|
||||||
"bytes": 5385,
|
"bytes": 5385,
|
||||||
@@ -706,6 +697,11 @@
|
|||||||
"bytes": 5350,
|
"bytes": 5350,
|
||||||
"lines": 74
|
"lines": 74
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/http/dispatch.py",
|
||||||
|
"bytes": 5281,
|
||||||
|
"lines": 139
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/shared/feedback.js",
|
"path": "frontend/shared/feedback.js",
|
||||||
"bytes": 5157,
|
"bytes": 5157,
|
||||||
@@ -721,6 +717,16 @@
|
|||||||
"bytes": 4712,
|
"bytes": 4712,
|
||||||
"lines": 106
|
"lines": 106
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_helpers.py",
|
||||||
|
"bytes": 4406,
|
||||||
|
"lines": 124
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/shared/admin.js",
|
||||||
|
"bytes": 4376,
|
||||||
|
"lines": 107
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/routes.py",
|
"path": "backend/features/market/routes.py",
|
||||||
"bytes": 4276,
|
"bytes": 4276,
|
||||||
@@ -736,11 +742,6 @@
|
|||||||
"bytes": 4242,
|
"bytes": 4242,
|
||||||
"lines": 129
|
"lines": 129
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/http/dispatch.py",
|
|
||||||
"bytes": 4196,
|
|
||||||
"lines": 117
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend/shared/table.js",
|
"path": "frontend/shared/table.js",
|
||||||
"bytes": 3790,
|
"bytes": 3790,
|
||||||
@@ -766,16 +767,16 @@
|
|||||||
"bytes": 3316,
|
"bytes": 3316,
|
||||||
"lines": 55
|
"lines": 55
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/pages/rotation/page.html",
|
||||||
|
"bytes": 3299,
|
||||||
|
"lines": 57
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_context.py",
|
"path": "backend/features/market/insights_context.py",
|
||||||
"bytes": 3175,
|
"bytes": 3175,
|
||||||
"lines": 84
|
"lines": 84
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "frontend/pages/rotation/page.html",
|
|
||||||
"bytes": 3031,
|
|
||||||
"lines": 54
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/market/bindings.js",
|
"path": "frontend/pages/market/bindings.js",
|
||||||
"bytes": 2663,
|
"bytes": 2663,
|
||||||
@@ -783,13 +784,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/accounts/application.py",
|
"path": "backend/features/accounts/application.py",
|
||||||
"bytes": 2514,
|
"bytes": 2597,
|
||||||
"lines": 63
|
"lines": 69
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "backend/data/providers/tushare_helpers.py",
|
|
||||||
"bytes": 2360,
|
|
||||||
"lines": 75
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/jobs/service.py",
|
"path": "backend/jobs/service.py",
|
||||||
@@ -801,16 +797,16 @@
|
|||||||
"bytes": 2299,
|
"bytes": 2299,
|
||||||
"lines": 57
|
"lines": 57
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_client.py",
|
||||||
|
"bytes": 2263,
|
||||||
|
"lines": 70
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/screener/regime.py",
|
"path": "backend/features/screener/regime.py",
|
||||||
"bytes": 2202,
|
"bytes": 2202,
|
||||||
"lines": 53
|
"lines": 53
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/data/providers/tushare_client.py",
|
|
||||||
"bytes": 2166,
|
|
||||||
"lines": 68
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/popularity/page.html",
|
"path": "frontend/pages/popularity/page.html",
|
||||||
"bytes": 2165,
|
"bytes": 2165,
|
||||||
@@ -846,6 +842,11 @@
|
|||||||
"bytes": 1642,
|
"bytes": 1642,
|
||||||
"lines": 53
|
"lines": 53
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/data/providers/tushare_transport.py",
|
||||||
|
"bytes": 1592,
|
||||||
|
"lines": 50
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights.py",
|
"path": "backend/features/market/insights.py",
|
||||||
"bytes": 1580,
|
"bytes": 1580,
|
||||||
@@ -856,11 +857,6 @@
|
|||||||
"bytes": 1535,
|
"bytes": 1535,
|
||||||
"lines": 39
|
"lines": 39
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/data/providers/tushare_transport.py",
|
|
||||||
"bytes": 1455,
|
|
||||||
"lines": 48
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/features/themes/routes.py",
|
"path": "backend/features/themes/routes.py",
|
||||||
"bytes": 1337,
|
"bytes": 1337,
|
||||||
|
|||||||
@@ -320,6 +320,11 @@ PC端统一采用以下固定骨架:
|
|||||||
|
|
||||||
### 6.1 数据源职责
|
### 6.1 数据源职责
|
||||||
|
|
||||||
|
运行边界:下表中的职责全部由独立的 `xiaobai-datahub` 数据中枢执行。主网站只按固定业务
|
||||||
|
协议请求“行情、日K、分时、申万、竞价”等数据,不接触任何提供方参数或凭据,也不决定优先级、
|
||||||
|
重试、降级和回填。数据中枢是主网站唯一的行情出口;中枢暂时取不到新数据时,网站只能读取
|
||||||
|
已经归档的真实快照,不能绕回旧提供方直连接口。
|
||||||
|
|
||||||
| 数据源 | 可用于正式计算 | 主要职责 |
|
| 数据源 | 可用于正式计算 | 主要职责 |
|
||||||
|---|:---:|---|
|
|---|:---:|---|
|
||||||
| Tushare | 是 | 交易日历、股票主表、日线、估值、财务、资金流、申万行业、涨跌停、9:25竞价、热榜、龙虎榜 |
|
| Tushare | 是 | 交易日历、股票主表、日线、估值、财务、资金流、申万行业、涨跌停、9:25竞价、热榜、龙虎榜 |
|
||||||
|
|||||||
+8
-35
@@ -73,6 +73,7 @@
|
|||||||
<label class="form-field"><span>账号名</span><input id="authUsername" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>
|
<label class="form-field"><span>账号名</span><input id="authUsername" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>
|
||||||
<label class="form-field"><span>密码</span><input id="authPassword" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>
|
<label class="form-field"><span>密码</span><input id="authPassword" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>
|
||||||
<label id="authConfirmField" class="form-field" hidden><span>确认密码</span><input id="authPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>
|
<label id="authConfirmField" class="form-field" hidden><span>确认密码</span><input id="authPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>
|
||||||
|
<label id="authInviteField" class="form-field" hidden><span>邀请码</span><input id="authInviteCode" type="text" maxlength="32" autocomplete="off" spellcheck="false" placeholder="XB-XXXX-XXXX-XXXX"></label>
|
||||||
<p id="authError" class="auth-error" hidden></p>
|
<p id="authError" class="auth-error" hidden></p>
|
||||||
<button id="authSubmitButton" class="button primary" type="submit">登录</button>
|
<button id="authSubmitButton" class="button primary" type="submit">登录</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -156,7 +157,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="refreshButton" class="button command-button" type="button" title="刷新"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
|
<button id="refreshButton" class="button command-button" type="button" title="刷新"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
|
||||||
<button id="syncButton" class="button primary command-button" type="button" title="后台刷新" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
|
<button id="syncButton" class="button primary command-button" type="button" title="后台刷新" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
|
||||||
<button id="settingsButton" class="button command-button" type="button" title="系统管理" hidden><i data-lucide="settings-2"></i><span>系统管理</span></button>
|
<button id="marketAdminButton" class="button command-button" type="button" title="行情管理" hidden><i data-lucide="settings-2"></i><span>行情管理</span></button>
|
||||||
|
<button id="settingsButton" class="button command-button" type="button" title="数据中枢" hidden><i data-lucide="database"></i><span>数据中枢</span></button>
|
||||||
<div class="account-menu-shell">
|
<div class="account-menu-shell">
|
||||||
<div id="accountRoleBadges" class="account-role-badges" aria-label="账号身份">
|
<div id="accountRoleBadges" class="account-role-badges" aria-label="账号身份">
|
||||||
<span id="accountAdminBadge" class="account-role-badge admin-role-badge" title="管理员" hidden><i data-lucide="shield-check"></i><span>管理员</span></span>
|
<span id="accountAdminBadge" class="account-role-badge admin-role-badge" title="管理员" hidden><i data-lucide="shield-check"></i><span>管理员</span></span>
|
||||||
@@ -592,28 +594,21 @@
|
|||||||
|
|
||||||
<dialog id="adminDialog" class="settings-dialog admin-dialog" aria-labelledby="adminDialogTitle">
|
<dialog id="adminDialog" class="settings-dialog admin-dialog" aria-labelledby="adminDialogTitle">
|
||||||
<div class="dialog-header">
|
<div class="dialog-header">
|
||||||
<div><span class="dialog-eyebrow">管理员</span><h2 id="adminDialogTitle">系统配置</h2></div>
|
<div><span class="dialog-eyebrow">管理员</span><h2 id="adminDialogTitle">行情管理</h2></div>
|
||||||
<button id="closeAdminDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
|
<button id="closeAdminDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="adminConnectionStatus" class="connection-status" aria-live="polite">正在读取系统状态</div>
|
<div id="adminConnectionStatus" class="connection-status" aria-live="polite">正在读取系统状态</div>
|
||||||
<div class="admin-section-picker">
|
<p class="form-hint">模型池、会员与邀请码已统一在数据中枢管理,点击顶栏「数据中枢」进入。</p>
|
||||||
<label for="adminSectionSelect">管理项目</label>
|
|
||||||
<select id="adminSectionSelect">
|
|
||||||
<option value="market">行情管理</option>
|
|
||||||
<option value="models">模型池</option>
|
|
||||||
<option value="members">会员管理</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="admin-panel" data-admin-panel="market">
|
<div class="admin-panel" data-admin-panel="market">
|
||||||
<form id="systemMarketForm" class="settings-section">
|
<form id="systemMarketForm" class="settings-section">
|
||||||
<div class="settings-section-heading"><h3>公共行情</h3><span id="systemDataStatus">待检查</span></div>
|
<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>行情来源凭据</span><input id="systemTokenInput" type="text" value="请在数据中枢后台统一管理" disabled></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="systemIfindTokenInput" type="text" value="请在数据中枢后台统一管理" disabled></label>
|
||||||
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
|
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
|
||||||
<p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>
|
<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="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 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>
|
</form>
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<div class="settings-section-heading"><h3>历史数据回补</h3><span>管理员任务</span></div>
|
<div class="settings-section-heading"><h3>历史数据回补</h3><span>管理员任务</span></div>
|
||||||
@@ -624,28 +619,6 @@
|
|||||||
<div class="dialog-actions"><button id="backfillButton" class="button" type="button">开始回补</button></div>
|
<div class="dialog-actions"><button id="backfillButton" class="button" type="button">开始回补</button></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-panel" data-admin-panel="models" hidden>
|
|
||||||
<form id="systemModelsForm" class="settings-section">
|
|
||||||
<div class="settings-section-heading"><h3>平台模型池</h3><span>会员共享</span></div>
|
|
||||||
<div class="model-role-selectors">
|
|
||||||
<label class="form-field"><span>主模型</span><select id="platformPrimaryModelSelect"></select></label>
|
|
||||||
<label class="form-field"><span>辅助模型</span><select id="platformFallbackModelSelect"><option value="">不启用辅助模型</option></select></label>
|
|
||||||
</div>
|
|
||||||
<div id="modelPoolList" class="model-pool-list"></div>
|
|
||||||
<div class="dialog-actions admin-inline-actions"><button id="addPlatformModel" class="button" type="button"><i data-lucide="plus"></i>添加模型</button><button class="button primary" type="submit">保存模型池</button></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="admin-panel" data-admin-panel="members" hidden>
|
|
||||||
<form id="membershipSettingsForm" class="settings-section membership-limit-form">
|
|
||||||
<div class="settings-section-heading"><h3>会员调用额度</h3><span>每日自动重置</span></div>
|
|
||||||
<label class="form-field compact-number-field"><span>会员每日智能分析上限</span><input id="memberDailyLimit" type="number" min="1" max="1000" value="50"></label>
|
|
||||||
<div class="dialog-actions"><button class="button primary" type="submit">保存调用额度</button></div>
|
|
||||||
</form>
|
|
||||||
<section class="settings-section">
|
|
||||||
<div class="settings-section-heading"><h3>会员账号</h3><span>手动开通与续期</span></div>
|
|
||||||
<div id="adminUsersList" class="admin-users-list"></div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<div id="loadingOverlay" class="loading-overlay" hidden>
|
<div id="loadingOverlay" class="loading-overlay" hidden>
|
||||||
|
|||||||
+11
-2
@@ -15,6 +15,7 @@
|
|||||||
error: "",
|
error: "",
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
|
inviteCode: "",
|
||||||
passwordVisible: false,
|
passwordVisible: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@
|
|||||||
`<label class="form-field"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" placeholder="请输入账号名" value="${escapeHtml(state.username)}" required></label>`,
|
`<label class="form-field"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" placeholder="请输入账号名" value="${escapeHtml(state.username)}" required></label>`,
|
||||||
`<label class="form-field"><span>密码</span><span class="login-password-wrap"><input id="loginPassword" class="${invalid.trim()}" type="${passwordType}" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" placeholder="请输入密码" value="${escapeHtml(state.password)}" required><button class="login-password-toggle" type="button" data-login-action="toggle-password" aria-pressed="${state.passwordVisible ? "true" : "false"}" aria-label="${state.passwordVisible ? "隐藏密码" : "显示密码"}">${passwordToggle}</button></span></label>`,
|
`<label class="form-field"><span>密码</span><span class="login-password-wrap"><input id="loginPassword" class="${invalid.trim()}" type="${passwordType}" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" placeholder="请输入密码" value="${escapeHtml(state.password)}" required><button class="login-password-toggle" type="button" data-login-action="toggle-password" aria-pressed="${state.passwordVisible ? "true" : "false"}" aria-label="${state.passwordVisible ? "隐藏密码" : "显示密码"}">${passwordToggle}</button></span></label>`,
|
||||||
`<label class="form-field" id="loginConfirmField"${registering ? "" : " hidden"}><span>确认密码</span><input id="loginPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"${registering ? " required" : ""}></label>`,
|
`<label class="form-field" id="loginConfirmField"${registering ? "" : " hidden"}><span>确认密码</span><input id="loginPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"${registering ? " required" : ""}></label>`,
|
||||||
|
`<label class="form-field" id="loginInviteField"${registering ? "" : " hidden"}><span>邀请码</span><input id="loginInviteCode" type="text" maxlength="32" autocomplete="off" spellcheck="false" placeholder="XB-XXXX-XXXX-XXXX" value="${escapeHtml(state.inviteCode)}"${registering ? " required" : ""}></label>`,
|
||||||
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : '<p class="login-error" hidden></p>',
|
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : '<p class="login-error" hidden></p>',
|
||||||
`<button class="button primary login-submit" type="submit"${state.loading ? " disabled" : ""}>`,
|
`<button class="button primary login-submit" type="submit"${state.loading ? " disabled" : ""}>`,
|
||||||
state.loading ? '<span class="login-spinner" aria-hidden="true"></span>' : "",
|
state.loading ? '<span class="login-spinner" aria-hidden="true"></span>' : "",
|
||||||
@@ -289,9 +291,14 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const username = document.querySelector("#loginUsername").value.trim();
|
const username = document.querySelector("#loginUsername").value.trim();
|
||||||
const password = document.querySelector("#loginPassword").value;
|
const password = document.querySelector("#loginPassword").value;
|
||||||
|
const registering = state.mode === "register";
|
||||||
|
const inviteCode = registering
|
||||||
|
? document.querySelector("#loginInviteCode").value.trim()
|
||||||
|
: "";
|
||||||
state.username = username;
|
state.username = username;
|
||||||
state.password = password;
|
state.password = password;
|
||||||
if (state.mode === "register" && password !== document.querySelector("#loginPasswordConfirm").value) {
|
state.inviteCode = inviteCode;
|
||||||
|
if (registering && password !== document.querySelector("#loginPasswordConfirm").value) {
|
||||||
setError("两次输入的密码不一致。");
|
setError("两次输入的密码不一致。");
|
||||||
render();
|
render();
|
||||||
return;
|
return;
|
||||||
@@ -300,7 +307,9 @@
|
|||||||
setError("");
|
setError("");
|
||||||
render();
|
render();
|
||||||
try {
|
try {
|
||||||
await api.request(`/api/auth/${state.mode}`, "POST", { username, password });
|
await api.request(`/api/auth/${state.mode}`, "POST", registering
|
||||||
|
? { username, password, invite_code: inviteCode }
|
||||||
|
: { username, password });
|
||||||
await celebrateLogin();
|
await celebrateLogin();
|
||||||
enterApp();
|
enterApp();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -52,8 +52,7 @@
|
|||||||
{ key: "system/profile", label: "账号资料", icon: "user" },
|
{ key: "system/profile", label: "账号资料", icon: "user" },
|
||||||
{ key: "system/password", label: "修改密码", icon: "lock" },
|
{ key: "system/password", label: "修改密码", icon: "lock" },
|
||||||
{ key: "system/membership", label: "会员状态", icon: "gem" },
|
{ key: "system/membership", label: "会员状态", icon: "gem" },
|
||||||
{ key: "system/admin", label: "系统设置", icon: "sliders-horizontal", adminOnly: true },
|
{ key: "system/admin", label: "行情管理", icon: "sliders-horizontal", adminOnly: true }
|
||||||
{ key: "system/members", label: "会员管理", icon: "users", adminOnly: true }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+36
-375
@@ -198,11 +198,7 @@
|
|||||||
system: {
|
system: {
|
||||||
account: null,
|
account: null,
|
||||||
admin: null,
|
admin: null,
|
||||||
adminTab: "market",
|
|
||||||
models: [],
|
|
||||||
accounts: [],
|
accounts: [],
|
||||||
editingModelId: "",
|
|
||||||
editingUserId: "",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3137,7 +3133,6 @@
|
|||||||
"system/password": setupSystemPage,
|
"system/password": setupSystemPage,
|
||||||
"system/membership": setupSystemPage,
|
"system/membership": setupSystemPage,
|
||||||
"system/admin": setupSystemPage,
|
"system/admin": setupSystemPage,
|
||||||
"system/members": setupSystemPage,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPLEX_LOADERS = {
|
const COMPLEX_LOADERS = {
|
||||||
@@ -3160,7 +3155,6 @@
|
|||||||
"system/password": loadSystem,
|
"system/password": loadSystem,
|
||||||
"system/membership": loadSystem,
|
"system/membership": loadSystem,
|
||||||
"system/admin": loadSystem,
|
"system/admin": loadSystem,
|
||||||
"system/members": loadSystem,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isComplexPage(key) {
|
function isComplexPage(key) {
|
||||||
@@ -3402,9 +3396,9 @@
|
|||||||
const payload = detail && detail.payload ? detail.payload : {};
|
const payload = detail && detail.payload ? detail.payload : {};
|
||||||
const meta = payload.meta || {};
|
const meta = payload.meta || {};
|
||||||
if (tab === "daily") {
|
if (tab === "daily") {
|
||||||
const bars = (payload.prices || []).slice(-48);
|
const bars = (payload.prices || []).slice(-45);
|
||||||
const last = bars.length ? bars[bars.length - 1].trade_date : "";
|
const last = bars.length ? bars[bars.length - 1].trade_date : "";
|
||||||
return "日线 · 近48根 · 至 " + (displayCompactDate(last) || "--");
|
return "日线 · 近45根 · 至 " + (displayCompactDate(last) || "--");
|
||||||
}
|
}
|
||||||
const d = displayCompactDate(meta.intraday_trade_date) || displayCompactDate(meta.trade_date);
|
const d = displayCompactDate(meta.intraday_trade_date) || displayCompactDate(meta.trade_date);
|
||||||
return "分时 · " + (d || "--");
|
return "分时 · " + (d || "--");
|
||||||
@@ -3702,7 +3696,7 @@
|
|||||||
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
|
const W = 360, H = 240, padL = 8, padR = 52, padT = 10, padB = 22;
|
||||||
const pw = W - padL - padR;
|
const pw = W - padL - padR;
|
||||||
const ph = H - padT - padB;
|
const ph = H - padT - padB;
|
||||||
const prices = (payload.prices || []).slice(-48);
|
const prices = (payload.prices || []).slice(-45);
|
||||||
|
|
||||||
if (prices.length < 2) return emptyChart("日线数据暂不可用");
|
if (prices.length < 2) return emptyChart("日线数据暂不可用");
|
||||||
|
|
||||||
@@ -4938,7 +4932,7 @@
|
|||||||
return '<div class="m-state m-motion-rise-in" data-system-page="forbidden">' +
|
return '<div class="m-state m-motion-rise-in" data-system-page="forbidden">' +
|
||||||
'<span class="m-state-icon">' + icon("lock", 26) + "</span>" +
|
'<span class="m-state-icon">' + icon("lock", 26) + "</span>" +
|
||||||
"<p>仅管理员可访问</p>" +
|
"<p>仅管理员可访问</p>" +
|
||||||
"<small>系统设置和会员管理需要管理员权限。</small>" +
|
"<small>行情管理与数据中枢需要管理员权限。</small>" +
|
||||||
"</div>";
|
"</div>";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4974,7 +4968,6 @@
|
|||||||
state.sort = { key: "", dir: null };
|
state.sort = { key: "", dir: null };
|
||||||
state.sortTable = { cols: null, reapply: null };
|
state.sortTable = { cols: null, reapply: null };
|
||||||
state.detail = null;
|
state.detail = null;
|
||||||
if (key === "system/admin") state.system.adminTab = state.system.adminTab || "market";
|
|
||||||
document.getElementById("m-view").classList.add("m-view-feature");
|
document.getElementById("m-view").classList.add("m-view-feature");
|
||||||
global.MobileRouter.updateHeader({ title: findLabel(key) || key, back: true, actions: "" });
|
global.MobileRouter.updateHeader({ title: findLabel(key) || key, back: true, actions: "" });
|
||||||
document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
|
document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
|
||||||
@@ -4982,7 +4975,7 @@
|
|||||||
|
|
||||||
function loadSystem() {
|
function loadSystem() {
|
||||||
const key = state.key;
|
const key = state.key;
|
||||||
if ((key === "system/admin" || key === "system/members") && !global.MobileSession.isAdmin()) {
|
if (key === "system/admin" && !global.MobileSession.isAdmin()) {
|
||||||
systemFill(systemForbiddenHtml());
|
systemFill(systemForbiddenHtml());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4991,15 +4984,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const seq = nextSeq();
|
const seq = nextSeq();
|
||||||
const url = (key === "system/admin" || key === "system/members") ? "/api/admin/settings" : "/api/account/status";
|
const url = key === "system/admin" ? "/api/admin/settings" : "/api/account/status";
|
||||||
global.MobileAPI.request(url).then(function (payload) {
|
return global.MobileAPI.request(url).then(function (payload) {
|
||||||
if (seq !== state.seq || state.key !== key) return;
|
if (seq !== state.seq || state.key !== key) return;
|
||||||
if (key === "system/admin" || key === "system/members") {
|
if (key === "system/admin") {
|
||||||
state.system.admin = payload || {};
|
state.system.admin = payload || {};
|
||||||
state.system.models = ((payload.llm && payload.llm.models) || []).map(function (item) {
|
renderSystemAdmin();
|
||||||
return Object.assign({}, item);
|
|
||||||
});
|
|
||||||
renderSystemAdmin(key);
|
|
||||||
} else {
|
} else {
|
||||||
state.system.account = payload || {};
|
state.system.account = payload || {};
|
||||||
if (key === "system/profile") renderSystemProfile();
|
if (key === "system/profile") renderSystemProfile();
|
||||||
@@ -5089,8 +5079,7 @@
|
|||||||
{ key: "system/membership", icon: "gem", label: "会员状态", hint: "有效期与智能分析额度" }
|
{ key: "system/membership", icon: "gem", label: "会员状态", hint: "有效期与智能分析额度" }
|
||||||
];
|
];
|
||||||
const adminRows = [
|
const adminRows = [
|
||||||
{ key: "system/admin", icon: "sliders-horizontal", label: "系统设置", hint: "行情数据 · 模型池" },
|
{ key: "system/admin", icon: "sliders-horizontal", label: "行情管理", hint: "数据源状态 · 后台刷新 · 历史回补" }
|
||||||
{ key: "system/members", icon: "users", label: "会员管理", hint: "开通 · 续期 · 额度" }
|
|
||||||
];
|
];
|
||||||
const html =
|
const html =
|
||||||
'<div class="m-sys-home" data-system-page="home">' +
|
'<div class="m-sys-home" data-system-page="home">' +
|
||||||
@@ -5104,7 +5093,7 @@
|
|||||||
'<h3 class="m-sys-group-title">偏好</h3>' +
|
'<h3 class="m-sys-group-title">偏好</h3>' +
|
||||||
'<div class="m-card m-sys-list">' + systemThemeRowHtml() + "</div>" +
|
'<div class="m-card m-sys-list">' + systemThemeRowHtml() + "</div>" +
|
||||||
(global.MobileSession.isAdmin()
|
(global.MobileSession.isAdmin()
|
||||||
? '<h3 class="m-sys-group-title">管理员专区</h3><div class="m-card m-sys-list">' + adminRows.map(systemRowHtml).join("") + "</div>"
|
? '<h3 class="m-sys-group-title">管理员专区</h3><div class="m-card m-sys-list">' + adminRows.map(systemRowHtml).join("") + dataHubRowHtml() + "</div>"
|
||||||
: "") +
|
: "") +
|
||||||
'<h3 class="m-sys-group-title">其他</h3>' +
|
'<h3 class="m-sys-group-title">其他</h3>' +
|
||||||
'<div class="m-card m-sys-list">' +
|
'<div class="m-card m-sys-list">' +
|
||||||
@@ -5117,7 +5106,7 @@
|
|||||||
'<span class="m-sys-row-body"><strong>退出登录</strong><small>退出后需要重新登录</small></span>' +
|
'<span class="m-sys-row-body"><strong>退出登录</strong><small>退出后需要重新登录</small></span>' +
|
||||||
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>" +
|
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>" +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
'<p class="m-sys-foot">' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "系统设置与会员管理仅管理员可见") + "</p>" +
|
'<p class="m-sys-foot">' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "行情管理与数据中枢仅管理员可见") + "</p>" +
|
||||||
"</div>";
|
"</div>";
|
||||||
document.getElementById("m-view").innerHTML = html;
|
document.getElementById("m-view").innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -5207,14 +5196,6 @@
|
|||||||
systemFill(html);
|
systemFill(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminTabHtml() {
|
|
||||||
const tab = state.system.adminTab === "models" ? "models" : "market";
|
|
||||||
return '<div class="m-source-tabs" role="tablist" aria-label="系统设置分类">' +
|
|
||||||
'<button class="m-source-tab' + (tab === "market" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="market">行情管理</button>' +
|
|
||||||
'<button class="m-source-tab' + (tab === "models" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="models">模型池</button>' +
|
|
||||||
"</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusDot(ok) {
|
function statusDot(ok) {
|
||||||
return '<span class="m-sys-dot' + (ok ? " m-sys-dot--ok" : "") + '"></span>';
|
return '<span class="m-sys-dot' + (ok ? " m-sys-dot--ok" : "") + '"></span>';
|
||||||
}
|
}
|
||||||
@@ -5228,30 +5209,24 @@
|
|||||||
return " 未配置";
|
return " 未配置";
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSystemAdmin(key) {
|
function renderSystemAdmin() {
|
||||||
if (key === "system/members") {
|
|
||||||
renderSystemMembers();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = state.system.admin || {};
|
const payload = state.system.admin || {};
|
||||||
const data = payload.data || {};
|
const data = payload.data || {};
|
||||||
const ifind = data.ifind || {};
|
const ifind = data.ifind || {};
|
||||||
const llm = payload.llm || {};
|
|
||||||
const tab = state.system.adminTab === "models" ? "models" : "market";
|
|
||||||
const marketHtml =
|
const marketHtml =
|
||||||
'<div class="m-sys-body" data-system-admin-panel="market">' +
|
'<div class="m-sys-body" data-system-admin-panel="market">' +
|
||||||
'<div class="m-card m-sys-section"><strong>数据源状态</strong>' +
|
'<div class="m-card m-sys-section"><strong>数据源状态</strong>' +
|
||||||
'<div class="m-sys-status-list">' +
|
'<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>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><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(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 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></div>" +
|
||||||
'<div class="m-card m-sys-section"><strong>数据源密钥</strong>' +
|
'<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("行情来源凭据", '<input id="m-sys-token" type="text" value="请在数据中枢后台统一管理" disabled>', false) +
|
||||||
formFieldHtml("iFinD Refresh Token", '<input id="m-sys-ifind" type="password" autocomplete="off" maxlength="2048" placeholder="留空则保留现有 Token">', 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>' +
|
'<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-card m-sys-section"><strong>后台刷新</strong>' +
|
||||||
'<div class="m-sys-switch-row"><div><strong>交易时段自动刷新</strong><p class="m-sys-hint">开启后后台定时更新快照</p></div>' +
|
'<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>' +
|
'<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>' +
|
||||||
@@ -5263,178 +5238,28 @@
|
|||||||
formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) +
|
formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) +
|
||||||
'<button class="m-btn-outline" type="button" data-system-backfill>开始回补</button>' +
|
'<button class="m-btn-outline" type="button" data-system-backfill>开始回补</button>' +
|
||||||
'<p class="m-sys-hint">回补用于补齐缺失的历史行情,开始前会再次确认;回补期间页面可正常使用。</p></div>' +
|
'<p class="m-sys-hint">回补用于补齐缺失的历史行情,开始前会再次确认;回补期间页面可正常使用。</p></div>' +
|
||||||
|
'<div class="m-card m-sys-section"><strong>模型池 · 会员 · 邀请码</strong>' +
|
||||||
|
'<p class="m-sys-hint">这些配置已统一在数据中枢管理。</p>' +
|
||||||
|
'<button class="m-btn-outline" type="button" data-system-datahub>打开数据中枢</button></div>' +
|
||||||
"</div>";
|
"</div>";
|
||||||
const models = state.system.models || [];
|
systemFill(marketHtml);
|
||||||
const modelsHtml =
|
|
||||||
'<div class="m-sys-body" data-system-admin-panel="models">' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>模型分工</strong>' +
|
|
||||||
formFieldHtml("主模型", '<select id="m-sys-primary-model"></select>', false) +
|
|
||||||
formFieldHtml("辅助模型", '<select id="m-sys-fallback-model"></select>', false) +
|
|
||||||
'<p class="m-sys-hint">主模型不可用时自动改用辅助模型</p>' +
|
|
||||||
'<button class="m-btn-primary" type="button" data-system-save-models>保存分工</button></div>' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>模型池 · ' + models.length + " 个</strong>" +
|
|
||||||
'<div id="m-sys-model-list">' + renderModelPoolHtml(models) + "</div>" +
|
|
||||||
'<p class="m-sys-hint">点任意模型卡片进入编辑:改名称、地址、密钥、测试连接或删除</p>' +
|
|
||||||
'<button class="m-btn-primary" type="button" data-system-add-model>+ 添加模型</button></div>' +
|
|
||||||
"</div>";
|
|
||||||
const page = document.querySelector(".m-page");
|
|
||||||
if (!page) return;
|
|
||||||
page.innerHTML = adminTabHtml() + '<div class="m-scroll" id="m-scroll">' + (tab === "models" ? modelsHtml : marketHtml) + "</div>";
|
|
||||||
if (tab === "models") updateSystemModelRoleOptions(llm.primary_model_id || "", llm.fallback_model_id || "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function hostOfUrl(url) {
|
function dataHubConsoleUrl() {
|
||||||
try {
|
if (global.XIAOBAI_DATAHUB_URL) return String(global.XIAOBAI_DATAHUB_URL);
|
||||||
return new URL(url).host;
|
return global.location.protocol + "//" + global.location.hostname + ":8766/admin/";
|
||||||
} catch (error) {
|
|
||||||
return String(url || "").replace(/^https?:\/\//, "").split("/")[0] || "--";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderModelPoolHtml(models) {
|
function openDataHubConsole() {
|
||||||
if (!models.length) {
|
if (!global.MobileSession.isAdmin()) return;
|
||||||
return '<div class="m-state"><p>模型池为空,请先添加模型</p></div>';
|
global.open(dataHubConsoleUrl(), "_blank", "noopener");
|
||||||
}
|
}
|
||||||
const llm = (state.system.admin && state.system.admin.llm) || {};
|
|
||||||
return models.map(function (item) {
|
function dataHubRowHtml() {
|
||||||
const badges = [];
|
return '<button class="m-sys-row" type="button" data-system-datahub>' +
|
||||||
if (item.id === llm.primary_model_id) badges.push('<span class="m-sys-badge m-sys-badge--admin">主模型</span>');
|
'<span class="m-sys-row-icon">' + icon("database", 18) + "</span>" +
|
||||||
if (item.id === llm.fallback_model_id) badges.push('<span class="m-sys-badge">辅助</span>');
|
'<span class="m-sys-row-body"><strong>数据中枢</strong><small>数据源 · 模型池 · 会员与邀请码</small></span>' +
|
||||||
badges.push('<span class="m-sys-badge' + (item.configured ? " m-sys-badge--ok" : "") + '">' + (item.configured ? "已配置" : "待配置") + "</span>");
|
|
||||||
return '<button class="m-sys-model-card" type="button" data-model-id="' + escapeHtml(item.id) + '" data-system-edit-model>' +
|
|
||||||
"<div><strong>" + escapeHtml(item.name || "未命名模型") + "</strong>" +
|
|
||||||
'<div class="m-sys-badges">' + badges.join("") + "</div>" +
|
|
||||||
'<p class="m-sys-hint">' + escapeHtml(hostOfUrl(item.base_url) + " · " + (item.model || "未填写标识")) + "</p></div>" +
|
|
||||||
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>";
|
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>";
|
||||||
}).join("");
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectSystemModelPool() {
|
|
||||||
return (state.system.models || []).map(function (item) {
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
name: item.name || "",
|
|
||||||
base_url: item.base_url || "",
|
|
||||||
model: item.model || "",
|
|
||||||
api_key: item.api_key || "",
|
|
||||||
configured: Boolean(item.configured)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSystemModelRoleOptions(primaryId, fallbackId) {
|
|
||||||
const models = collectSystemModelPool();
|
|
||||||
const options = models.map(function (item) {
|
|
||||||
return '<option value="' + escapeHtml(item.id) + '">' + escapeHtml(item.name || item.model || "未命名模型") + "</option>";
|
|
||||||
}).join("");
|
|
||||||
const primary = document.getElementById("m-sys-primary-model");
|
|
||||||
const fallback = document.getElementById("m-sys-fallback-model");
|
|
||||||
if (!primary || !fallback) return;
|
|
||||||
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
|
|
||||||
fallback.innerHTML = '<option value="">不启用辅助模型</option>' + options;
|
|
||||||
const keepPrimary = models.some(function (item) { return item.id === primaryId; }) ? primaryId : (models[0] && models[0].id) || "";
|
|
||||||
primary.value = keepPrimary;
|
|
||||||
fallback.value = models.some(function (item) { return item.id === fallbackId; }) && fallbackId !== keepPrimary ? fallbackId : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function openModelEditSheet(modelId) {
|
|
||||||
const models = state.system.models || [];
|
|
||||||
const item = models.find(function (row) { return row.id === modelId; }) || {};
|
|
||||||
state.system.editingModelId = modelId;
|
|
||||||
openSheet(
|
|
||||||
'<div class="m-sheet-head"><h2>编辑模型</h2>' +
|
|
||||||
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
|
|
||||||
'<div class="m-sheet-body" data-model-id="' + escapeHtml(modelId) + '">' +
|
|
||||||
formFieldHtml("显示名称", '<input data-model-field="name" maxlength="50" value="' + escapeHtml(item.name || "") + '">', true) +
|
|
||||||
formFieldHtml("API Base URL", '<input data-model-field="base_url" type="url" value="' + escapeHtml(item.base_url || "https://api.openai.com/v1") + '">', true) +
|
|
||||||
formFieldHtml("模型标识", '<input data-model-field="model" maxlength="100" value="' + escapeHtml(item.model || "") + '">', true) +
|
|
||||||
formFieldHtml("API Key", '<input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="' + (item.configured ? "留空则保留已保存的 Key" : "输入 API Key") + '">', !item.configured) +
|
|
||||||
'<div class="m-sys-test-row"><button class="m-btn-outline" type="button" data-system-test-model>测试连接</button>' +
|
|
||||||
'<span data-model-test-status>未测试</span></div>' +
|
|
||||||
'<div class="m-sys-sheet-actions">' +
|
|
||||||
'<button class="m-btn-outline-danger" type="button" data-system-delete-model>删除模型</button>' +
|
|
||||||
'<button class="m-btn-primary" type="button" data-system-save-model>保存</button>' +
|
|
||||||
"</div></div>"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readModelSheetFields(root) {
|
|
||||||
function fieldValue(name) {
|
|
||||||
const input = root.querySelector("[data-model-field='" + name + "']");
|
|
||||||
return String(input && input.value != null ? input.value : "").trim();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
name: fieldValue("name"),
|
|
||||||
base_url: fieldValue("base_url"),
|
|
||||||
model: fieldValue("model"),
|
|
||||||
api_key: fieldValue("api_key")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSystemMembers() {
|
|
||||||
const payload = state.system.admin || {};
|
|
||||||
const membership = payload.membership || {};
|
|
||||||
const users = payload.users || [];
|
|
||||||
const userHtml = users.map(function (user) {
|
|
||||||
const admin = user.role === "admin";
|
|
||||||
const member = Boolean(user.membership_subscribed);
|
|
||||||
const badges = [];
|
|
||||||
if (admin) badges.push('<span class="m-sys-badge m-sys-badge--admin">管理员</span>');
|
|
||||||
if (member) badges.push('<span class="m-sys-badge m-sys-badge--ok">会员</span>');
|
|
||||||
else badges.push('<span class="m-sys-badge">普通用户</span>');
|
|
||||||
const expiry = member
|
|
||||||
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
|
|
||||||
: user.membership_status === "suspended"
|
|
||||||
? "会员已停用"
|
|
||||||
: "尚未开通";
|
|
||||||
return '<div class="m-sys-user-row" data-admin-user="' + number(user.id) + '">' +
|
|
||||||
'<div class="m-sys-row-body"><strong>' + escapeHtml(user.username) + "</strong>" +
|
|
||||||
'<div class="m-sys-badges">' + badges.join("") + "</div>" +
|
|
||||||
'<p class="m-sys-hint">' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次</p></div>" +
|
|
||||||
'<button class="m-btn-outline" type="button" data-system-open-member>管理</button></div>';
|
|
||||||
}).join("") || '<div class="m-state"><p>暂无注册用户</p></div>';
|
|
||||||
const html =
|
|
||||||
'<div class="m-sys-body" data-system-page="members">' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>全局额度</strong>' +
|
|
||||||
formFieldHtml("会员每日智能分析上限", '<input id="m-sys-member-limit" type="number" min="1" max="1000" value="' + (number(membership.member_daily_limit) || 50) + '">', false) +
|
|
||||||
'<p class="m-sys-hint">对所有会员生效,每日 0 点自动重置。</p>' +
|
|
||||||
'<button class="m-btn-primary" type="button" data-system-save-limit>保存额度</button></div>' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>会员账号 · ' + users.length + " 个</strong>" +
|
|
||||||
userHtml +
|
|
||||||
'<p class="m-sys-hint">点「管理」为对应账号开通、续期或停用会员。</p></div>' +
|
|
||||||
"</div>";
|
|
||||||
systemFill(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMemberManageSheet(userId) {
|
|
||||||
const users = (state.system.admin && state.system.admin.users) || [];
|
|
||||||
const user = users.find(function (item) { return String(item.id) === String(userId); });
|
|
||||||
if (!user) return;
|
|
||||||
state.system.editingUserId = String(userId);
|
|
||||||
const member = Boolean(user.membership_subscribed);
|
|
||||||
const expiry = member
|
|
||||||
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
|
|
||||||
: user.membership_status === "suspended" ? "会员已停用" : "尚未开通";
|
|
||||||
openSheet(
|
|
||||||
'<div class="m-sheet-head"><h2>管理会员 · ' + escapeHtml(user.username) + "</h2>" +
|
|
||||||
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
|
|
||||||
'<div class="m-sheet-body" data-admin-user="' + number(user.id) + '">' +
|
|
||||||
'<p class="m-sys-lead">当前状态:' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次</p>" +
|
|
||||||
formFieldHtml("会员状态", '<select data-member-status>' +
|
|
||||||
'<option value="inactive"' + (user.membership_status === "inactive" ? " selected" : "") + ">未开通</option>" +
|
|
||||||
'<option value="active"' + (user.membership_status === "active" ? " selected" : "") + ">有效</option>" +
|
|
||||||
'<option value="suspended"' + (user.membership_status === "suspended" ? " selected" : "") + ">停用</option>" +
|
|
||||||
"</select>", false) +
|
|
||||||
formFieldHtml("开通 / 续期时长", '<select data-member-duration><option value="">选择时长</option>' +
|
|
||||||
'<option value="1_month">1个月</option><option value="3_months">3个月</option>' +
|
|
||||||
'<option value="12_months">12个月</option><option value="3_years">3年</option>' +
|
|
||||||
'<option value="permanent">永久</option></select>', false) +
|
|
||||||
'<p class="m-sys-hint">从当前时间开始顺延;已有会员则叠加续期。</p>' +
|
|
||||||
'<div class="m-sys-sheet-actions">' +
|
|
||||||
'<button class="m-btn-outline" type="button" data-sheet-close>取消</button>' +
|
|
||||||
'<button class="m-btn-primary" type="button" data-system-save-member>应用</button>' +
|
|
||||||
"</div></div>"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFieldError(inputId, message) {
|
function setFieldError(inputId, message) {
|
||||||
@@ -5550,14 +5375,10 @@
|
|||||||
function saveSystemMarket() {
|
function saveSystemMarket() {
|
||||||
const button = document.querySelector("[data-system-save-market]");
|
const button = document.querySelector("[data-system-save-market]");
|
||||||
if (button) button.disabled = true;
|
if (button) button.disabled = true;
|
||||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
loadSystem().then(function () {
|
||||||
tushare_token: ((document.getElementById("m-sys-token") || {}).value || "").trim(),
|
showToast("数据中枢状态已刷新");
|
||||||
ifind_refresh_token: ((document.getElementById("m-sys-ifind") || {}).value || "").trim(),
|
|
||||||
}).then(function () {
|
|
||||||
showToast("行情密钥已保存");
|
|
||||||
loadSystem();
|
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
showToast(error && error.message ? error.message : "系统配置保存失败");
|
showToast(error && error.message ? error.message : "数据中枢状态读取失败");
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
if (button) button.disabled = false;
|
if (button) button.disabled = false;
|
||||||
});
|
});
|
||||||
@@ -5575,97 +5396,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSystemModels() {
|
|
||||||
const button = document.querySelector("[data-system-save-models]");
|
|
||||||
if (button) button.disabled = true;
|
|
||||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
|
||||||
primary_model_id: (document.getElementById("m-sys-primary-model") || {}).value || "",
|
|
||||||
fallback_model_id: (document.getElementById("m-sys-fallback-model") || {}).value || "",
|
|
||||||
}).then(function () {
|
|
||||||
showToast("模型分工已保存");
|
|
||||||
loadSystem();
|
|
||||||
}).catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "模型分工保存失败");
|
|
||||||
}).then(function () {
|
|
||||||
if (button) button.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistSystemModels(models, message) {
|
|
||||||
return global.MobileAPI.request("/api/admin/settings", "POST", { models: models }).then(function () {
|
|
||||||
showToast(message || "模型池已保存");
|
|
||||||
closeSheet();
|
|
||||||
loadSystem();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveEditedSystemModel() {
|
|
||||||
const sheet = document.querySelector(".m-sheet-body[data-model-id]");
|
|
||||||
if (!sheet) return;
|
|
||||||
const id = sheet.dataset.modelId;
|
|
||||||
const fields = readModelSheetFields(sheet);
|
|
||||||
const models = collectSystemModelPool().map(function (item) {
|
|
||||||
if (item.id !== id) return item;
|
|
||||||
return Object.assign({}, item, fields);
|
|
||||||
});
|
|
||||||
persistSystemModels(models, "模型已保存").catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "模型保存失败");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function addSystemModel() {
|
|
||||||
const models = collectSystemModelPool();
|
|
||||||
const id = "model-" + Date.now() + "-" + Math.floor(Math.random() * 10000);
|
|
||||||
const created = { id: id, name: "模型 " + (models.length + 1), base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false };
|
|
||||||
state.system.models = models.concat([created]);
|
|
||||||
const list = document.getElementById("m-sys-model-list");
|
|
||||||
if (list) list.innerHTML = renderModelPoolHtml(state.system.models);
|
|
||||||
updateSystemModelRoleOptions(id, (document.getElementById("m-sys-fallback-model") || {}).value || "");
|
|
||||||
openModelEditSheet(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteSystemModel(row) {
|
|
||||||
if (!row) return;
|
|
||||||
const id = row.dataset.modelId;
|
|
||||||
const primary = (document.getElementById("m-sys-primary-model") || {}).value;
|
|
||||||
const fallback = (document.getElementById("m-sys-fallback-model") || {}).value;
|
|
||||||
const llm = (state.system.admin && state.system.admin.llm) || {};
|
|
||||||
if (id === primary || id === fallback || id === llm.primary_model_id || id === llm.fallback_model_id) {
|
|
||||||
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openConfirmSheet("删除模型?", "删除后该模型将从模型池移除,此操作不可恢复。", {
|
|
||||||
danger: true,
|
|
||||||
centered: true,
|
|
||||||
confirmLabel: "删除",
|
|
||||||
onConfirm: function () {
|
|
||||||
const models = collectSystemModelPool().filter(function (item) { return item.id !== id; });
|
|
||||||
persistSystemModels(models, "模型已删除").catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "删除失败");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function testSystemModel(row) {
|
|
||||||
if (!row) return;
|
|
||||||
const status = document.querySelector("[data-model-test-status]");
|
|
||||||
const button = document.querySelector("[data-system-test-model]");
|
|
||||||
const sheet = document.querySelector(".m-sheet-body[data-model-id]");
|
|
||||||
const id = (sheet && sheet.dataset.modelId) || row.dataset.modelId;
|
|
||||||
const fields = sheet ? readModelSheetFields(sheet) : {};
|
|
||||||
const profile = Object.assign({}, collectSystemModelPool().find(function (item) { return item.id === id; }) || {}, fields, { id: id });
|
|
||||||
if (button) button.disabled = true;
|
|
||||||
if (status) status.textContent = "连接中";
|
|
||||||
global.MobileAPI.request("/api/admin/settings/test", "POST", { model_id: id, profile: profile }).then(function (payload) {
|
|
||||||
if (status) status.textContent = "上次测试:成功 · " + number(payload.result && payload.result.latency_ms) + "ms";
|
|
||||||
}).catch(function (error) {
|
|
||||||
if (status) status.textContent = error && error.message ? error.message : "测试失败";
|
|
||||||
}).then(function () {
|
|
||||||
if (button) button.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startSystemRefresh() {
|
function startSystemRefresh() {
|
||||||
const button = document.querySelector("[data-system-refresh]");
|
const button = document.querySelector("[data-system-refresh]");
|
||||||
if (button) button.disabled = true;
|
if (button) button.disabled = true;
|
||||||
@@ -5703,56 +5433,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSystemMemberLimit() {
|
|
||||||
const button = document.querySelector("[data-system-save-limit]");
|
|
||||||
if (button) button.disabled = true;
|
|
||||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
|
||||||
member_daily_limit: number((document.getElementById("m-sys-member-limit") || {}).value),
|
|
||||||
}).then(function () {
|
|
||||||
showToast("会员调用额度已保存");
|
|
||||||
loadSystem();
|
|
||||||
}).catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "会员调用额度保存失败");
|
|
||||||
}).then(function () {
|
|
||||||
if (button) button.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSystemMember(card) {
|
|
||||||
if (!card) return;
|
|
||||||
const status = (card.querySelector("[data-member-status]") || {}).value;
|
|
||||||
const apply = function () {
|
|
||||||
const button = card.querySelector("[data-system-save-member]");
|
|
||||||
if (button) button.disabled = true;
|
|
||||||
global.MobileAPI.request("/api/admin/membership", "POST", {
|
|
||||||
user_id: card.dataset.adminUser,
|
|
||||||
status: status,
|
|
||||||
duration: (card.querySelector("[data-member-duration]") || {}).value,
|
|
||||||
}).then(function (payload) {
|
|
||||||
if (state.system.admin) state.system.admin.users = payload.users || [];
|
|
||||||
closeSheet();
|
|
||||||
showToast("会员状态已更新");
|
|
||||||
renderSystemMembers();
|
|
||||||
}).catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "会员状态保存失败");
|
|
||||||
}).then(function () {
|
|
||||||
if (button) button.disabled = false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if (status === "suspended") {
|
|
||||||
openConfirmSheet("停用该会员?", "停用后该账号将无法使用会员智能功能,可稍后重新开通。", {
|
|
||||||
danger: true,
|
|
||||||
centered: true,
|
|
||||||
confirmLabel: "停用",
|
|
||||||
onConfirm: apply
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- events */
|
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
window.addEventListener("hashchange", closeSheet);
|
window.addEventListener("hashchange", closeSheet);
|
||||||
|
|
||||||
@@ -5880,12 +5560,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemAdminTab = event.target.closest("[data-system-admin-tab]");
|
|
||||||
if (systemAdminTab) {
|
|
||||||
state.system.adminTab = systemAdminTab.dataset.systemAdminTab === "models" ? "models" : "market";
|
|
||||||
renderSystemAdmin("system/admin");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.target.closest("[data-system-save-birth]")) { saveSystemBirth(); return; }
|
if (event.target.closest("[data-system-save-birth]")) { saveSystemBirth(); return; }
|
||||||
if (event.target.closest("[data-system-delete-birth]")) { deleteSystemBirth(); return; }
|
if (event.target.closest("[data-system-delete-birth]")) { deleteSystemBirth(); return; }
|
||||||
if (event.target.closest("[data-system-save-password]")) { saveSystemPassword(); return; }
|
if (event.target.closest("[data-system-save-password]")) { saveSystemPassword(); return; }
|
||||||
@@ -5893,22 +5567,9 @@
|
|||||||
if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; }
|
if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; }
|
||||||
if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; }
|
if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; }
|
||||||
if (event.target.closest("[data-system-toggle-refresh]")) { toggleSystemRefresh(); return; }
|
if (event.target.closest("[data-system-toggle-refresh]")) { toggleSystemRefresh(); return; }
|
||||||
if (event.target.closest("[data-system-save-models]")) { saveSystemModels(); return; }
|
|
||||||
if (event.target.closest("[data-system-save-model]")) { saveEditedSystemModel(); return; }
|
|
||||||
if (event.target.closest("[data-system-add-model]")) { addSystemModel(); return; }
|
|
||||||
const editModel = event.target.closest("[data-system-edit-model]");
|
|
||||||
if (editModel) { openModelEditSheet(editModel.dataset.modelId); return; }
|
|
||||||
const deleteModel = event.target.closest("[data-system-delete-model]");
|
|
||||||
if (deleteModel) { deleteSystemModel(deleteModel.closest("[data-model-id]")); return; }
|
|
||||||
const testModel = event.target.closest("[data-system-test-model]");
|
|
||||||
if (testModel) { testSystemModel(testModel.closest("[data-model-id]")); return; }
|
|
||||||
if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; }
|
if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; }
|
||||||
if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; }
|
if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; }
|
||||||
if (event.target.closest("[data-system-save-limit]")) { saveSystemMemberLimit(); return; }
|
if (event.target.closest("[data-system-datahub]")) { openDataHubConsole(); return; }
|
||||||
const openMember = event.target.closest("[data-system-open-member]");
|
|
||||||
if (openMember) { openMemberManageSheet(openMember.closest("[data-admin-user]").dataset.adminUser); return; }
|
|
||||||
const saveMember = event.target.closest("[data-system-save-member]");
|
|
||||||
if (saveMember) { saveSystemMember(saveMember.closest("[data-admin-user]")); return; }
|
|
||||||
|
|
||||||
// 我的复盘:新增/编辑/删除/筛选/提交等操作
|
// 我的复盘:新增/编辑/删除/筛选/提交等操作
|
||||||
const reviewAddTrade = event.target.closest("[data-review-add-trade]");
|
const reviewAddTrade = event.target.closest("[data-review-add-trade]");
|
||||||
|
|||||||
@@ -263,6 +263,7 @@
|
|||||||
'<label class="m-form-field"><span>账号名</span><input id="m-auth-username" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>',
|
'<label class="m-form-field"><span>账号名</span><input id="m-auth-username" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>',
|
||||||
'<label class="m-form-field"><span>密码</span><input id="m-auth-password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>',
|
'<label class="m-form-field"><span>密码</span><input id="m-auth-password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>',
|
||||||
'<label class="m-form-field" id="m-auth-confirm-field" hidden><span>确认密码</span><input id="m-auth-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>',
|
'<label class="m-form-field" id="m-auth-confirm-field" hidden><span>确认密码</span><input id="m-auth-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>',
|
||||||
|
'<label class="m-form-field" id="m-auth-invite-field" hidden><span>邀请码</span><input id="m-auth-invite" type="text" maxlength="32" autocomplete="off" spellcheck="false" placeholder="XB-XXXX-XXXX-XXXX"></label>',
|
||||||
'<p class="m-auth-error" id="m-auth-error" hidden></p>',
|
'<p class="m-auth-error" id="m-auth-error" hidden></p>',
|
||||||
'<button class="m-btn-primary" id="m-auth-submit" type="submit"><span class="m-btn-spinner" aria-hidden="true" hidden></span><span class="m-btn-label">登录</span></button>',
|
'<button class="m-btn-primary" id="m-auth-submit" type="submit"><span class="m-btn-spinner" aria-hidden="true" hidden></span><span class="m-btn-label">登录</span></button>',
|
||||||
"</form>",
|
"</form>",
|
||||||
@@ -280,6 +281,8 @@
|
|||||||
});
|
});
|
||||||
document.getElementById("m-auth-confirm-field").hidden = authMode !== "register";
|
document.getElementById("m-auth-confirm-field").hidden = authMode !== "register";
|
||||||
document.getElementById("m-auth-confirm").required = authMode === "register";
|
document.getElementById("m-auth-confirm").required = authMode === "register";
|
||||||
|
document.getElementById("m-auth-invite-field").hidden = authMode !== "register";
|
||||||
|
document.getElementById("m-auth-invite").required = authMode === "register";
|
||||||
document.getElementById("m-auth-password").autocomplete = authMode === "register" ? "new-password" : "current-password";
|
document.getElementById("m-auth-password").autocomplete = authMode === "register" ? "new-password" : "current-password";
|
||||||
const submitLabel = document.querySelector("#m-auth-submit .m-btn-label");
|
const submitLabel = document.querySelector("#m-auth-submit .m-btn-label");
|
||||||
if (submitLabel) submitLabel.textContent = authMode === "register" ? "注册并进入" : "登录";
|
if (submitLabel) submitLabel.textContent = authMode === "register" ? "注册并进入" : "登录";
|
||||||
@@ -346,7 +349,7 @@
|
|||||||
label.textContent = authMode === "register" ? "注册中…" : "登录中…";
|
label.textContent = authMode === "register" ? "注册中…" : "登录中…";
|
||||||
try {
|
try {
|
||||||
if (authMode === "register") {
|
if (authMode === "register") {
|
||||||
await global.MobileSession.register(username, password);
|
await global.MobileSession.register(username, password, document.getElementById("m-auth-invite").value.trim());
|
||||||
} else {
|
} else {
|
||||||
await global.MobileSession.login(username, password);
|
await global.MobileSession.login(username, password);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,10 +24,11 @@
|
|||||||
return applySession(payload);
|
return applySession(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(username, password) {
|
async function register(username, password, inviteCode) {
|
||||||
const payload = await global.MobileAPI.request("/api/auth/register", "POST", {
|
const payload = await global.MobileAPI.request("/api/auth/register", "POST", {
|
||||||
username: username,
|
username: username,
|
||||||
password: password
|
password: password,
|
||||||
|
invite_code: inviteCode || ""
|
||||||
});
|
});
|
||||||
return applySession(payload);
|
return applySession(payload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,10 +68,10 @@
|
|||||||
"/pages/sentiment/page.js?v=20260729-1",
|
"/pages/sentiment/page.js?v=20260729-1",
|
||||||
"/pages/pools/page.js?v=20260820-1",
|
"/pages/pools/page.js?v=20260820-1",
|
||||||
"/pages/market/breadth.js?v=20260803-1",
|
"/pages/market/breadth.js?v=20260803-1",
|
||||||
"/pages/market/charts.js?v=20260803-1",
|
"/pages/market/charts.js?v=20260908-1",
|
||||||
"/pages/market/entity-detail.js?v=20260803-1",
|
"/pages/market/entity-detail.js?v=20260908-1",
|
||||||
"/pages/market/stock-detail.js?v=20260803-1",
|
"/pages/market/stock-detail.js?v=20260908-1",
|
||||||
"/pages/market/preview.js?v=20260806-1",
|
"/pages/market/preview.js?v=20260908-1",
|
||||||
"/pages/market/search.js?v=20260803-1",
|
"/pages/market/search.js?v=20260803-1",
|
||||||
"/pages/market/bindings.js?v=20260803-1",
|
"/pages/market/bindings.js?v=20260803-1",
|
||||||
"/pages/ladder/page.js?v=20260820-1",
|
"/pages/ladder/page.js?v=20260820-1",
|
||||||
|
|||||||
@@ -113,9 +113,12 @@ async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
|
|||||||
document.querySelector("#resetHeavenCalibrationButton"),
|
document.querySelector("#resetHeavenCalibrationButton"),
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
cancelHeavenPerformance();
|
cancelHeavenPerformance();
|
||||||
heavenView?.classList.add("heaven-data-loading");
|
const blocking = !state.heavenSetup;
|
||||||
|
if (blocking) heavenView?.classList.add("heaven-data-loading");
|
||||||
if (loadButton) loadButton.disabled = true;
|
if (loadButton) loadButton.disabled = true;
|
||||||
calibrationButtons.forEach((button) => { button.disabled = true; });
|
calibrationButtons.forEach((button) => { button.disabled = true; });
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), 25_000);
|
||||||
try {
|
try {
|
||||||
if (state.heavenSetup?.requestedKey && state.heavenSetup.requestedKey !== requestedKey) {
|
if (state.heavenSetup?.requestedKey && state.heavenSetup.requestedKey !== requestedKey) {
|
||||||
state.personalField = null;
|
state.personalField = null;
|
||||||
@@ -126,7 +129,7 @@ async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
|
|||||||
if (sector) query.set("sector", sector);
|
if (sector) query.set("sector", sector);
|
||||||
if (stockCode) query.set("stock_code", stockCode);
|
if (stockCode) query.set("stock_code", stockCode);
|
||||||
if (manualData) query.set("manual_data", JSON.stringify(manualData));
|
if (manualData) query.set("manual_data", JSON.stringify(manualData));
|
||||||
const payload = await apiRequest(`/api/heaven/setup?${query}`);
|
const payload = await apiRequest(`/api/heaven/setup?${query}`, "GET", null, { signal: controller.signal });
|
||||||
if (
|
if (
|
||||||
requestSequence !== state.heavenRequestSequence
|
requestSequence !== state.heavenRequestSequence
|
||||||
|| calendarDate !== document.querySelector("#qiObservationDate")?.value
|
|| calendarDate !== document.querySelector("#qiObservationDate")?.value
|
||||||
@@ -152,9 +155,15 @@ async function loadHeavenSetup(force = false, sector = "", stockCode = "") {
|
|||||||
if (payload.chart.selection_notice) showHeavenNotice(payload.chart.selection_notice);
|
if (payload.chart.selection_notice) showHeavenNotice(payload.chart.selection_notice);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (requestSequence !== state.heavenRequestSequence) return;
|
if (requestSequence !== state.heavenRequestSequence) return;
|
||||||
showHeavenNotice(error.message || "问天数据加载失败");
|
const aborted = error?.payload?.aborted || /abort|超时|cancel/i.test(String(error?.message || ""));
|
||||||
showToast(error.message || "问天数据加载失败");
|
const message = aborted
|
||||||
|
? "问天数据仍在准备,页面可继续输入和操作"
|
||||||
|
: (error.message || "问天数据加载失败");
|
||||||
|
showHeavenNotice(message);
|
||||||
|
if (!aborted) showToast(message);
|
||||||
|
if (!state.heavenSetup) renderHeavenWorkspace();
|
||||||
} finally {
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
if (requestSequence === state.heavenRequestSequence) {
|
if (requestSequence === state.heavenRequestSequence) {
|
||||||
heavenView?.classList.remove("heaven-data-loading");
|
heavenView?.classList.remove("heaven-data-loading");
|
||||||
if (loadButton) loadButton.disabled = false;
|
if (loadButton) loadButton.disabled = false;
|
||||||
|
|||||||
@@ -1,3 +1,16 @@
|
|||||||
|
const DAILY_CHART_BARS = 45;
|
||||||
|
|
||||||
|
function visibleDailyPrices(prices) {
|
||||||
|
return (prices || []).slice(-DAILY_CHART_BARS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dailyChartSourceLabel(prices, notice) {
|
||||||
|
const count = visibleDailyPrices(prices).length;
|
||||||
|
const base = `日 K 行情 · ${count} 个交易日`;
|
||||||
|
const text = String(notice || "").trim();
|
||||||
|
return text ? `${base} · ${text}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
function currentChartPalette() {
|
function currentChartPalette() {
|
||||||
const style = getComputedStyle(document.documentElement);
|
const style = getComputedStyle(document.documentElement);
|
||||||
const color = (token, fallback) => style.getPropertyValue(token).trim() || fallback;
|
const color = (token, fallback) => style.getPropertyValue(token).trim() || fallback;
|
||||||
@@ -56,7 +69,8 @@ function drawCandlestick(context, x, item, priceY, candleWidth, palette = curren
|
|||||||
|
|
||||||
function drawPriceChart(prices) {
|
function drawPriceChart(prices) {
|
||||||
const canvas = elements.priceChart;
|
const canvas = elements.priceChart;
|
||||||
if (!prices?.length) {
|
const visible = visibleDailyPrices(prices);
|
||||||
|
if (!visible.length) {
|
||||||
clearPriceChart("暂无日 K 数据");
|
clearPriceChart("暂无日 K 数据");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -81,15 +95,15 @@ function drawPriceChart(prices) {
|
|||||||
const gap = 12;
|
const gap = 12;
|
||||||
const priceBottom = height - bottom - volumeHeight - gap;
|
const priceBottom = height - bottom - volumeHeight - gap;
|
||||||
const plotWidth = width - left - right;
|
const plotWidth = width - left - right;
|
||||||
const highs = prices.map((item) => number(item.high));
|
const highs = visible.map((item) => number(item.high));
|
||||||
const lows = prices.map((item) => number(item.low));
|
const lows = visible.map((item) => number(item.low));
|
||||||
const maximum = Math.max(...highs);
|
const maximum = Math.max(...highs);
|
||||||
const minimum = Math.min(...lows);
|
const minimum = Math.min(...lows);
|
||||||
const range = Math.max(maximum - minimum, maximum * 0.01, 0.01);
|
const range = Math.max(maximum - minimum, maximum * 0.01, 0.01);
|
||||||
const volumes = prices.map((item) => number(item.volume));
|
const volumes = visible.map((item) => number(item.volume));
|
||||||
const maxVolume = Math.max(...volumes, 1);
|
const maxVolume = Math.max(...volumes, 1);
|
||||||
const priceY = (value) => top + (maximum - value) / range * (priceBottom - top);
|
const priceY = (value) => top + (maximum - value) / range * (priceBottom - top);
|
||||||
const step = plotWidth / prices.length;
|
const step = plotWidth / visible.length;
|
||||||
const candleWidth = clamp(step * 0.62, 2, 8);
|
const candleWidth = clamp(step * 0.62, 2, 8);
|
||||||
|
|
||||||
context.strokeStyle = palette.grid;
|
context.strokeStyle = palette.grid;
|
||||||
@@ -105,7 +119,7 @@ function drawPriceChart(prices) {
|
|||||||
context.fillText((maximum - range * line / 4).toFixed(2), left - 5, y + 4);
|
context.fillText((maximum - range * line / 4).toFixed(2), left - 5, y + 4);
|
||||||
}
|
}
|
||||||
|
|
||||||
prices.forEach((item, index) => {
|
visible.forEach((item, index) => {
|
||||||
const x = left + step * index + step / 2;
|
const x = left + step * index + step / 2;
|
||||||
const color = drawCandlestick(context, x, item, priceY, candleWidth, palette);
|
const color = drawCandlestick(context, x, item, priceY, candleWidth, palette);
|
||||||
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
|
const volumeBarHeight = number(item.volume) / maxVolume * volumeHeight;
|
||||||
@@ -117,10 +131,10 @@ function drawPriceChart(prices) {
|
|||||||
|
|
||||||
context.textAlign = "center";
|
context.textAlign = "center";
|
||||||
context.fillStyle = palette.axis;
|
context.fillStyle = palette.axis;
|
||||||
const labelIndexes = [0, Math.floor((prices.length - 1) / 2), prices.length - 1];
|
const labelIndexes = [0, Math.floor((visible.length - 1) / 2), visible.length - 1];
|
||||||
labelIndexes.forEach((index) => {
|
labelIndexes.forEach((index) => {
|
||||||
const x = left + step * index + step / 2;
|
const x = left + step * index + step / 2;
|
||||||
context.fillText(String(prices[index].trade_date).slice(5), x, height - 5);
|
context.fillText(String(visible[index].trade_date).slice(5), x, height - 5);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +315,7 @@ function drawIntradayPreviewChart(points, dailyPrices, referenceClose = 0) {
|
|||||||
|
|
||||||
function drawDailyPreviewChart(prices) {
|
function drawDailyPreviewChart(prices) {
|
||||||
const { context, width, height, palette } = prepareStockPreviewCanvas();
|
const { context, width, height, palette } = prepareStockPreviewCanvas();
|
||||||
const visible = prices.slice(-45);
|
const visible = visibleDailyPrices(prices);
|
||||||
const visibleStart = prices.length - visible.length;
|
const visibleStart = prices.length - visible.length;
|
||||||
const left = 45;
|
const left = 45;
|
||||||
const right = 10;
|
const right = 10;
|
||||||
|
|||||||
@@ -113,13 +113,13 @@ function renderEntityDetailMetrics(metrics) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawEntityDetailChart(series, canvas = elements.entityDetailChart) {
|
function drawEntityDetailChart(series, canvas = elements.entityDetailChart) {
|
||||||
const candles = (series || []).filter((item) => number(item.close) > 0).map((item) => {
|
const candles = visibleDailyPrices((series || []).filter((item) => number(item.close) > 0).map((item) => {
|
||||||
const close = number(item.close);
|
const close = number(item.close);
|
||||||
const open = number(item.open) || close;
|
const open = number(item.open) || close;
|
||||||
const high = Math.max(number(item.high) || close, open, close);
|
const high = Math.max(number(item.high) || close, open, close);
|
||||||
const low = Math.min(number(item.low) || close, open, close);
|
const low = Math.min(number(item.low) || close, open, close);
|
||||||
return { ...item, open, high, low, close };
|
return { ...item, open, high, low, close };
|
||||||
});
|
}));
|
||||||
if (!candles.length) {
|
if (!candles.length) {
|
||||||
clearEntityDetailChart("暂无日 K 数据", canvas);
|
clearEntityDetailChart("暂无日 K 数据", canvas);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -368,10 +368,7 @@ function selectStockPreviewChart(chart) {
|
|||||||
} else if ((payload.prices || []).length) {
|
} else if ((payload.prices || []).length) {
|
||||||
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
||||||
const notice = String(payload.meta?.notice || "").trim();
|
const notice = String(payload.meta?.notice || "").trim();
|
||||||
setText(
|
setText("stockPreviewSource", dailyChartSourceLabel(payload.prices, notice));
|
||||||
"stockPreviewSource",
|
|
||||||
notice ? `日 K 行情 · ${payload.prices.length} 个交易日 · ${notice}` : `日 K 行情 · ${payload.prices.length} 个交易日`,
|
|
||||||
);
|
|
||||||
drawDailyPreviewChart(payload.prices);
|
drawDailyPreviewChart(payload.prices);
|
||||||
} else {
|
} else {
|
||||||
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
setText("stockPreviewDate", payload.meta?.trade_date || "最新行情");
|
||||||
|
|||||||
@@ -20,17 +20,9 @@ async function openStock(code, fallback = null) {
|
|||||||
setText("detailStreak", row.status === "涨停" ? streakLabel(row.streak) : row.status || "--");
|
setText("detailStreak", row.status === "涨停" ? streakLabel(row.streak) : row.status || "--");
|
||||||
setText("detailReason", row.reason || "--");
|
setText("detailReason", row.reason || "--");
|
||||||
setText("detailSector", row.sector || "其他");
|
setText("detailSector", row.sector || "其他");
|
||||||
setText("detailFirst", row.first_time || "--");
|
setStockBoardFields(row);
|
||||||
setText("detailLast", row.last_time || "--");
|
|
||||||
setText("detailOpen", `${number(row.open_times)} 次`);
|
|
||||||
setText("detailTurnover", `${formatNumber(row.turnover_rate, 2)}%`);
|
|
||||||
setText("detailAmount", `${formatNumber(row.amount_billion, 2)} 亿`);
|
|
||||||
setText("detailSeal", `${formatNumber(row.seal_amount_million, 0)} 万`);
|
|
||||||
setText("chartSource", "正在加载行情");
|
setText("chartSource", "正在加载行情");
|
||||||
setText("flowNet", "--");
|
renderMoneyflow({});
|
||||||
setText("flowLarge", "--");
|
|
||||||
setText("flowMedium", "--");
|
|
||||||
setText("flowSmall", "--");
|
|
||||||
document.querySelector("#reasonInput").value = row.reason || "";
|
document.querySelector("#reasonInput").value = row.reason || "";
|
||||||
document.querySelector("#stockNoteContent").value = "";
|
document.querySelector("#stockNoteContent").value = "";
|
||||||
document.querySelector("#stockNotePlan").value = "";
|
document.querySelector("#stockNotePlan").value = "";
|
||||||
@@ -48,15 +40,13 @@ async function openStock(code, fallback = null) {
|
|||||||
setText("detailName", stock.name || row.name);
|
setText("detailName", stock.name || row.name);
|
||||||
setText("detailPrice", formatNumber(stock.price || row.price, 2));
|
setText("detailPrice", formatNumber(stock.price || row.price, 2));
|
||||||
setText("detailChange", `${signed(stock.change ?? row.change)}%`);
|
setText("detailChange", `${signed(stock.change ?? row.change)}%`);
|
||||||
|
setStockBoardFields({ ...row, ...stock });
|
||||||
renderMoneyflow(payload.moneyflow || {});
|
renderMoneyflow(payload.moneyflow || {});
|
||||||
renderStockNotes(payload.notes || []);
|
renderStockNotes(payload.notes || []);
|
||||||
updateWatchButton();
|
updateWatchButton();
|
||||||
if (state.stockDetailChartMode === "daily") {
|
if (state.stockDetailChartMode === "daily") {
|
||||||
const notice = String(payload.meta?.notice || "").trim();
|
const notice = String(payload.meta?.notice || "").trim();
|
||||||
setText(
|
setText("chartSource", dailyChartSourceLabel(payload.prices, notice));
|
||||||
"chartSource",
|
|
||||||
notice ? `日 K 行情 · ${payload.prices.length} 个交易日 · ${notice}` : `日 K 行情 · ${payload.prices.length} 个交易日`,
|
|
||||||
);
|
|
||||||
requestAnimationFrame(() => drawPriceChart(payload.prices || []));
|
requestAnimationFrame(() => drawPriceChart(payload.prices || []));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -76,9 +66,7 @@ async function selectStockDetailChart(mode) {
|
|||||||
const notice = String(state.stockDetail?.meta?.notice || "").trim();
|
const notice = String(state.stockDetail?.meta?.notice || "").trim();
|
||||||
setText(
|
setText(
|
||||||
"chartSource",
|
"chartSource",
|
||||||
prices.length
|
prices.length ? dailyChartSourceLabel(prices, notice) : "正在加载行情",
|
||||||
? (notice ? `日 K 行情 · ${prices.length} 个交易日 · ${notice}` : `日 K 行情 · ${prices.length} 个交易日`)
|
|
||||||
: "正在加载行情",
|
|
||||||
);
|
);
|
||||||
if (prices.length) requestAnimationFrame(() => drawPriceChart(prices));
|
if (prices.length) requestAnimationFrame(() => drawPriceChart(prices));
|
||||||
else clearPriceChart("正在加载日 K 数据");
|
else clearPriceChart("正在加载日 K 数据");
|
||||||
@@ -121,6 +109,21 @@ function renderStockDetailIntraday(payload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setStockBoardFields(row) {
|
||||||
|
const firstTime = String(row.first_time || "").trim();
|
||||||
|
const lastTime = String(row.last_time || "").trim();
|
||||||
|
setText("detailFirst", firstTime && firstTime !== "--" ? firstTime : "--");
|
||||||
|
setText("detailLast", lastTime && lastTime !== "--" ? lastTime : "--");
|
||||||
|
setText("detailOpen", row.open_times === null || row.open_times === undefined || row.open_times === "" ? "--" : `${number(row.open_times)} 次`);
|
||||||
|
setText("detailTurnover", presentMetric(row.turnover_rate) ? `${formatNumber(row.turnover_rate, 2)}%` : "--");
|
||||||
|
setText("detailAmount", presentMetric(row.amount_billion) ? `${formatNumber(row.amount_billion, 2)} 亿` : "--");
|
||||||
|
setText("detailSeal", presentMetric(row.seal_amount_million) ? `${formatNumber(row.seal_amount_million, 0)} 万` : "--");
|
||||||
|
}
|
||||||
|
|
||||||
|
function presentMetric(value) {
|
||||||
|
return meaningfulNumber(value) && Number(value) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
function openActiveStockInHeaven() {
|
function openActiveStockInHeaven() {
|
||||||
const code = state.activeStock?.code;
|
const code = state.activeStock?.code;
|
||||||
if (!/^\d{6}$/.test(String(code || ""))) return;
|
if (!/^\d{6}$/.test(String(code || ""))) return;
|
||||||
|
|||||||
Vendored
+10
-1
@@ -100,7 +100,12 @@ body[data-active-view="mentorView"] .app-page-context span {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#mentorView .mentor-page-header {
|
#mentorView .mentor-page-header {
|
||||||
display: none;
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 34px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#mentorView .mentor-page-title {
|
#mentorView .mentor-page-title {
|
||||||
@@ -1360,6 +1365,10 @@ body[data-active-view="mentorView"] .app-page-context span {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
#mentorView.workspace-view {
|
||||||
|
padding: calc(var(--page-pad-y) + 40px) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
#mentorView .mentor-layout {
|
#mentorView .mentor-layout {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|||||||
Vendored
+1
-1
@@ -2065,7 +2065,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] :is(.global-search-results, .assistant-messages, .trade-log-form, .alerts-toolbar, .admin-section-picker) {
|
:root[data-theme="dark"] :is(.global-search-results, .assistant-messages, .trade-log-form, .alerts-toolbar) {
|
||||||
border-color: var(--line-soft);
|
border-color: var(--line-soft);
|
||||||
|
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
|
|||||||
@@ -408,8 +408,18 @@ async function saveReasonOverride(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderMoneyflow(flow) {
|
function renderMoneyflow(flow) {
|
||||||
for (const [id, value] of [["flowNet", flow.net_million], ["flowLarge", flow.large_million], ["flowMedium", flow.medium_million], ["flowSmall", flow.small_million]]) {
|
const payload = flow || {};
|
||||||
|
const available = payload.available !== false && [
|
||||||
|
payload.net_million, payload.large_million, payload.medium_million, payload.small_million,
|
||||||
|
].some((value) => value !== null && value !== undefined && value !== "");
|
||||||
|
for (const [id, value] of [["flowNet", payload.net_million], ["flowLarge", payload.large_million], ["flowMedium", payload.medium_million], ["flowSmall", payload.small_million]]) {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
|
if (!element) continue;
|
||||||
|
if (!available || value === null || value === undefined || value === "") {
|
||||||
|
element.textContent = "--";
|
||||||
|
element.className = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
element.textContent = formatMoneyMillion(value);
|
element.textContent = formatMoneyMillion(value);
|
||||||
element.className = changeClass(value);
|
element.className = changeClass(value);
|
||||||
}
|
}
|
||||||
|
|||||||
+137
-79
@@ -28,14 +28,6 @@
|
|||||||
background: var(--selected);
|
background: var(--selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rotation-sector-chip.selected {
|
|
||||||
border-color: var(--action);
|
|
||||||
|
|
||||||
background: var(--action-soft);
|
|
||||||
|
|
||||||
color: var(--action-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rotation-sector-chip > span {
|
.rotation-sector-chip > span {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
||||||
@@ -158,8 +150,6 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
|
|
||||||
background: color-mix(in srgb, var(--accent) calc(4% + var(--rotation-heat) * 46%), transparent);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.redesigned-rotation-view {
|
.redesigned-rotation-view {
|
||||||
@@ -397,8 +387,12 @@
|
|||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
|
row-gap: 4px;
|
||||||
|
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
@@ -434,18 +428,32 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-swatch.strong {
|
#rotationView .rotation-swatch.tier-1 {
|
||||||
background: var(--accent);
|
background: var(--rotation-tier1-bar);
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-swatch.warm {
|
#rotationView .rotation-swatch.tier-2 {
|
||||||
background: var(--accent-soft);
|
background: var(--rotation-tier2-bar);
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-swatch.mild {
|
#rotationView .rotation-swatch.tier-3 {
|
||||||
background: var(--surface-muted);
|
background: var(--rotation-tier3-bar);
|
||||||
|
}
|
||||||
|
|
||||||
border: 1px solid var(--border);
|
#rotationView .rotation-swatch.tier-4 {
|
||||||
|
background: var(--rotation-tier4-bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-swatch.tier-5 {
|
||||||
|
background: var(--rotation-tier5-bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-legend-note {
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
color: var(--r2-faint);
|
||||||
|
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-tracker {
|
#rotationView .rotation-tracker {
|
||||||
@@ -818,10 +826,59 @@
|
|||||||
|
|
||||||
#rotationView .rotation-history.tracking .rotation-sector-chip.selected {
|
#rotationView .rotation-history.tracking .rotation-sector-chip.selected {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
border-color: var(--r2-blue);
|
/* 追踪态:命中被追踪板块名、但不是当次点击的那一格 —— 保持档位底色,2px 同档位描边(含 80% 不透明度),
|
||||||
|
连点标记不做动画,仅描边淡入 200ms */
|
||||||
|
#rotationView .rotation-sector-chip.selected:not(.current-cell) {
|
||||||
|
border-width: 2px;
|
||||||
|
|
||||||
box-shadow: inset 3px 0 0 var(--r2-blue), 0 0 0 1px var(--accent-soft);
|
border-color: color-mix(in srgb, var(--tier-bar) 80%, transparent);
|
||||||
|
|
||||||
|
transition: border-color 200ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中态:仅当次点击的日期+板块这一格 —— 同档位实色渐变 + 2px 亮描边 + 克制光晕,全页最多一张 */
|
||||||
|
#rotationView .rotation-sector-chip.selected.current-cell {
|
||||||
|
border-width: 2px;
|
||||||
|
|
||||||
|
border-color: var(--tier-text);
|
||||||
|
|
||||||
|
background: linear-gradient(135deg, var(--tier-fill-start), var(--tier-fill-end));
|
||||||
|
|
||||||
|
box-shadow: 0 0 16px color-mix(in srgb, var(--tier-bar) var(--rotation-tier-glow-alpha), transparent);
|
||||||
|
|
||||||
|
color: var(--text-inverse);
|
||||||
|
|
||||||
|
transition: background 160ms var(--ease-out), border-color 160ms var(--ease-out), box-shadow 160ms var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-sector-chip.selected.current-cell strong,
|
||||||
|
#rotationView .rotation-sector-chip.selected.current-cell small,
|
||||||
|
#rotationView .rotation-sector-chip.selected.current-cell small b {
|
||||||
|
color: var(--text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-track-dot {
|
||||||
|
width: 6px;
|
||||||
|
|
||||||
|
height: 6px;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 6px;
|
||||||
|
|
||||||
|
right: 6px;
|
||||||
|
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
|
background: var(--tier-bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#rotationView .rotation-sector-chip.selected.current-cell {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rotation-cell-tooltip {
|
.rotation-cell-tooltip {
|
||||||
@@ -1050,40 +1107,79 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-sector-chip.heat-mild {
|
/* HEL-524: 五档强度色 —— 同一强度值在 9 个日期列中颜色完全一致,仅代表强度档位,与涨跌无关。
|
||||||
border-color: var(--border);
|
普通卡只用低透明着色,页面/主容器/日期分栏的层级由 HEL-522 的明度骨架承担,这里不重复叠加。 */
|
||||||
|
#rotationView .rotation-sector-chip.heat-tier-1 {
|
||||||
background: var(--surface-muted);
|
--tier-bar: var(--rotation-tier1-bar);
|
||||||
|
--tier-bg: var(--rotation-tier1-bg);
|
||||||
box-shadow: none;
|
--tier-border: var(--rotation-tier1-border);
|
||||||
|
--tier-text: var(--rotation-tier1-text);
|
||||||
|
--tier-fill-start: var(--rotation-tier1-fill-start);
|
||||||
|
--tier-fill-end: var(--rotation-tier1-fill-end);
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-sector-chip.heat-strong {
|
#rotationView .rotation-sector-chip.heat-tier-2 {
|
||||||
border-color: var(--action-line);
|
--tier-bar: var(--rotation-tier2-bar);
|
||||||
|
--tier-bg: var(--rotation-tier2-bg);
|
||||||
background: var(--accent-soft);
|
--tier-border: var(--rotation-tier2-border);
|
||||||
|
--tier-text: var(--rotation-tier2-text);
|
||||||
box-shadow: none;
|
--tier-fill-start: var(--rotation-tier2-fill-start);
|
||||||
|
--tier-fill-end: var(--rotation-tier2-fill-end);
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-sector-chip.heat-warm {
|
#rotationView .rotation-sector-chip.heat-tier-3 {
|
||||||
border-color: var(--action-line);
|
--tier-bar: var(--rotation-tier3-bar);
|
||||||
|
--tier-bg: var(--rotation-tier3-bg);
|
||||||
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
--tier-border: var(--rotation-tier3-border);
|
||||||
|
--tier-text: var(--rotation-tier3-text);
|
||||||
box-shadow: none;
|
--tier-fill-start: var(--rotation-tier3-fill-start);
|
||||||
|
--tier-fill-end: var(--rotation-tier3-fill-end);
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-sector-chip:hover {
|
#rotationView .rotation-sector-chip.heat-tier-4 {
|
||||||
|
--tier-bar: var(--rotation-tier4-bar);
|
||||||
|
--tier-bg: var(--rotation-tier4-bg);
|
||||||
|
--tier-border: var(--rotation-tier4-border);
|
||||||
|
--tier-text: var(--rotation-tier4-text);
|
||||||
|
--tier-fill-start: var(--rotation-tier4-fill-start);
|
||||||
|
--tier-fill-end: var(--rotation-tier4-fill-end);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-sector-chip.heat-tier-5 {
|
||||||
|
--tier-bar: var(--rotation-tier5-bar);
|
||||||
|
--tier-bg: var(--rotation-tier5-bg);
|
||||||
|
--tier-border: var(--rotation-tier5-border);
|
||||||
|
--tier-text: var(--rotation-tier5-text);
|
||||||
|
--tier-fill-start: var(--rotation-tier5-fill-start);
|
||||||
|
--tier-fill-end: var(--rotation-tier5-fill-end);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-sector-chip[class*="heat-tier-"] {
|
||||||
|
border-color: var(--tier-border);
|
||||||
|
|
||||||
|
background: var(--tier-bg);
|
||||||
|
|
||||||
|
box-shadow: inset 4px 0 0 0 var(--tier-bar);
|
||||||
|
|
||||||
|
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1), background-color 120ms var(--ease-out), border-color 120ms var(--ease-out), box-shadow 120ms var(--ease-out), filter 240ms, opacity 220ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rotationView .rotation-sector-chip[class*="heat-tier-"] .rotation-strength {
|
||||||
|
color: var(--tier-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬停态:卡底与描边 alpha 适度提升,120ms ease-out,可打断,不打乱档位色含义;
|
||||||
|
已经处于选中/追踪态的卡片保持其自身样式,不叠加普通悬停处理 */
|
||||||
|
#rotationView .rotation-sector-chip[class*="heat-tier-"]:hover:not(.selected) {
|
||||||
z-index: 6;
|
z-index: 6;
|
||||||
|
|
||||||
border-color: var(--accent);
|
background: color-mix(in srgb, var(--tier-bar) 8%, var(--tier-bg) 92%);
|
||||||
|
|
||||||
|
border-color: color-mix(in srgb, var(--tier-bar) 26%, var(--tier-border) 74%);
|
||||||
|
|
||||||
filter: saturate(1.06);
|
filter: saturate(1.06);
|
||||||
|
|
||||||
transform: translate3d(2px, -2px, 0px) scale(1.015);
|
transform: translate3d(2px, -2px, 0px) scale(1.015);
|
||||||
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#rotationView .rotation-table {
|
#rotationView .rotation-table {
|
||||||
@@ -1207,30 +1303,6 @@
|
|||||||
box-shadow: var(--control-shadow);
|
box-shadow: var(--control-shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView .rotation-sector-chip.heat-strong {
|
|
||||||
border-color: var(--blue-line);
|
|
||||||
|
|
||||||
background: var(--heat-strong-bg);
|
|
||||||
|
|
||||||
color: var(--heat-strong-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView .rotation-sector-chip.heat-warm {
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
|
|
||||||
background: var(--heat-warm-bg);
|
|
||||||
|
|
||||||
color: var(--heat-warm-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView .rotation-sector-chip.heat-mild {
|
|
||||||
border-color: var(--border);
|
|
||||||
|
|
||||||
background: var(--heat-mild-bg);
|
|
||||||
|
|
||||||
color: var(--heat-mild-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView :is(.rotation-rank, .rotation-table thead th, .trend-flat) {
|
:root[data-theme="dark"] #rotationView :is(.rotation-rank, .rotation-table thead th, .trend-flat) {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
|
|
||||||
@@ -1243,20 +1315,6 @@
|
|||||||
background: var(--action-soft);
|
background: var(--action-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView :is(.rotation-swatch.warm) {
|
|
||||||
border-color: var(--blue-line);
|
|
||||||
|
|
||||||
background: var(--action-soft);
|
|
||||||
|
|
||||||
color: var(--action);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] #rotationView :is(.rotation-swatch.mild) {
|
|
||||||
border-color: var(--border);
|
|
||||||
|
|
||||||
background: var(--surface-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
#rotationView :is(.rotation-table-frame, .rotation-detail-card) {
|
#rotationView :is(.rotation-table-frame, .rotation-detail-card) {
|
||||||
max-height: none;
|
max-height: none;
|
||||||
|
|||||||
@@ -22,9 +22,12 @@
|
|||||||
<span class="rotation-top-tag">每日 Top 12 热点</span>
|
<span class="rotation-top-tag">每日 Top 12 热点</span>
|
||||||
</header>
|
</header>
|
||||||
<div class="rotation-legend" aria-label="板块强度图例">
|
<div class="rotation-legend" aria-label="板块强度图例">
|
||||||
<span><i class="rotation-swatch strong"></i>强度高(90+)</span>
|
<span><i class="rotation-swatch tier-1"></i>低·青(<65)</span>
|
||||||
<span><i class="rotation-swatch warm"></i>强度中(70–89)</span>
|
<span><i class="rotation-swatch tier-2"></i>中低·蓝(65–74)</span>
|
||||||
<span><i class="rotation-swatch mild"></i>强度低(<70)</span>
|
<span><i class="rotation-swatch tier-3"></i>中高·紫(75–84)</span>
|
||||||
|
<span><i class="rotation-swatch tier-4"></i>高·橙(85–94)</span>
|
||||||
|
<span><i class="rotation-swatch tier-5"></i>最高·红(95+)</span>
|
||||||
|
<span class="rotation-legend-note">颜色=强度档位,与涨跌无关</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="rotationTracker" class="rotation-tracker" hidden></div>
|
<div id="rotationTracker" class="rotation-tracker" hidden></div>
|
||||||
<div id="rotationHistory" class="rotation-history"><div class="empty-state">正在读取轮动历史</div></div>
|
<div id="rotationHistory" class="rotation-history"><div class="empty-state">正在读取轮动历史</div></div>
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ window.XiaobaiPageModules.register("rotation", ["rotationView"], {
|
|||||||
enter: ["loadRotation"],
|
enter: ["loadRotation"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// HEL-524: 五档强度色固定映射,所有日期列共用同一套阈值 —— <65 青,65-74 蓝,75-84 紫,85-94 橙,95+ 红。
|
||||||
|
function rotationHeatTier(strengthValue) {
|
||||||
|
const value = clamp(number(strengthValue), 0, 100);
|
||||||
|
if (value >= 95) return 5;
|
||||||
|
if (value >= 85) return 4;
|
||||||
|
if (value >= 75) return 3;
|
||||||
|
if (value >= 65) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRotationHistory(force = false) {
|
async function loadRotationHistory(force = false) {
|
||||||
if (!state.dashboard || state.rotationLoading) return;
|
if (!state.dashboard || state.rotationLoading) return;
|
||||||
const key = `${elements.tradeDate.value}:9`;
|
const key = `${elements.tradeDate.value}:9`;
|
||||||
@@ -87,10 +97,14 @@ function renderRotationHistory() {
|
|||||||
<header><time>${escapeHtml(displayCompactDate(day.trade_date).slice(5))}</time><span>${(day.sectors || []).length} 个热点</span></header>
|
<header><time>${escapeHtml(displayCompactDate(day.trade_date).slice(5))}</time><span>${(day.sectors || []).length} 个热点</span></header>
|
||||||
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => {
|
<div class="rotation-day-sectors">${(day.sectors || []).map((sector) => {
|
||||||
const strength = clamp(number(sector.strength), 0, 100);
|
const strength = clamp(number(sector.strength), 0, 100);
|
||||||
const heatClass = strength >= 90 ? "heat-strong" : strength >= 70 ? "heat-warm" : "heat-mild";
|
const heatClass = `heat-tier-${rotationHeatTier(strength)}`;
|
||||||
|
const isTrackedName = Boolean(selected) && selected === sector.name;
|
||||||
|
const isCurrentCell = isTrackedName && day.trade_date === state.rotationSelectedDate;
|
||||||
|
const stateClass = isCurrentCell ? "selected current-cell" : isTrackedName ? "selected" : "";
|
||||||
return `
|
return `
|
||||||
<button type="button" class="rotation-sector-chip ${heatClass} ${selected === sector.name ? "selected" : ""}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
|
<button type="button" class="rotation-sector-chip ${heatClass} ${stateClass}" data-rotation-sector="${escapeHtml(sector.name)}" data-rotation-date="${escapeHtml(day.trade_date)}">
|
||||||
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · ${formatNumber(sector.strength, 0)}</small>
|
<span class="rotation-rank rank-${Math.min(number(sector.rank), 4)}">${number(sector.rank)}</span><strong>${escapeHtml(sector.name)}</strong><small><b>${number(sector.count)}</b> 家 · <b class="rotation-strength">${formatNumber(sector.strength, 0)}</b></small>
|
||||||
|
${isTrackedName && !isCurrentCell ? '<span class="rotation-track-dot" aria-hidden="true"></span>' : ""}
|
||||||
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
|
<span class="rotation-cell-tooltip">${escapeHtml(displayCompactDate(day.trade_date).slice(5))} · 第 ${number(sector.rank)} 名 · 涨停 ${number(sector.count)} 家 · 强度 ${formatNumber(sector.strength, 0)}</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
}).join("")}</div>
|
}).join("")}</div>
|
||||||
|
|||||||
+17
-11
@@ -2287,8 +2287,6 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:where(#screenerView) .curated-library-pane {
|
:where(#screenerView) .curated-library-pane {
|
||||||
@@ -2546,7 +2544,7 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
|
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
gap: 2px;
|
gap: 8px;
|
||||||
|
|
||||||
margin: 0px;
|
margin: 0px;
|
||||||
|
|
||||||
@@ -2566,11 +2564,11 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
|
|
||||||
padding: 0 14px;
|
padding: 0 14px;
|
||||||
|
|
||||||
border: 0px;
|
border: 1px solid var(--control-border);
|
||||||
|
|
||||||
border-radius: 0;
|
border-radius: var(--radius-md);
|
||||||
|
|
||||||
background: transparent;
|
background: var(--control-surface);
|
||||||
|
|
||||||
color: var(--r2-sub);
|
color: var(--r2-sub);
|
||||||
|
|
||||||
@@ -2586,15 +2584,19 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .screener-mode-tabs button:hover {
|
#screenerView .screener-mode-tabs button:hover {
|
||||||
background: var(--surface-hover);
|
border-color: var(--r2-blue-line);
|
||||||
|
|
||||||
|
background: var(--control-hover);
|
||||||
|
|
||||||
color: var(--r2-ink);
|
color: var(--r2-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .screener-mode-tabs button.active {
|
#screenerView .screener-mode-tabs button.active {
|
||||||
background: transparent;
|
border-color: var(--scr-blue);
|
||||||
|
|
||||||
color: var(--scr-blue);
|
background: var(--scr-blue);
|
||||||
|
|
||||||
|
color: var(--on-action);
|
||||||
|
|
||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
@@ -3135,7 +3137,11 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .curated-screener-panel {
|
#screenerView .curated-screener-panel {
|
||||||
display: block;
|
display: flex;
|
||||||
|
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenerView .curated-workspace {
|
#screenerView .curated-workspace {
|
||||||
@@ -6184,7 +6190,7 @@ body[data-active-view="screenerView"] .workspace-view {
|
|||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] #screenerView :is(.screener-mode-tabs, .screener-stepper, .regime-selector, .regime-option, .screener-results-view, .result-toolbar) {
|
:root[data-theme="dark"] #screenerView :is(.screener-stepper, .regime-selector, .regime-option, .screener-results-view, .result-toolbar) {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
|||||||
+2
-2
@@ -1596,7 +1596,7 @@
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] :is(.sentiment-cycle-toolbar, .tbl-tools) {
|
:root[data-theme="dark"] :is(.tbl-tools) {
|
||||||
border-color: var(--line-soft);
|
border-color: var(--line-soft);
|
||||||
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -1610,7 +1610,7 @@
|
|||||||
color: var(--warning-color);
|
color: var(--warning-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] #sentimentCycleView :is(.sentiment-current-tag, .sentiment-auto-tag, .sentiment-detail-toolbar, .section-toolbar) {
|
:root[data-theme="dark"] #sentimentCycleView :is(.sentiment-current-tag, .sentiment-auto-tag, .sentiment-detail-toolbar) {
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
|
|
||||||
background: var(--surface-muted);
|
background: var(--surface-muted);
|
||||||
|
|||||||
+15
-197
@@ -39,23 +39,26 @@ async function openAdminSettings(refreshOnly = false) {
|
|||||||
const payload = await apiRequest("/api/admin/settings");
|
const payload = await apiRequest("/api/admin/settings");
|
||||||
const data = payload.data || {};
|
const data = payload.data || {};
|
||||||
const ifind = data.ifind || {};
|
const ifind = data.ifind || {};
|
||||||
const llm = payload.llm || {};
|
status.textContent = `数据中枢 ${data.configured ? "已连接" : "未连接"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
|
||||||
const membership = payload.membership || {};
|
|
||||||
status.textContent = `Tushare ${data.configured ? "已配置" : "未配置"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
|
|
||||||
status.classList.toggle("connected", Boolean(data.configured));
|
status.classList.toggle("connected", Boolean(data.configured));
|
||||||
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
|
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
|
||||||
renderDatahubRouteStatus(data.datahub || {});
|
renderDatahubRouteStatus(data.datahub || {});
|
||||||
document.querySelector("#systemTokenInput").value = "";
|
|
||||||
document.querySelector("#systemIfindTokenInput").value = "";
|
|
||||||
document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled);
|
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 || "");
|
|
||||||
renderAdminUsers(payload.users || []);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
status.textContent = error.message || "系统配置读取失败";
|
status.textContent = error.message || "系统配置读取失败";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dataHubConsoleUrl() {
|
||||||
|
if (window.XIAOBAI_DATAHUB_URL) return String(window.XIAOBAI_DATAHUB_URL);
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}:8766/admin/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDataHubConsole() {
|
||||||
|
if (state.user?.role !== "admin") return;
|
||||||
|
window.open(dataHubConsoleUrl(), "_blank", "noopener");
|
||||||
|
}
|
||||||
|
|
||||||
function renderDatahubRouteStatus(hub) {
|
function renderDatahubRouteStatus(hub) {
|
||||||
const box = document.querySelector("#datahubRouteStatus");
|
const box = document.querySelector("#datahubRouteStatus");
|
||||||
if (!box) return;
|
if (!box) return;
|
||||||
@@ -72,130 +75,7 @@ function renderDatahubRouteStatus(hub) {
|
|||||||
if (label) {
|
if (label) {
|
||||||
label.textContent = hub.configured
|
label.textContent = hub.configured
|
||||||
? `数据中枢主线路 ${enabled}/${total},当前无备用`
|
? `数据中枢主线路 ${enabled}/${total},当前无备用`
|
||||||
: "数据中枢尚未配置,网站仍走原接口";
|
: "数据中枢未配置,网站只保留已有真实快照";
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectAdminPanel(panel) {
|
|
||||||
const selected = ["market", "models", "members"].includes(panel) ? panel : "market";
|
|
||||||
document.querySelector("#adminSectionSelect").value = selected;
|
|
||||||
document.querySelectorAll("[data-admin-panel]").forEach((item) => {
|
|
||||||
item.hidden = item.dataset.adminPanel !== selected;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderModelPool(models, primaryId = "", fallbackId = "") {
|
|
||||||
state.adminModels = models.map((item) => ({ ...item, api_key: item.api_key || "" }));
|
|
||||||
const container = document.querySelector("#modelPoolList");
|
|
||||||
container.innerHTML = state.adminModels.map((item, index) => `
|
|
||||||
<article class="model-pool-row" data-model-id="${escapeHtml(item.id)}">
|
|
||||||
<div class="model-pool-heading"><strong>${escapeHtml(item.name || `模型 ${index + 1}`)}</strong><span>${item.configured ? "已保存密钥" : "待配置"}</span></div>
|
|
||||||
<div class="model-pool-fields">
|
|
||||||
<label class="form-field"><span>显示名称 *</span><input data-model-field="name" maxlength="50" value="${escapeHtml(item.name || "")}" required></label>
|
|
||||||
<label class="form-field"><span>API Base URL *</span><input data-model-field="base_url" type="url" value="${escapeHtml(item.base_url || "https://api.openai.com/v1")}" required></label>
|
|
||||||
<label class="form-field"><span>模型标识 *</span><input data-model-field="model" maxlength="100" value="${escapeHtml(item.model || "")}" required></label>
|
|
||||||
<label class="form-field"><span>API Key${item.configured ? "" : " *"}</span><input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="${item.configured ? "留空保留已保存的 Key" : "输入 API Key"}" ${item.configured ? "" : "required"}></label>
|
|
||||||
</div>
|
|
||||||
<div class="model-test-row"><button class="button" type="button" data-test-model>测试连接</button><span class="model-test-status" aria-live="polite">未测试</span><button class="icon-button model-delete-button" type="button" data-delete-model aria-label="删除模型" title="删除模型"><i data-lucide="trash-2"></i></button></div>
|
|
||||||
</article>
|
|
||||||
`).join("") || emptyStateHtml("模型池为空,请先添加模型");
|
|
||||||
updateModelRoleOptions(primaryId, fallbackId);
|
|
||||||
container.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", () => testPlatformModel(button.closest("[data-model-id]"))));
|
|
||||||
container.querySelectorAll("[data-delete-model]").forEach((button) => button.addEventListener("click", () => deletePlatformModel(button.closest("[data-model-id]"))));
|
|
||||||
container.querySelectorAll("[data-model-field='name']").forEach((input) => input.addEventListener("input", updateModelRoleLabels));
|
|
||||||
refreshIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectModelPool() {
|
|
||||||
const saved = new Map(state.adminModels.map((item) => [item.id, item]));
|
|
||||||
return [...document.querySelectorAll("#modelPoolList [data-model-id]")].map((row) => ({
|
|
||||||
id: row.dataset.modelId,
|
|
||||||
name: row.querySelector("[data-model-field='name']").value.trim(),
|
|
||||||
base_url: row.querySelector("[data-model-field='base_url']").value.trim(),
|
|
||||||
model: row.querySelector("[data-model-field='model']").value.trim(),
|
|
||||||
api_key: row.querySelector("[data-model-field='api_key']").value.trim(),
|
|
||||||
configured: Boolean(saved.get(row.dataset.modelId)?.configured),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateModelRoleOptions(primaryId = document.querySelector("#platformPrimaryModelSelect").value, fallbackId = document.querySelector("#platformFallbackModelSelect").value) {
|
|
||||||
const models = collectModelPool();
|
|
||||||
const options = models.map((item) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.name || item.model || "未命名模型")}</option>`).join("");
|
|
||||||
const primary = document.querySelector("#platformPrimaryModelSelect");
|
|
||||||
const fallback = document.querySelector("#platformFallbackModelSelect");
|
|
||||||
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
|
|
||||||
fallback.innerHTML = `<option value="">不启用辅助模型</option>${options}`;
|
|
||||||
primary.value = models.some((item) => item.id === primaryId) ? primaryId : models[0]?.id || "";
|
|
||||||
fallback.value = models.some((item) => item.id === fallbackId) && fallbackId !== primary.value ? fallbackId : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateModelRoleLabels() {
|
|
||||||
updateModelRoleOptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPlatformModel() {
|
|
||||||
const models = collectModelPool();
|
|
||||||
const id = `model-${Date.now()}-${Math.floor(Math.random() * 10000)}`;
|
|
||||||
models.push({ id, name: `模型 ${models.length + 1}`, base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false });
|
|
||||||
renderModelPool(models, document.querySelector("#platformPrimaryModelSelect").value || id, document.querySelector("#platformFallbackModelSelect").value);
|
|
||||||
document.querySelector(`[data-model-id="${CSS.escape(id)}"] [data-model-field="name"]`)?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function deletePlatformModel(row) {
|
|
||||||
if (!row) return;
|
|
||||||
const id = row.dataset.modelId;
|
|
||||||
const primary = document.querySelector("#platformPrimaryModelSelect").value;
|
|
||||||
const fallback = document.querySelector("#platformFallbackModelSelect").value;
|
|
||||||
if (id === primary || id === fallback) {
|
|
||||||
showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const models = collectModelPool().filter((item) => item.id !== id);
|
|
||||||
renderModelPool(models, primary, fallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAdminUsers(users) {
|
|
||||||
const container = document.querySelector("#adminUsersList");
|
|
||||||
container.innerHTML = users.map((user) => {
|
|
||||||
const admin = user.role === "admin";
|
|
||||||
const member = Boolean(user.membership_subscribed);
|
|
||||||
const identityLabels = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · ");
|
|
||||||
const expiry = member
|
|
||||||
? (user.membership_expires_at ? `有效至 ${membershipDateDisplay(user.membership_expires_at)}` : "永久有效")
|
|
||||||
: user.membership_status === "suspended"
|
|
||||||
? "会员已停用"
|
|
||||||
: user.membership_status === "active" && user.membership_expires_at
|
|
||||||
? `已于 ${membershipDateDisplay(user.membership_expires_at)} 到期`
|
|
||||||
: "尚未开通";
|
|
||||||
return `<article class="admin-user-row" data-admin-user="${number(user.id)}">
|
|
||||||
<div class="admin-user-identity"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(identityLabels)}</span><small>${escapeHtml(expiry)}</small></div>
|
|
||||||
<div class="admin-user-usage">今日调用 <b>${number(user.used_today)}</b></div>
|
|
||||||
<form class="membership-form">
|
|
||||||
<input type="hidden" name="user_id" value="${number(user.id)}">
|
|
||||||
<label><span>状态</span><select name="status"><option value="inactive" ${user.membership_status === "inactive" ? "selected" : ""}>未开通</option><option value="active" ${user.membership_status === "active" ? "selected" : ""}>有效</option><option value="suspended" ${user.membership_status === "suspended" ? "selected" : ""}>停用</option></select></label>
|
|
||||||
<label><span>开通 / 续期时长</span><select name="duration"><option value="">选择时长</option><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></label>
|
|
||||||
<div class="membership-expiry"><span>当前到期</span><strong>${escapeHtml(expiry)}</strong></div>
|
|
||||||
<button class="button" type="submit">应用</button>
|
|
||||||
</form>
|
|
||||||
</article>`;
|
|
||||||
}).join("") || emptyStateHtml("暂无注册用户");
|
|
||||||
container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveMembership(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = event.currentTarget;
|
|
||||||
const data = Object.fromEntries(new FormData(form).entries());
|
|
||||||
const button = form.querySelector("button[type='submit']");
|
|
||||||
button.disabled = true;
|
|
||||||
try {
|
|
||||||
const payload = await apiRequest("/api/admin/membership", "POST", data);
|
|
||||||
renderAdminUsers(payload.users || []);
|
|
||||||
showToast("会员状态已更新");
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error.message || "会员状态保存失败");
|
|
||||||
} finally {
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,13 +85,9 @@ async function saveMarketSettings(event) {
|
|||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try {
|
try {
|
||||||
await apiRequest("/api/admin/settings", "POST", {
|
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,
|
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
|
||||||
});
|
});
|
||||||
document.querySelector("#systemTokenInput").value = "";
|
showToast("行情刷新设置已保存");
|
||||||
document.querySelector("#systemIfindTokenInput").value = "";
|
|
||||||
showToast("行情配置已保存");
|
|
||||||
await openAdminSettings(true);
|
await openAdminSettings(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error.message || "系统配置保存失败");
|
showToast(error.message || "系统配置保存失败");
|
||||||
@@ -220,70 +96,12 @@ async function saveMarketSettings(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveModelPool(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const button = event.currentTarget.querySelector("button[type='submit']");
|
|
||||||
button.disabled = true;
|
|
||||||
try {
|
|
||||||
await apiRequest("/api/admin/settings", "POST", {
|
|
||||||
models: collectModelPool(),
|
|
||||||
primary_model_id: document.querySelector("#platformPrimaryModelSelect").value,
|
|
||||||
fallback_model_id: document.querySelector("#platformFallbackModelSelect").value,
|
|
||||||
});
|
|
||||||
showToast("模型池已保存");
|
|
||||||
await openAdminSettings(true);
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error.message || "模型池保存失败");
|
|
||||||
} finally {
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveMembershipSettings(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const button = event.currentTarget.querySelector("button[type='submit']");
|
|
||||||
button.disabled = true;
|
|
||||||
try {
|
|
||||||
await apiRequest("/api/admin/settings", "POST", {
|
|
||||||
member_daily_limit: number(document.querySelector("#memberDailyLimit").value),
|
|
||||||
});
|
|
||||||
showToast("会员调用额度已保存");
|
|
||||||
await openAdminSettings(true);
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error.message || "会员调用额度保存失败");
|
|
||||||
} finally {
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testPlatformModel(row) {
|
|
||||||
if (!row) return;
|
|
||||||
const button = row.querySelector("[data-test-model]");
|
|
||||||
const status = row.querySelector(".model-test-status");
|
|
||||||
const profile = collectModelPool().find((item) => item.id === row.dataset.modelId) || {};
|
|
||||||
button.disabled = true;
|
|
||||||
status.textContent = "连接中";
|
|
||||||
try {
|
|
||||||
const payload = await apiRequest("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile });
|
|
||||||
status.textContent = `已连通 · ${number(payload.result.latency_ms)} ms`;
|
|
||||||
status.className = "model-test-status success";
|
|
||||||
} catch (error) {
|
|
||||||
status.textContent = error.message;
|
|
||||||
status.className = "model-test-status failure";
|
|
||||||
} finally {
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function bindAdminEvents() {
|
function bindAdminEvents() {
|
||||||
document.querySelector("#settingsButton").addEventListener("click", () => openAdminSettings());
|
document.querySelector("#settingsButton").addEventListener("click", openDataHubConsole);
|
||||||
|
document.querySelector("#marketAdminButton").addEventListener("click", () => openAdminSettings());
|
||||||
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
|
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
|
||||||
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
||||||
document.querySelector("#adminSectionSelect").addEventListener("change", (event) => selectAdminPanel(event.target.value));
|
|
||||||
document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings);
|
document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings);
|
||||||
document.querySelector("#systemModelsForm").addEventListener("submit", saveModelPool);
|
|
||||||
document.querySelector("#membershipSettingsForm").addEventListener("submit", saveMembershipSettings);
|
|
||||||
document.querySelector("#addPlatformModel").addEventListener("click", addPlatformModel);
|
|
||||||
document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh);
|
document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@
|
|||||||
try {
|
try {
|
||||||
response = await fetch(url, requestOptions(method, body, options.signal));
|
response = await fetch(url, requestOptions(method, body, options.signal));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.name === "AbortError") {
|
||||||
|
throw new ApiError("请求已取消或超时", 0, { aborted: true });
|
||||||
|
}
|
||||||
throw new ApiError(readableRequestError(error), 0, null);
|
throw new ApiError(readableRequestError(error), 0, null);
|
||||||
}
|
}
|
||||||
const payload = await parseJson(response);
|
const payload = await parseJson(response);
|
||||||
|
|||||||
@@ -125,50 +125,6 @@
|
|||||||
width: min(1060px, -24px + 100vw);
|
width: min(1060px, -24px + 100vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-section-picker {
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
grid-template-columns: 96px minmax(220px, 360px);
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
padding: 16px 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-section-picker label {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-section-picker select {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
min-height: 32px;
|
|
||||||
|
|
||||||
padding: 0px 11px;
|
|
||||||
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
background: var(--surface);
|
|
||||||
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
font-size: var(--font-size-label);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-section-picker select:focus-visible {
|
|
||||||
outline: 2px solid var(--action);
|
|
||||||
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-panel[hidden] {
|
.admin-panel[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -220,161 +176,6 @@
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-number-field {
|
|
||||||
width: min(260px, 100%);
|
|
||||||
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-users-list {
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
border-top: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-row {
|
|
||||||
min-width: 0px;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
grid-template-columns: 160px 100px minmax(0px, 1fr);
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 14px;
|
|
||||||
|
|
||||||
padding: 13px 0px;
|
|
||||||
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-identity strong {
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
color: var(--text-primary);
|
|
||||||
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-identity small,
|
|
||||||
.admin-user-identity span {
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
margin-top: 4px;
|
|
||||||
|
|
||||||
color: var(--text-muted);
|
|
||||||
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-usage {
|
|
||||||
margin-top: 4px;
|
|
||||||
|
|
||||||
color: var(--text-muted);
|
|
||||||
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form {
|
|
||||||
padding: 0px;
|
|
||||||
|
|
||||||
min-width: 0px;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
grid-template-columns: 100px minmax(170px, 0.8fr) minmax(160px, 1fr) auto;
|
|
||||||
|
|
||||||
align-items: end;
|
|
||||||
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form label {
|
|
||||||
min-width: 0px;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form label span {
|
|
||||||
color: var(--text-muted);
|
|
||||||
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form input,
|
|
||||||
.membership-form select {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
min-width: 0px;
|
|
||||||
|
|
||||||
height: 32px;
|
|
||||||
|
|
||||||
padding: 0px 8px;
|
|
||||||
|
|
||||||
border: 1px solid var(--border-strong);
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-expiry {
|
|
||||||
min-width: 0px;
|
|
||||||
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
gap: 5px;
|
|
||||||
|
|
||||||
align-self: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-expiry span {
|
|
||||||
color: var(--text-muted);
|
|
||||||
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-expiry strong {
|
|
||||||
min-height: 34px;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
|
||||||
.admin-user-row {
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-user-row .membership-form {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form {
|
|
||||||
grid-template-columns: repeat(2, minmax(0px, 1fr));
|
|
||||||
}
|
|
||||||
|
|
||||||
.membership-form .button {
|
|
||||||
align-self: end;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
|
|
||||||
.membership-form {
|
|
||||||
grid-template-columns: minmax(0px, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-status {
|
.connection-status {
|
||||||
margin: 18px 18px 0px;
|
margin: 18px 18px 0px;
|
||||||
|
|
||||||
@@ -399,7 +200,6 @@
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dialog .membership-form,
|
|
||||||
.settings-dialog .settings-section > form,
|
.settings-dialog .settings-section > form,
|
||||||
.settings-dialog.admin-dialog > form {
|
.settings-dialog.admin-dialog > form {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
@@ -559,7 +359,6 @@
|
|||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dialog .membership-form,
|
|
||||||
.settings-dialog .settings-section > form,
|
.settings-dialog .settings-section > form,
|
||||||
.settings-dialog.admin-dialog > form {
|
.settings-dialog.admin-dialog > form {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
@@ -1173,12 +972,6 @@ button.account-role-badge:focus-visible {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.admin-section-picker {
|
|
||||||
grid-template-columns: minmax(0px, 1fr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.settings-dialog .form-field {
|
.settings-dialog .form-field {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1520,32 +1313,6 @@ button.account-role-badge:focus-visible {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog .admin-section-picker {
|
|
||||||
grid-template-columns: auto minmax(220px, 300px);
|
|
||||||
|
|
||||||
gap: 14px;
|
|
||||||
|
|
||||||
padding: 12px 20px;
|
|
||||||
|
|
||||||
border-bottom: 1px solid var(--dialog-line);
|
|
||||||
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-section-picker label {
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-section-picker select {
|
|
||||||
min-height: 32px;
|
|
||||||
|
|
||||||
border-color: var(--border-strong);
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
font-size: var(--font-size-label);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-panel {
|
.admin-dialog .admin-panel {
|
||||||
max-width: 860px;
|
max-width: 860px;
|
||||||
|
|
||||||
@@ -1556,24 +1323,6 @@ button.account-role-badge:focus-visible {
|
|||||||
border-top: 1px solid var(--dialog-line);
|
border-top: 1px solid var(--dialog-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog .admin-users-list {
|
|
||||||
border-color: var(--dialog-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-user-row {
|
|
||||||
grid-template-columns: 150px 90px minmax(0px, 1fr);
|
|
||||||
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
padding: 12px 0px;
|
|
||||||
|
|
||||||
border-color: var(--dialog-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .membership-form {
|
|
||||||
grid-template-columns: 92px minmax(135px, 0.8fr) minmax(140px, 1fr) auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.account-settings-dialog .membership-status-grid {
|
.account-settings-dialog .membership-status-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0px, 1fr));
|
grid-template-columns: repeat(2, minmax(0px, 1fr));
|
||||||
@@ -1582,22 +1331,6 @@ button.account-role-badge:focus-visible {
|
|||||||
.account-settings-dialog .account-birth-form {
|
.account-settings-dialog .account-birth-form {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog .admin-section-picker {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-user-row {
|
|
||||||
grid-template-columns: 1fr auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-user-row .membership-form {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
|
|
||||||
grid-template-columns: repeat(2, minmax(0px, 1fr));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 460px) {
|
@media (max-width: 460px) {
|
||||||
@@ -1612,10 +1345,6 @@ button.account-role-badge:focus-visible {
|
|||||||
.account-settings-dialog .membership-comparison > div {
|
.account-settings-dialog .membership-comparison > div {
|
||||||
min-width: 510px;
|
min-width: 510px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog .admin-user-row .membership-form {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="dark"] :is(.header-command-group, .account-dropdown) {
|
:root[data-theme="dark"] :is(.header-command-group, .account-dropdown) {
|
||||||
|
|||||||
@@ -1028,64 +1028,6 @@
|
|||||||
vertical-align: -1px;
|
vertical-align: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model {
|
|
||||||
padding: 12px 14px;
|
|
||||||
|
|
||||||
border-bottom: 1px solid var(--line-soft);
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model:hover {
|
|
||||||
background: var(--table-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r1 {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r1 .nm {
|
|
||||||
font-weight: 700;
|
|
||||||
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r1 .lv {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r2 {
|
|
||||||
font-size: 11.5px;
|
|
||||||
|
|
||||||
color: var(--sub);
|
|
||||||
|
|
||||||
margin-top: 4px;
|
|
||||||
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r3 {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
|
|
||||||
margin-top: 6px;
|
|
||||||
|
|
||||||
font-size: 10.5px;
|
|
||||||
|
|
||||||
color: var(--faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.model .r3 .q {
|
|
||||||
border-bottom: 1px dashed var(--faint);
|
|
||||||
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme="dark"] :is(.card-h, .performance-stage-card header, .rotation-card-head, .auction-card-head-v2, .theme-card-head-v2, .dragon-detail-header, .screener-card-head, .mentor-page-header) {
|
:root[data-theme="dark"] :is(.card-h, .performance-stage-card header, .rotation-card-head, .auction-card-head-v2, .theme-card-head-v2, .dragon-detail-header, .screener-card-head, .mentor-page-header) {
|
||||||
border-color: var(--line-soft);
|
border-color: var(--line-soft);
|
||||||
|
|
||||||
|
|||||||
@@ -29,22 +29,6 @@
|
|||||||
margin-top: 22px;
|
margin-top: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog .model-role-selectors {
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .model-pool-list {
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .model-row {
|
|
||||||
border-color: var(--border);
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
background: var(--surface-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes overlay-enter {
|
@keyframes overlay-enter {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ function selectAuthMode(mode) {
|
|||||||
const registering = state.authMode === "register";
|
const registering = state.authMode === "register";
|
||||||
document.querySelector("#authConfirmField").hidden = !registering;
|
document.querySelector("#authConfirmField").hidden = !registering;
|
||||||
document.querySelector("#authPasswordConfirm").required = registering;
|
document.querySelector("#authPasswordConfirm").required = registering;
|
||||||
|
document.querySelector("#authInviteField").hidden = !registering;
|
||||||
|
document.querySelector("#authInviteCode").required = registering;
|
||||||
document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password";
|
document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password";
|
||||||
document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录";
|
document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录";
|
||||||
document.querySelector("#authError").hidden = true;
|
document.querySelector("#authError").hidden = true;
|
||||||
@@ -24,7 +26,11 @@ async function submitAuthForm(event) {
|
|||||||
const button = document.querySelector("#authSubmitButton");
|
const button = document.querySelector("#authSubmitButton");
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try {
|
try {
|
||||||
const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", { username, password });
|
const registering = state.authMode === "register";
|
||||||
|
const credentials = registering
|
||||||
|
? { username, password, invite_code: document.querySelector("#authInviteCode").value.trim() }
|
||||||
|
: { username, password };
|
||||||
|
const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", credentials);
|
||||||
document.querySelector("#authForm").reset();
|
document.querySelector("#authForm").reset();
|
||||||
await applyAuthenticatedSession(session);
|
await applyAuthenticatedSession(session);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -42,6 +48,7 @@ async function applyAuthenticatedSession(session) {
|
|||||||
const isAdmin = session.user?.role === "admin";
|
const isAdmin = session.user?.role === "admin";
|
||||||
updateAccountIdentityBadges(session.user?.membership || {});
|
updateAccountIdentityBadges(session.user?.membership || {});
|
||||||
document.querySelector("#settingsButton").hidden = !isAdmin;
|
document.querySelector("#settingsButton").hidden = !isAdmin;
|
||||||
|
document.querySelector("#marketAdminButton").hidden = !isAdmin;
|
||||||
document.querySelector("#syncButton").hidden = !isAdmin;
|
document.querySelector("#syncButton").hidden = !isAdmin;
|
||||||
document.querySelector("#reasonForm").hidden = !isAdmin;
|
document.querySelector("#reasonForm").hidden = !isAdmin;
|
||||||
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
|
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
|
||||||
|
|||||||
@@ -3765,3 +3765,30 @@ body.mobile-shell[data-active-view="heavenView"] .app-header .overview-strip[dat
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* HEL-524: 侧栏去掉作为整块独立色板的背景,融入页面底层;品牌区与顶栏保持同层;
|
||||||
|
收起侧栏整行不再单独着色。展开/收起态均生效。「问天」冻结路由维持原有外观。 */
|
||||||
|
body:not(.mobile-shell):not([data-active-view="heavenView"]) .module-nav,
|
||||||
|
body.sidebar-collapsed:not(.mobile-shell):not([data-active-view="heavenView"]) .module-nav {
|
||||||
|
background: var(--canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.mobile-shell):not([data-active-view="heavenView"]) .sidebar-brand {
|
||||||
|
background: var(--header-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body:not(.mobile-shell):not([data-active-view="heavenView"]) .sidebar-collapse-button {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] body:not(.mobile-shell):not([data-active-view="heavenView"]) .module-nav {
|
||||||
|
background: var(--canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] body:not(.mobile-shell):not([data-active-view="heavenView"]) .sidebar-brand {
|
||||||
|
background-color: var(--header-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] body:not(.mobile-shell):not([data-active-view="heavenView"]) :is(.nav-group, .sidebar-collapse-button) {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -381,6 +381,40 @@
|
|||||||
--r2-radius: var(--size-radius-lg);
|
--r2-radius: var(--size-radius-lg);
|
||||||
--r2-shadow: var(--elevation-card);
|
--r2-shadow: var(--elevation-card);
|
||||||
|
|
||||||
|
/* HEL-524: 板块轮动五档强度色(日间)。五档单调递进:青→蓝→紫→橙→红,冷到暖;
|
||||||
|
同一强度值在所有日期列使用完全相同的颜色,颜色仅代表强度档位,与涨跌无关。 */
|
||||||
|
--rotation-tier1-bar: #0D9488;
|
||||||
|
--rotation-tier1-bg: rgba(13, 148, 136, .08);
|
||||||
|
--rotation-tier1-border: rgba(13, 148, 136, .25);
|
||||||
|
--rotation-tier1-text: #0F766E;
|
||||||
|
--rotation-tier1-fill-start: #11766D;
|
||||||
|
--rotation-tier1-fill-end: #0C443F;
|
||||||
|
--rotation-tier2-bar: #2563EB;
|
||||||
|
--rotation-tier2-bg: rgba(37, 99, 235, .07);
|
||||||
|
--rotation-tier2-border: rgba(37, 99, 235, .22);
|
||||||
|
--rotation-tier2-text: #1D4ED8;
|
||||||
|
--rotation-tier2-fill-start: #2059D7;
|
||||||
|
--rotation-tier2-fill-end: #1D46A2;
|
||||||
|
--rotation-tier3-bar: #7C3AED;
|
||||||
|
--rotation-tier3-bg: rgba(124, 58, 237, .07);
|
||||||
|
--rotation-tier3-border: rgba(124, 58, 237, .22);
|
||||||
|
--rotation-tier3-text: #6D28D9;
|
||||||
|
--rotation-tier3-fill-start: #6F2DE0;
|
||||||
|
--rotation-tier3-fill-end: #5720B5;
|
||||||
|
--rotation-tier4-bar: #EA580C;
|
||||||
|
--rotation-tier4-bg: rgba(234, 88, 12, .08);
|
||||||
|
--rotation-tier4-border: rgba(234, 88, 12, .25);
|
||||||
|
--rotation-tier4-text: #C2410C;
|
||||||
|
--rotation-tier4-fill-start: #C85215;
|
||||||
|
--rotation-tier4-fill-end: #913E13;
|
||||||
|
--rotation-tier5-bar: #DC2626;
|
||||||
|
--rotation-tier5-bg: rgba(220, 38, 38, .08);
|
||||||
|
--rotation-tier5-border: rgba(220, 38, 38, .25);
|
||||||
|
--rotation-tier5-text: #B91C1C;
|
||||||
|
--rotation-tier5-fill-start: #BE2B2B;
|
||||||
|
--rotation-tier5-fill-end: #8C2424;
|
||||||
|
--rotation-tier-glow-alpha: 22%;
|
||||||
|
|
||||||
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
font-family: "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@@ -557,6 +591,40 @@
|
|||||||
--heat-warm-ink: #dfeaf7;
|
--heat-warm-ink: #dfeaf7;
|
||||||
--heat-mild-bg: #293440;
|
--heat-mild-bg: #293440;
|
||||||
--heat-mild-ink: #c7d2dc;
|
--heat-mild-ink: #c7d2dc;
|
||||||
|
|
||||||
|
/* HEL-524: 板块轮动五档强度色(夜间)。五档单调递进:青→蓝→紫→橙→红,冷到暖;
|
||||||
|
同一强度值在所有日期列使用完全相同的颜色,颜色仅代表强度档位,与涨跌无关。 */
|
||||||
|
--rotation-tier1-bar: #2DD4BF;
|
||||||
|
--rotation-tier1-bg: rgba(45, 212, 191, .09);
|
||||||
|
--rotation-tier1-border: rgba(45, 212, 191, .20);
|
||||||
|
--rotation-tier1-text: #5EEAD4;
|
||||||
|
--rotation-tier1-fill-start: #32B6A5;
|
||||||
|
--rotation-tier1-fill-end: #2A867A;
|
||||||
|
--rotation-tier2-bar: #60A5FA;
|
||||||
|
--rotation-tier2-bg: rgba(96, 165, 250, .10);
|
||||||
|
--rotation-tier2-border: rgba(96, 165, 250, .22);
|
||||||
|
--rotation-tier2-text: #93C5FD;
|
||||||
|
--rotation-tier2-fill-start: #5098F1;
|
||||||
|
--rotation-tier2-fill-end: #217AE7;
|
||||||
|
--rotation-tier3-bar: #A78BFA;
|
||||||
|
--rotation-tier3-bg: rgba(167, 139, 250, .11);
|
||||||
|
--rotation-tier3-border: rgba(167, 139, 250, .24);
|
||||||
|
--rotation-tier3-text: #C4B5FD;
|
||||||
|
--rotation-tier3-fill-start: #9879F2;
|
||||||
|
--rotation-tier3-fill-end: #724AE9;
|
||||||
|
--rotation-tier4-bar: #FB923C;
|
||||||
|
--rotation-tier4-bg: rgba(251, 146, 60, .12);
|
||||||
|
--rotation-tier4-border: rgba(251, 146, 60, .26);
|
||||||
|
--rotation-tier4-text: #FDBA74;
|
||||||
|
--rotation-tier4-fill-start: #F07C2D;
|
||||||
|
--rotation-tier4-fill-end: #D05A14;
|
||||||
|
--rotation-tier5-bar: #F87171;
|
||||||
|
--rotation-tier5-bg: rgba(248, 113, 113, .13);
|
||||||
|
--rotation-tier5-border: rgba(248, 113, 113, .28);
|
||||||
|
--rotation-tier5-text: #FCA5A5;
|
||||||
|
--rotation-tier5-fill-start: #EF6161;
|
||||||
|
--rotation-tier5-fill-end: #E53333;
|
||||||
|
--rotation-tier-glow-alpha: 35%;
|
||||||
--heaven-field-bg: #23241f;
|
--heaven-field-bg: #23241f;
|
||||||
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .28), 0 8px 24px rgba(0, 0, 0, .16);
|
--shadow-soft: 0 1px 2px rgba(0, 0, 0, .28), 0 8px 24px rgba(0, 0, 0, .16);
|
||||||
--shadow: 0 18px 50px rgba(0, 0, 0, .46);
|
--shadow: 0 18px 50px rgba(0, 0, 0, .46);
|
||||||
|
|||||||
@@ -632,6 +632,7 @@ test("admin shell opens every primary workspace and global search", async ({ pag
|
|||||||
await expect(page.locator("#authGate")).toBeHidden();
|
await expect(page.locator("#authGate")).toBeHidden();
|
||||||
await openHeaderCommandMenu(page);
|
await openHeaderCommandMenu(page);
|
||||||
await expect(page.locator("#settingsButton")).toBeVisible();
|
await expect(page.locator("#settingsButton")).toBeVisible();
|
||||||
|
await expect(page.locator("#marketAdminButton")).toBeVisible();
|
||||||
await expect(page.locator("#syncButton")).toBeVisible();
|
await expect(page.locator("#syncButton")).toBeVisible();
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await page.locator("#alertButton").click();
|
await page.locator("#alertButton").click();
|
||||||
@@ -1260,9 +1261,9 @@ test("sector rotation transfers the nine-day matrix, tracking and sortable detai
|
|||||||
await expect(page.locator("#rotationHistory .rotation-day").last()).toHaveClass(/latest-day/);
|
await expect(page.locator("#rotationHistory .rotation-day").last()).toHaveClass(/latest-day/);
|
||||||
await expect(page.locator("#rotationView .rotation-legend")).not.toContainText("单元格 =");
|
await expect(page.locator("#rotationView .rotation-legend")).not.toContainText("单元格 =");
|
||||||
const firstDayCells = page.locator("#rotationHistory .rotation-day").first().locator(".rotation-sector-chip");
|
const firstDayCells = page.locator("#rotationHistory .rotation-day").first().locator(".rotation-sector-chip");
|
||||||
await expect(firstDayCells.nth(0)).toHaveClass(/heat-strong/);
|
await expect(firstDayCells.nth(0)).toHaveClass(/heat-tier-4/); // strength 92 → 85-94 档(高·橙)
|
||||||
await expect(firstDayCells.nth(1)).toHaveClass(/heat-warm/);
|
await expect(firstDayCells.nth(1)).toHaveClass(/heat-tier-3/); // strength 76 → 75-84 档(中高·紫)
|
||||||
await expect(firstDayCells.nth(2)).toHaveClass(/heat-mild/);
|
await expect(firstDayCells.nth(2)).toHaveClass(/heat-tier-1/); // strength 58 → <65 档(低·青)
|
||||||
const cellVisuals = await firstDayCells.evaluateAll((cells) => cells.map((cell) => {
|
const cellVisuals = await firstDayCells.evaluateAll((cells) => cells.map((cell) => {
|
||||||
const style = getComputedStyle(cell);
|
const style = getComputedStyle(cell);
|
||||||
return { background: style.backgroundColor, radius: parseFloat(style.borderRadius), duration: style.transitionDuration };
|
return { background: style.backgroundColor, radius: parseFloat(style.borderRadius), duration: style.transitionDuration };
|
||||||
@@ -1753,6 +1754,7 @@ test("regular account cannot see admin controls and member features are gated",
|
|||||||
await mockApplication(page, session("user", false));
|
await mockApplication(page, session("user", false));
|
||||||
await page.goto("/index.html");
|
await page.goto("/index.html");
|
||||||
await expect(page.locator("#settingsButton")).toBeHidden();
|
await expect(page.locator("#settingsButton")).toBeHidden();
|
||||||
|
await expect(page.locator("#marketAdminButton")).toBeHidden();
|
||||||
await expect(page.locator("#syncButton")).toBeHidden();
|
await expect(page.locator("#syncButton")).toBeHidden();
|
||||||
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
||||||
await page.locator('[data-view="screenerView"]').first().click();
|
await page.locator('[data-view="screenerView"]').first().click();
|
||||||
@@ -3332,9 +3334,9 @@ test("global dialogs share the stage 18 geometry without changing account or adm
|
|||||||
await page.locator("#closeSettingsDialog").click();
|
await page.locator("#closeSettingsDialog").click();
|
||||||
|
|
||||||
await openHeaderCommandMenu(page);
|
await openHeaderCommandMenu(page);
|
||||||
await page.locator("#settingsButton").click();
|
await page.locator("#marketAdminButton").click();
|
||||||
await expect(page.locator("#adminDialog")).toHaveAttribute("aria-labelledby", "adminDialogTitle");
|
await expect(page.locator("#adminDialog")).toHaveAttribute("aria-labelledby", "adminDialogTitle");
|
||||||
await expect(page.locator("#adminSectionSelect")).toBeVisible();
|
await expect(page.locator("#systemMarketForm")).toBeVisible();
|
||||||
const adminBox = await page.locator("#adminDialog").boundingBox();
|
const adminBox = await page.locator("#adminDialog").boundingBox();
|
||||||
expect(adminBox.width).toBeLessThanOrEqual(902);
|
expect(adminBox.width).toBeLessThanOrEqual(902);
|
||||||
expect(Math.abs(adminBox.x + adminBox.width / 2 - 720)).toBeLessThanOrEqual(2);
|
expect(Math.abs(adminBox.x + adminBox.width / 2 - 720)).toBeLessThanOrEqual(2);
|
||||||
@@ -3510,7 +3512,7 @@ test("B-199 screener review and account surfaces fit day night viewports", async
|
|||||||
await page.locator("#closeSettingsDialog").click();
|
await page.locator("#closeSettingsDialog").click();
|
||||||
|
|
||||||
await openHeaderCommandMenu(page);
|
await openHeaderCommandMenu(page);
|
||||||
await page.locator("#settingsButton").click();
|
await page.locator("#marketAdminButton").click();
|
||||||
await expect(page.locator("#adminDialog")).toBeVisible();
|
await expect(page.locator("#adminDialog")).toBeVisible();
|
||||||
await shot("admin-1600-day");
|
await shot("admin-1600-day");
|
||||||
await page.locator("#closeAdminDialog").click();
|
await page.locator("#closeAdminDialog").click();
|
||||||
@@ -3609,6 +3611,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
|
|||||||
await expect(page.locator("#refreshButton")).toBeVisible();
|
await expect(page.locator("#refreshButton")).toBeVisible();
|
||||||
await expect(page.locator("#syncButton")).toBeVisible();
|
await expect(page.locator("#syncButton")).toBeVisible();
|
||||||
await expect(page.locator("#settingsButton")).toBeVisible();
|
await expect(page.locator("#settingsButton")).toBeVisible();
|
||||||
|
await expect(page.locator("#marketAdminButton")).toBeVisible();
|
||||||
await expect(page.locator("#accountAdminBadge")).toBeVisible();
|
await expect(page.locator("#accountAdminBadge")).toBeVisible();
|
||||||
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
||||||
await expect(page.locator("#accountButton")).toBeVisible();
|
await expect(page.locator("#accountButton")).toBeVisible();
|
||||||
@@ -3621,7 +3624,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
|
|||||||
expect(geometry.nameFits, `${viewport.width} account name truncated`).toBe(true);
|
expect(geometry.nameFits, `${viewport.width} account name truncated`).toBe(true);
|
||||||
await page.locator("#refreshButton").click();
|
await page.locator("#refreshButton").click();
|
||||||
await expect(page.locator("#loadingOverlay")).toBeHidden();
|
await expect(page.locator("#loadingOverlay")).toBeHidden();
|
||||||
await page.locator("#settingsButton").click();
|
await page.locator("#marketAdminButton").click();
|
||||||
await expect(page.locator("#adminDialog")).toBeVisible();
|
await expect(page.locator("#adminDialog")).toBeVisible();
|
||||||
await page.locator("#closeAdminDialog").click();
|
await page.locator("#closeAdminDialog").click();
|
||||||
await page.locator("#accountButton").click();
|
await page.locator("#accountButton").click();
|
||||||
@@ -3668,6 +3671,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
|
|||||||
await expect(page.locator("#refreshButton")).toBeVisible();
|
await expect(page.locator("#refreshButton")).toBeVisible();
|
||||||
await expect(page.locator("#syncButton")).toBeHidden();
|
await expect(page.locator("#syncButton")).toBeHidden();
|
||||||
await expect(page.locator("#settingsButton")).toBeHidden();
|
await expect(page.locator("#settingsButton")).toBeHidden();
|
||||||
|
await expect(page.locator("#marketAdminButton")).toBeHidden();
|
||||||
await expect(page.locator("#accountAdminBadge")).toBeHidden();
|
await expect(page.locator("#accountAdminBadge")).toBeHidden();
|
||||||
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
||||||
await expect(page.locator("#accountName")).toHaveText("normal_user");
|
await expect(page.locator("#accountName")).toHaveText("normal_user");
|
||||||
|
|||||||
@@ -264,8 +264,7 @@ const SYSTEM_PAGES = [
|
|||||||
["system/profile", "账号资料"],
|
["system/profile", "账号资料"],
|
||||||
["system/password", "修改密码"],
|
["system/password", "修改密码"],
|
||||||
["system/membership", "会员状态"],
|
["system/membership", "会员状态"],
|
||||||
["system/admin", "系统设置"],
|
["system/admin", "行情管理"],
|
||||||
["system/members", "会员管理"],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
|
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
|
||||||
@@ -360,8 +359,7 @@ test("system management pages render real content instead of placeholders", asyn
|
|||||||
await expect(page.locator(".m-sys-grid")).toBeVisible();
|
await expect(page.locator(".m-sys-grid")).toBeVisible();
|
||||||
await navigateToFeature(page, "system/admin");
|
await navigateToFeature(page, "system/admin");
|
||||||
await expect(page.locator("#m-sys-token")).toBeVisible();
|
await expect(page.locator("#m-sys-token")).toBeVisible();
|
||||||
await navigateToFeature(page, "system/members");
|
await expect(page.locator("[data-system-datahub]")).toBeVisible();
|
||||||
await expect(page.locator("#m-sys-member-limit")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("system home groups entries and keeps admin-only items gated", async ({ page }) => {
|
test("system home groups entries and keeps admin-only items gated", async ({ page }) => {
|
||||||
@@ -403,10 +401,7 @@ test("system settings tabs, model editor, delete confirm and theme toggle work",
|
|||||||
await page.locator("[data-theme-toggle]").click();
|
await page.locator("[data-theme-toggle]").click();
|
||||||
await expect.poll(async () => page.locator("#m-app").getAttribute("data-theme")).not.toBe(before);
|
await expect.poll(async () => page.locator("#m-app").getAttribute("data-theme")).not.toBe(before);
|
||||||
|
|
||||||
await navigateToFeature(page, "system/members");
|
await expect(page.locator("[data-system-datahub]").first()).toBeVisible();
|
||||||
await page.locator("[data-system-open-member]").click();
|
|
||||||
await expect(page.locator(".m-sheet-root.is-open")).toBeVisible();
|
|
||||||
await expect(page.locator(".m-sheet-head h2")).toContainText("管理会员");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("password mismatch shows inline error instead of a silent submit", async ({ page }) => {
|
test("password mismatch shows inline error instead of a silent submit", async ({ page }) => {
|
||||||
@@ -439,7 +434,7 @@ test("non-admin cannot open system admin pages as placeholders", async ({ page }
|
|||||||
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
|
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
|
||||||
await expect(page.locator("[data-system-page='home']")).toBeVisible();
|
await expect(page.locator("[data-system-page='home']")).toBeVisible();
|
||||||
await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0);
|
await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0);
|
||||||
await expect(page.locator('[data-route="#/feature/system/members"]')).toHaveCount(0);
|
await expect(page.locator("[data-system-datahub]")).toHaveCount(0);
|
||||||
await expect(page.locator("[data-system-switch]")).toBeVisible();
|
await expect(page.locator("[data-system-switch]")).toBeVisible();
|
||||||
await navigateToFeature(page, "system/admin");
|
await navigateToFeature(page, "system/admin");
|
||||||
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
||||||
|
|||||||
@@ -35,7 +35,12 @@ class AccountSwitchGrantTests(unittest.TestCase):
|
|||||||
self.bound_user_id = int(user_id)
|
self.bound_user_id = int(user_id)
|
||||||
|
|
||||||
def _register(self, username: str, device_hash: str = "") -> dict:
|
def _register(self, username: str, device_hash: str = "") -> dict:
|
||||||
return self.service.register(username, "Password123", device_hash or self.device_a)
|
invite = ""
|
||||||
|
if self.database.count_users():
|
||||||
|
invite = self.service.generate_invite_codes(1)[0]["code"]
|
||||||
|
return self.service.register(
|
||||||
|
username, "Password123", device_hash or self.device_a, invite
|
||||||
|
)
|
||||||
|
|
||||||
def test_login_records_accounts_for_the_current_device_only(self) -> None:
|
def test_login_records_accounts_for_the_current_device_only(self) -> None:
|
||||||
first = self._register("alpha_user")
|
first = self._register("alpha_user")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from database import ReviewDatabase
|
|||||||
|
|
||||||
|
|
||||||
class BootstrapContainerTests(unittest.TestCase):
|
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(
|
result = environment_credentials(
|
||||||
{
|
{
|
||||||
"TUSHARE_TOKEN": " tushare ",
|
"TUSHARE_TOKEN": " tushare ",
|
||||||
@@ -20,8 +20,8 @@ class BootstrapContainerTests(unittest.TestCase):
|
|||||||
"LLM_MODEL": "legacy-model",
|
"LLM_MODEL": "legacy-model",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.assertEqual(result["tushare_token"], "tushare")
|
self.assertNotIn("tushare_token", result)
|
||||||
self.assertEqual(result["ifind_refresh_token"], "refresh")
|
self.assertNotIn("ifind_refresh_token", result)
|
||||||
self.assertEqual(result["platform_llm_primary_api_key"], "legacy-key")
|
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_base_url"], "https://legacy.example/v1")
|
||||||
self.assertEqual(result["platform_llm_primary_model"], "legacy-model")
|
self.assertEqual(result["platform_llm_primary_model"], "legacy-model")
|
||||||
@@ -45,8 +45,9 @@ class BootstrapContainerTests(unittest.TestCase):
|
|||||||
self.assertIs(container.strategy_tracking.repository.database, database)
|
self.assertIs(container.strategy_tracking.repository.database, database)
|
||||||
self.assertIs(container.alert_service.repository.database, database)
|
self.assertIs(container.alert_service.repository.database, database)
|
||||||
self.assertIs(container.trade_journal.repository.database, database)
|
self.assertIs(container.trade_journal.repository.database, database)
|
||||||
self.assertIs(container.chart_data.ifind, container.ifind)
|
self.assertIs(container.ifind, container.data_gateway.ifind)
|
||||||
self.assertTrue(container.ifind.configured)
|
self.assertIs(container.chart_data.datahub, container.data_gateway.datahub)
|
||||||
|
self.assertIsNone(container.chart_data.ifind)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from backend.data.providers.ifind_client import IfindHttpClient
|
|
||||||
from backend.features.market.charts import ChartDataError, EastmoneyChartClient, HIS_TRENDS_URL, MarketChartClient, TRENDS_URL
|
from backend.features.market.charts import ChartDataError, EastmoneyChartClient, HIS_TRENDS_URL, MarketChartClient, TRENDS_URL
|
||||||
from server import DashboardService
|
from server import DashboardService
|
||||||
|
|
||||||
@@ -196,13 +195,13 @@ class DatahubChartFallbackTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
fallback = LookbackChartClient()
|
fallback = LookbackChartClient()
|
||||||
client = MarketChartClient(IfindHttpClient(), fallback, hub)
|
client = MarketChartClient(hub)
|
||||||
payload = client.stock_intraday("601318")
|
payload = client.stock_intraday("601318")
|
||||||
self.assertEqual(payload["source"], "datahub")
|
self.assertEqual(payload["source"], "datahub")
|
||||||
self.assertEqual(hub.calls, ["601318"])
|
self.assertEqual(hub.calls, ["601318"])
|
||||||
self.assertEqual(fallback.requests, [])
|
self.assertEqual(fallback.requests, [])
|
||||||
|
|
||||||
def test_datahub_timeout_or_empty_falls_back_to_eastmoney(self):
|
def test_datahub_timeout_or_empty_does_not_use_old_channel(self):
|
||||||
fallback = LookbackChartClient()
|
fallback = LookbackChartClient()
|
||||||
for hub in (
|
for hub in (
|
||||||
FakeHub(chart=None),
|
FakeHub(chart=None),
|
||||||
@@ -212,11 +211,10 @@ class DatahubChartFallbackTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
EastmoneyChartClient._cache.clear()
|
EastmoneyChartClient._cache.clear()
|
||||||
fallback.requests.clear()
|
fallback.requests.clear()
|
||||||
client = MarketChartClient(IfindHttpClient(), fallback, hub)
|
client = MarketChartClient(hub)
|
||||||
payload = client.stock_intraday("000001")
|
with self.assertRaises(ChartDataError):
|
||||||
self.assertEqual(payload["trade_date"], "2026-09-07")
|
client.stock_intraday("000001")
|
||||||
self.assertGreaterEqual(len(payload["points"]), 1)
|
self.assertEqual(fallback.requests, [])
|
||||||
self.assertTrue(fallback.requests)
|
|
||||||
|
|
||||||
def test_datahub_daily_skips_ifind(self):
|
def test_datahub_daily_skips_ifind(self):
|
||||||
hub = FakeHub(
|
hub = FakeHub(
|
||||||
@@ -232,7 +230,7 @@ class DatahubChartFallbackTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
client = MarketChartClient(IfindHttpClient(), LookbackChartClient(), hub)
|
client = MarketChartClient(hub)
|
||||||
rows = client.stock_daily("600000", "20260907")
|
rows = client.stock_daily("600000", "20260907")
|
||||||
self.assertEqual(rows[-1]["trade_date"], "2026-09-07")
|
self.assertEqual(rows[-1]["trade_date"], "2026-09-07")
|
||||||
self.assertIn("daily:600000", hub.calls)
|
self.assertIn("daily:600000", hub.calls)
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ MODULE_STYLESHEETS = (
|
|||||||
"pages/review/foundation.css",
|
"pages/review/foundation.css",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 问天是冻结区:即便某条规则在当前运行时里已经没人引用,也不在这里做陈旧清理,
|
||||||
|
# 免得为了让门禁变绿去动冻结代码。其余门禁(归属唯一、无空声明等)照常覆盖它。
|
||||||
|
FROZEN_STYLESHEETS = ("pages/heaven/foundation.css",)
|
||||||
|
|
||||||
RETIRED_STYLESHEETS = (
|
RETIRED_STYLESHEETS = (
|
||||||
"styles/styles.css",
|
"styles/styles.css",
|
||||||
"styles/renovation.css",
|
"styles/renovation.css",
|
||||||
@@ -559,6 +563,8 @@ class CssGovernanceTests(unittest.TestCase):
|
|||||||
def test_every_selector_has_a_runtime_consumer(self) -> None:
|
def test_every_selector_has_a_runtime_consumer(self) -> None:
|
||||||
stale: list[str] = []
|
stale: list[str] = []
|
||||||
for relative, keys in self.rule_keys.items():
|
for relative, keys in self.rule_keys.items():
|
||||||
|
if relative in FROZEN_STYLESHEETS:
|
||||||
|
continue
|
||||||
for contexts, selector in keys:
|
for contexts, selector in keys:
|
||||||
if selector.lower().startswith(("@keyframes", "@-webkit-keyframes")):
|
if selector.lower().startswith(("@keyframes", "@-webkit-keyframes")):
|
||||||
continue
|
continue
|
||||||
|
|||||||
+38
-15
@@ -12,6 +12,7 @@ from backend.data import (
|
|||||||
QualityEvidence,
|
QualityEvidence,
|
||||||
build_data_gateway,
|
build_data_gateway,
|
||||||
)
|
)
|
||||||
|
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
||||||
from backend.data.quality import market_timezone
|
from backend.data.quality import market_timezone
|
||||||
|
|
||||||
|
|
||||||
@@ -35,16 +36,30 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
with self.assertRaises(DataPolicyError):
|
with self.assertRaises(DataPolicyError):
|
||||||
policy.assert_allowed("market.level2", "unresolved", "display")
|
policy.assert_allowed("market.level2", "unresolved", "display")
|
||||||
|
|
||||||
def test_gateway_uses_live_token_supplier_and_shared_ifind(self) -> None:
|
def test_gateway_uses_hub_facade_and_proxies(self) -> None:
|
||||||
token = {"value": "first"}
|
settings = DatahubSettings(
|
||||||
gateway = build_data_gateway(
|
base_url="http://127.0.0.1:8766",
|
||||||
{"ifind_refresh_token": "refresh", "ifind_access_token": "access"},
|
token="hub-token",
|
||||||
lambda: token["value"],
|
datasets={name: DatasetFlags(name, read=True) for name in DATASETS},
|
||||||
)
|
)
|
||||||
self.assertEqual(gateway.tushare().token, "first")
|
gateway = build_data_gateway(
|
||||||
token["value"] = "second"
|
{},
|
||||||
self.assertEqual(gateway.tushare().token, "second")
|
datahub_settings=settings,
|
||||||
self.assertIs(gateway.chart_data.ifind, gateway.ifind)
|
)
|
||||||
|
client = gateway.tushare()
|
||||||
|
self.assertEqual(client.token, "datahub")
|
||||||
|
self.assertIsNone(client.realtime_aggregator)
|
||||||
|
self.assertFalse(hasattr(client, "_legacy"))
|
||||||
|
self.assertIs(gateway.ifind, gateway.ifind_provider.client)
|
||||||
|
self.assertIs(gateway.chart_data.datahub, gateway.datahub)
|
||||||
|
self.assertIsNone(gateway.chart_data.ifind)
|
||||||
|
from backend.data.datahub.bridge import DatahubAwareTushareClient
|
||||||
|
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
||||||
|
from backend.data.datahub.realtime_proxy import HubRealtimeProxy
|
||||||
|
|
||||||
|
self.assertIsInstance(client, DatahubAwareTushareClient)
|
||||||
|
self.assertIsInstance(gateway.ifind, HubIfindProxy)
|
||||||
|
self.assertIsInstance(gateway.realtime_observer, HubRealtimeProxy)
|
||||||
|
|
||||||
def test_server_has_no_direct_runtime_tushare_construction(self) -> None:
|
def test_server_has_no_direct_runtime_tushare_construction(self) -> None:
|
||||||
source = (
|
source = (
|
||||||
@@ -54,24 +69,29 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
/ "market"
|
/ "market"
|
||||||
/ "service.py"
|
/ "service.py"
|
||||||
).read_text(encoding="utf-8")
|
).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)
|
self.assertIn("return gateway.tushare()", source)
|
||||||
|
|
||||||
def test_provider_construction_has_unique_declared_owners(self) -> None:
|
def test_provider_construction_has_unique_declared_owners(self) -> None:
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
owners = {
|
owners = {
|
||||||
"EastmoneyChartClient": {"backend/data/gateway.py"},
|
|
||||||
"IfindHttpClient": {"backend/data/gateway.py"},
|
|
||||||
"IfindProvider": {"backend/data/gateway.py"},
|
"IfindProvider": {"backend/data/gateway.py"},
|
||||||
"MarketChartClient": {"backend/data/gateway.py"},
|
"MarketChartClient": {"backend/data/gateway.py"},
|
||||||
"TushareClient": {"backend/features/market/service.py"},
|
"TushareClient": set(),
|
||||||
"TushareProvider": {"backend/data/gateway.py"},
|
|
||||||
"WebRealtimeAggregator": {"backend/data/gateway.py"},
|
|
||||||
"DatahubClient": {"backend/data/gateway.py"},
|
"DatahubClient": {"backend/data/gateway.py"},
|
||||||
"DatahubAwareTushareClient": {"backend/data/gateway.py"},
|
"DatahubAwareTushareClient": {"backend/data/gateway.py"},
|
||||||
"DatahubBridge": {"backend/data/gateway.py"},
|
"DatahubBridge": {"backend/data/gateway.py"},
|
||||||
|
"HubIfindProxy": {"backend/data/gateway.py"},
|
||||||
|
"HubRealtimeProxy": {"backend/data/gateway.py"},
|
||||||
}
|
}
|
||||||
found = {name: set() for name in owners}
|
found = {name: set() for name in owners}
|
||||||
|
forbidden = {
|
||||||
|
"IfindHttpClient": set(),
|
||||||
|
"EastmoneyChartClient": set(),
|
||||||
|
"WebRealtimeAggregator": set(),
|
||||||
|
"TushareProvider": set(),
|
||||||
|
}
|
||||||
|
found_forbidden = {name: set() for name in forbidden}
|
||||||
for path in (root / "backend").rglob("*.py"):
|
for path in (root / "backend").rglob("*.py"):
|
||||||
relative = path.relative_to(root).as_posix()
|
relative = path.relative_to(root).as_posix()
|
||||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
@@ -81,7 +101,10 @@ class DataGatewayTests(unittest.TestCase):
|
|||||||
name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
|
name = getattr(node.func, "id", None) or getattr(node.func, "attr", None)
|
||||||
if name in found:
|
if name in found:
|
||||||
found[name].add(relative)
|
found[name].add(relative)
|
||||||
|
if name in found_forbidden:
|
||||||
|
found_forbidden[name].add(relative)
|
||||||
self.assertEqual(found, owners)
|
self.assertEqual(found, owners)
|
||||||
|
self.assertEqual(found_forbidden, forbidden)
|
||||||
provider_source = (root / "backend/data/providers/tushare.py").read_text(
|
provider_source = (root / "backend/data/providers/tushare.py").read_text(
|
||||||
encoding="utf-8"
|
encoding="utf-8"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
("0003", "extend_llm_audit"),
|
("0003", "extend_llm_audit"),
|
||||||
("0004", "add_mentor_note"),
|
("0004", "add_mentor_note"),
|
||||||
("0005", "create_account_switch_grants"),
|
("0005", "create_account_switch_grants"),
|
||||||
|
("0006", "create_invite_codes"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
columns = {
|
columns = {
|
||||||
@@ -40,7 +41,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
count = connection.execute(
|
count = connection.execute(
|
||||||
"SELECT COUNT(*) AS count FROM schema_migrations"
|
"SELECT COUNT(*) AS count FROM schema_migrations"
|
||||||
).fetchone()["count"]
|
).fetchone()["count"]
|
||||||
self.assertEqual(count, 5)
|
self.assertEqual(count, 6)
|
||||||
|
|
||||||
def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
|
def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
|
||||||
self,
|
self,
|
||||||
@@ -61,7 +62,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
count = connection.execute(
|
count = connection.execute(
|
||||||
"SELECT COUNT(*) AS count FROM schema_migrations"
|
"SELECT COUNT(*) AS count FROM schema_migrations"
|
||||||
).fetchone()["count"]
|
).fetchone()["count"]
|
||||||
self.assertEqual(count, 5)
|
self.assertEqual(count, 6)
|
||||||
|
|
||||||
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
|
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as root:
|
with tempfile.TemporaryDirectory() as root:
|
||||||
@@ -88,7 +89,7 @@ class DatabaseMigrationTests(unittest.TestCase):
|
|||||||
"PRAGMA table_info(mentor_preferences)"
|
"PRAGMA table_info(mentor_preferences)"
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005"})
|
self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005", "0006"})
|
||||||
self.assertIn("note", note_rows)
|
self.assertIn("note", note_rows)
|
||||||
|
|
||||||
def test_database_with_unknown_migration_is_rejected(self) -> None:
|
def test_database_with_unknown_migration_is_rejected(self) -> None:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from backend.data.datahub.compare import compare_rows
|
|||||||
from backend.data.datahub.errors import DatahubError
|
from backend.data.datahub.errors import DatahubError
|
||||||
from backend.data.datahub.native import to_canonical_row, to_native_row
|
from backend.data.datahub.native import to_canonical_row, to_native_row
|
||||||
from backend.data.datahub.route_state import LEDGER
|
from backend.data.datahub.route_state import LEDGER
|
||||||
|
from backend.data.providers.tushare_transport import TushareError
|
||||||
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -68,9 +69,16 @@ class FakeClient(DatahubClient):
|
|||||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
|
def get(self, path: str, params: dict[str, Any] | None = None) -> DatahubResponse:
|
||||||
|
return self._record(path, params)
|
||||||
|
|
||||||
|
def post(self, path: str, body: dict[str, Any] | None = None) -> DatahubResponse:
|
||||||
|
return self._record(path, body)
|
||||||
|
|
||||||
|
def _record(self, path: str, payload: dict[str, Any] | None) -> DatahubResponse:
|
||||||
self.paths.append(path)
|
self.paths.append(path)
|
||||||
self.calls.append((path, {key: value for key, value in (params or {}).items()}))
|
self.calls.append((path, {key: value for key, value in (payload or {}).items()}))
|
||||||
if TOKEN in json.dumps(params or {}) or TOKEN in path:
|
packed = json.dumps(payload or {})
|
||||||
|
if TOKEN in packed or TOKEN in path:
|
||||||
raise AssertionError("token leaked into url")
|
raise AssertionError("token leaked into url")
|
||||||
if self.error:
|
if self.error:
|
||||||
raise self.error
|
raise self.error
|
||||||
@@ -131,16 +139,22 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
self.assertEqual(legacy.calls, [])
|
self.assertEqual(legacy.calls, [])
|
||||||
self.assertEqual(client.paths, ["/v1/bars/daily"])
|
self.assertEqual(client.paths, ["/v1/bars/daily"])
|
||||||
calendar_legacy = FakeLegacy([{"cal_date": "20240902", "is_open": 1}])
|
calendar_legacy = FakeLegacy([{"cal_date": "20240902", "is_open": 1}])
|
||||||
calendar_client = FakeClient(error=DatahubError("INTERNAL", "nope"))
|
calendar_client = FakeClient(
|
||||||
|
response=DatahubResponse(
|
||||||
|
data=[{"cal_date": "20240902", "is_open": 1, "pretrade_date": "20240830"}],
|
||||||
|
meta={"source": "datahub", "stale": False, "staleness_seconds": 0},
|
||||||
|
)
|
||||||
|
)
|
||||||
calendar_wrapped = DatahubAwareTushareClient(
|
calendar_wrapped = DatahubAwareTushareClient(
|
||||||
calendar_legacy,
|
calendar_legacy,
|
||||||
DatahubBridge(flags(daily=(True, False)), calendar_client),
|
DatahubBridge(flags(daily=(True, False)), calendar_client),
|
||||||
)
|
)
|
||||||
calendar = calendar_wrapped.query("trade_cal", {"start_date": "20240902", "end_date": "20240902"}, "")
|
calendar = calendar_wrapped.query("trade_cal", {"start_date": "20240902", "end_date": "20240902"}, "")
|
||||||
self.assertEqual(calendar[0]["is_open"], 1)
|
self.assertEqual(calendar[0]["is_open"], 1)
|
||||||
self.assertEqual(calendar_client.paths, [])
|
self.assertEqual(calendar_legacy.calls, [])
|
||||||
|
self.assertEqual(calendar_client.paths, ["/v1/query"])
|
||||||
|
|
||||||
def test_fallback_on_down_401_timeout_empty_unpublished_stale_and_incomplete(self) -> None:
|
def test_hub_failure_does_not_call_website_legacy(self) -> None:
|
||||||
cases = [
|
cases = [
|
||||||
DatahubError("UNAVAILABLE", "down"),
|
DatahubError("UNAVAILABLE", "down"),
|
||||||
DatahubError("UNAUTHORIZED", "401"),
|
DatahubError("UNAUTHORIZED", "401"),
|
||||||
@@ -152,34 +166,21 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
for error in cases:
|
for error in cases:
|
||||||
with self.subTest(error=error.code):
|
with self.subTest(error=error.code):
|
||||||
if error.code == "EMPTY":
|
|
||||||
client = FakeClient(response=DatahubResponse(data=[], meta={"stale": False, "staleness_seconds": 0}))
|
|
||||||
elif error.code == "STALE":
|
|
||||||
client = FakeClient(response=DatahubResponse(
|
|
||||||
data=[dict(HUB_DAILY)],
|
|
||||||
meta={"stale": True, "staleness_seconds": 999999},
|
|
||||||
))
|
|
||||||
elif error.code == "INCOMPLETE":
|
|
||||||
client = FakeClient(response=DatahubResponse(
|
|
||||||
data=[dict(HUB_DAILY)],
|
|
||||||
meta={
|
|
||||||
"stale": False,
|
|
||||||
"staleness_seconds": 0,
|
|
||||||
"incomplete": True,
|
|
||||||
"coverage": {"complete": False, "missing_count": 80},
|
|
||||||
},
|
|
||||||
))
|
|
||||||
else:
|
|
||||||
client = FakeClient(error=error)
|
client = FakeClient(error=error)
|
||||||
legacy = FakeLegacy([LEGACY_DAILY])
|
legacy = FakeLegacy([LEGACY_DAILY])
|
||||||
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(flags(daily=(True, False)), client))
|
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(flags(daily=(True, False)), client))
|
||||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
with self.assertRaises(TushareError):
|
||||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
||||||
self.assertEqual(len(legacy.calls), 1)
|
self.assertEqual(legacy.calls, [])
|
||||||
|
|
||||||
def test_shadow_compares_without_replacing_and_survives_hub_failure(self) -> None:
|
def test_shadow_mode_no_longer_calls_website_tushare(self) -> None:
|
||||||
reports: list[dict[str, Any]] = []
|
reports: list[dict[str, Any]] = []
|
||||||
client = FakeClient()
|
client = FakeClient(
|
||||||
|
response=DatahubResponse(
|
||||||
|
data=[dict(LEGACY_DAILY)],
|
||||||
|
meta={"source": "tushare", "stale": False, "staleness_seconds": 0, "row_shape": "tushare"},
|
||||||
|
)
|
||||||
|
)
|
||||||
legacy = FakeLegacy([LEGACY_DAILY])
|
legacy = FakeLegacy([LEGACY_DAILY])
|
||||||
wrapped = DatahubAwareTushareClient(
|
wrapped = DatahubAwareTushareClient(
|
||||||
legacy,
|
legacy,
|
||||||
@@ -187,21 +188,19 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount,vol")
|
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount,vol")
|
||||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
self.assertEqual(rows[0]["amount"], 2000.0)
|
||||||
self.assertEqual(len(legacy.calls), 1)
|
self.assertEqual(legacy.calls, [])
|
||||||
self.assertEqual(reports[0]["equal"], True)
|
self.assertEqual(client.paths, ["/v1/query"])
|
||||||
self.assertEqual(reports[0]["matched"], 1)
|
|
||||||
|
|
||||||
failed = FakeClient(error=DatahubError("UNAVAILABLE", TOKEN))
|
failed = FakeClient(error=DatahubError("UNAVAILABLE", TOKEN))
|
||||||
fail_reports: list[dict[str, Any]] = []
|
|
||||||
fail_legacy = FakeLegacy([LEGACY_DAILY])
|
fail_legacy = FakeLegacy([LEGACY_DAILY])
|
||||||
fail_wrapped = DatahubAwareTushareClient(
|
fail_wrapped = DatahubAwareTushareClient(
|
||||||
fail_legacy,
|
fail_legacy,
|
||||||
DatahubBridge(flags(daily=(False, True)), failed, shadow_sink=fail_reports.append),
|
DatahubBridge(flags(daily=(False, True)), failed, shadow_sink=reports.append),
|
||||||
)
|
)
|
||||||
again = fail_wrapped.query("daily", {"trade_date": "20240902"}, "amount")
|
with self.assertRaises(TushareError):
|
||||||
self.assertEqual(again[0]["amount"], 2000.0)
|
fail_wrapped.query("daily", {"trade_date": "20240902"}, "amount")
|
||||||
self.assertTrue(fail_reports[0]["hub_error"])
|
self.assertEqual(fail_legacy.calls, [])
|
||||||
self.assertNotIn(TOKEN, json.dumps(fail_reports[0]))
|
self.assertNotIn(TOKEN, str(failed.calls))
|
||||||
|
|
||||||
def test_compare_classifies_unit_conversion_missing_row_and_value_diff(self) -> None:
|
def test_compare_classifies_unit_conversion_missing_row_and_value_diff(self) -> None:
|
||||||
equal = compare_rows("daily", [LEGACY_DAILY], [HUB_DAILY], {"stale": False, "staleness_seconds": 0})
|
equal = compare_rows("daily", [LEGACY_DAILY], [HUB_DAILY], {"stale": False, "staleness_seconds": 0})
|
||||||
@@ -288,13 +287,12 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
wrapped = DatahubAwareTushareClient(
|
wrapped = DatahubAwareTushareClient(
|
||||||
FakeLegacy([legacy_close_only]),
|
FakeLegacy([legacy_close_only]),
|
||||||
DatahubBridge(flags(daily=(False, True)), client, shadow_sink=reports.append),
|
DatahubBridge(flags(daily=(True, False)), client, shadow_sink=reports.append),
|
||||||
)
|
)
|
||||||
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,trade_date,close,vol,amount")
|
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,trade_date,close,vol,amount")
|
||||||
self.assertEqual(rows[0]["close"], 10.20)
|
self.assertEqual(rows[0]["close"], 10.20)
|
||||||
self.assertEqual(rows[0]["vol"], 1000.0)
|
self.assertEqual(rows[0]["vol"], 1000.0)
|
||||||
self.assertTrue(reports[0]["equal"])
|
self.assertEqual(client.paths, ["/v1/bars/daily"])
|
||||||
self.assertEqual(reports[0]["matched"], 1)
|
|
||||||
|
|
||||||
def test_native_roundtrip_matches_known_scales(self) -> None:
|
def test_native_roundtrip_matches_known_scales(self) -> None:
|
||||||
native = to_native_row("daily", HUB_DAILY)
|
native = to_native_row("daily", HUB_DAILY)
|
||||||
@@ -347,21 +345,17 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
self.assertIn('"daily"', source)
|
self.assertIn('"daily"', source)
|
||||||
self.assertIn("start_date", source)
|
self.assertIn("start_date", source)
|
||||||
self.assertIn("end_date", source)
|
self.assertIn("end_date", source)
|
||||||
client = FakeClient(
|
client = FakeClient(error=DatahubError("INCOMPLETE", "truncated"))
|
||||||
response=DatahubResponse(
|
|
||||||
data=[dict(HUB_DAILY)],
|
|
||||||
meta={"stale": False, "staleness_seconds": 0, "incomplete": True, "coverage": {"complete": False, "missing_count": 89}},
|
|
||||||
)
|
|
||||||
)
|
|
||||||
legacy = FakeLegacy([LEGACY_DAILY])
|
legacy = FakeLegacy([LEGACY_DAILY])
|
||||||
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(flags(daily=(True, False)), client))
|
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(flags(daily=(True, False)), client))
|
||||||
rows = wrapped.query(
|
with self.assertRaises(TushareError):
|
||||||
|
wrapped.query(
|
||||||
"daily",
|
"daily",
|
||||||
{"ts_code": "600000.SH", "start_date": "20240301", "end_date": "20240902"},
|
{"ts_code": "600000.SH", "start_date": "20240301", "end_date": "20240902"},
|
||||||
"ts_code,amount",
|
"ts_code,amount",
|
||||||
)
|
)
|
||||||
self.assertEqual(rows[0]["amount"], 2000.0)
|
self.assertEqual(legacy.calls, [])
|
||||||
self.assertEqual(len(legacy.calls), 1)
|
self.assertIn("/v1/query", client.paths)
|
||||||
|
|
||||||
def test_try_intraday_respects_switch_and_falls_back_on_bad_payload(self) -> None:
|
def test_try_intraday_respects_switch_and_falls_back_on_bad_payload(self) -> None:
|
||||||
closed = DatahubBridge(flags(), FakeClient(error=DatahubError("INTERNAL", "should not run")))
|
closed = DatahubBridge(flags(), FakeClient(error=DatahubError("INTERNAL", "should not run")))
|
||||||
@@ -462,17 +456,15 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
FakeClient(error=DatahubError("UNAVAILABLE", "down")),
|
FakeClient(error=DatahubError("UNAVAILABLE", "down")),
|
||||||
)
|
)
|
||||||
self.assertIsNone(failed.try_market_quotes("20240902"))
|
self.assertIsNone(failed.try_market_quotes("20240902"))
|
||||||
failed.record_legacy("quotes", "tencent_qt", "down")
|
|
||||||
snap = next(item for item in LEDGER.snapshot() if item["dataset"] == "quotes")
|
snap = next(item for item in LEDGER.snapshot() if item["dataset"] == "quotes")
|
||||||
self.assertEqual(snap["route"], "legacy")
|
self.assertEqual(snap["route"], "datahub")
|
||||||
self.assertEqual(snap["source"], "tencent_qt")
|
self.assertEqual(snap["source"], "unavailable")
|
||||||
self.assertIn("备用", "备用")
|
|
||||||
|
|
||||||
gateway = build_data_gateway({}, datahub_settings=flags(quotes=(True, False)))
|
gateway = build_data_gateway({}, datahub_settings=flags(quotes=(True, False)))
|
||||||
status = gateway.datahub_status()
|
status = gateway.datahub_status()
|
||||||
self.assertEqual(status["enabled_reads"], 1)
|
self.assertEqual(status["enabled_reads"], 1)
|
||||||
self.assertEqual(status["total_reads"], len(DATASETS))
|
self.assertEqual(status["total_reads"], len(DATASETS))
|
||||||
self.assertGreaterEqual(status["fallback_count"], 1)
|
self.assertEqual(status["fallback_count"], 0)
|
||||||
|
|
||||||
def test_try_daily_chart_converts_hub_bars(self) -> None:
|
def test_try_daily_chart_converts_hub_bars(self) -> None:
|
||||||
rows = [
|
rows = [
|
||||||
@@ -554,7 +546,7 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
self.assertEqual(chart[-1]["trade_date"], "2024-09-02")
|
self.assertEqual(chart[-1]["trade_date"], "2024-09-02")
|
||||||
self.assertEqual(chart[-1]["close"], 10.4)
|
self.assertEqual(chart[-1]["close"], 10.4)
|
||||||
|
|
||||||
def test_gateway_tushare_assembly_binds_hooks_on_inner_client(self) -> None:
|
def test_gateway_tushare_facade_has_no_legacy_client(self) -> None:
|
||||||
quotes = [
|
quotes = [
|
||||||
{
|
{
|
||||||
"ts_code": f"{index:06d}.SZ",
|
"ts_code": f"{index:06d}.SZ",
|
||||||
@@ -582,21 +574,20 @@ class DatahubBridgeTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
gateway.datahub.client = hub_client
|
gateway.datahub.client = hub_client
|
||||||
wrapped = gateway.tushare()
|
wrapped = gateway.tushare()
|
||||||
inner = wrapped._legacy
|
self.assertFalse(hasattr(wrapped, "_legacy"))
|
||||||
self.assertTrue(callable(getattr(inner, "try_market_quotes", None)))
|
self.assertIsNone(getattr(type(wrapped), "__getattr__", None))
|
||||||
self.assertTrue(callable(getattr(inner, "try_index_quotes", None)))
|
self.assertTrue(callable(getattr(type(wrapped), "try_market_quotes", None)))
|
||||||
self.assertTrue(callable(getattr(inner, "record_datahub_legacy", None)))
|
self.assertTrue(callable(getattr(type(wrapped), "try_index_quotes", None)))
|
||||||
self.assertIs(inner.query.__self__, wrapped)
|
self.assertTrue(callable(getattr(type(wrapped), "record_datahub_legacy", None)))
|
||||||
self.assertEqual(inner.query.__func__, wrapped.query.__func__)
|
self.assertTrue(callable(getattr(type(wrapped), "dashboard", None)))
|
||||||
self.assertFalse(hasattr(type(inner), "try_market_quotes"))
|
rows = wrapped.try_market_quotes("20240902")
|
||||||
rows = inner.try_market_quotes("20240902")
|
|
||||||
self.assertGreaterEqual(len(rows or []), 200)
|
self.assertGreaterEqual(len(rows or []), 200)
|
||||||
self.assertIn("/v1/quotes/latest", hub_client.paths)
|
self.assertIn("/v1/quotes/latest", hub_client.paths)
|
||||||
hub_client.response = DatahubResponse(
|
hub_client.response = DatahubResponse(
|
||||||
data=[dict(HUB_DAILY)],
|
data=[dict(HUB_DAILY)],
|
||||||
meta={"stale": False, "staleness_seconds": 0, "source": "tushare:daily"},
|
meta={"stale": False, "staleness_seconds": 0, "source": "tushare:daily"},
|
||||||
)
|
)
|
||||||
daily = inner.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
daily = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
||||||
self.assertEqual(daily[0]["amount"], 2000.0)
|
self.assertEqual(daily[0]["amount"], 2000.0)
|
||||||
self.assertIn("/v1/bars/daily", hub_client.paths)
|
self.assertIn("/v1/bars/daily", hub_client.paths)
|
||||||
|
|
||||||
|
|||||||
@@ -459,6 +459,19 @@ class FrontendContractTests(unittest.TestCase):
|
|||||||
self.assertIn('payload.question_preset = state.heartQuestionPreset;', self.script)
|
self.assertIn('payload.question_preset = state.heartQuestionPreset;', self.script)
|
||||||
self.assertIn('payload.cast_at = state.heartCastAt;', self.script)
|
self.assertIn('payload.cast_at = state.heartCastAt;', self.script)
|
||||||
|
|
||||||
|
def test_heaven_loading_timeout_clears_dimmed_state(self):
|
||||||
|
self.assertIn("controller.abort()", self.script)
|
||||||
|
self.assertIn('heavenView?.classList.remove("heaven-data-loading")', self.script)
|
||||||
|
self.assertIn("问天数据仍在准备,页面可继续输入和操作", self.script)
|
||||||
|
self.assertIn("const blocking = !state.heavenSetup;", self.script)
|
||||||
|
self.assertIn("payload?.aborted", self.script)
|
||||||
|
|
||||||
|
def test_stock_detail_does_not_display_missing_metrics_as_zero(self):
|
||||||
|
self.assertIn("function setStockBoardFields(row)", self.script)
|
||||||
|
self.assertIn("function presentMetric(value)", self.script)
|
||||||
|
self.assertIn("payload.available !== false", self.script)
|
||||||
|
self.assertIn('element.textContent = "--"', self.script)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ class GlobalSearchTests(unittest.TestCase):
|
|||||||
self.assertIn('event.key.toLowerCase() !== "k"', script)
|
self.assertIn('event.key.toLowerCase() !== "k"', script)
|
||||||
self.assertIn('openStock(item.id, { code: item.code', script)
|
self.assertIn('openStock(item.id, { code: item.code', script)
|
||||||
self.assertNotIn('include_notes', script)
|
self.assertNotIn('include_notes', script)
|
||||||
self.assertIn('const candles = (series || [])', script)
|
self.assertIn('const candles = visibleDailyPrices((series || [])', script)
|
||||||
self.assertIn('renderStockNotes(payload.notes || [])', script)
|
self.assertIn('renderStockNotes(payload.notes || [])', script)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from backend.data.providers.tushare_client import TushareClient, TushareError
|
||||||
|
from backend.data.providers.tushare_helpers import _moneyflow_payload
|
||||||
|
from backend.data.realtime import (
|
||||||
|
WebRealtimeAggregator,
|
||||||
|
_normalize_eastmoney_limit_row,
|
||||||
|
_normalize_eastmoney_stock_quote,
|
||||||
|
)
|
||||||
|
from backend.data.providers.tushare_daily import DailyMarketMixin
|
||||||
|
|
||||||
|
|
||||||
|
class MoneyflowPayloadTests(unittest.TestCase):
|
||||||
|
def test_missing_row_is_not_zero(self) -> None:
|
||||||
|
payload = _moneyflow_payload(None)
|
||||||
|
self.assertFalse(payload["available"])
|
||||||
|
self.assertIsNone(payload["net_million"])
|
||||||
|
self.assertIsNone(payload["large_million"])
|
||||||
|
|
||||||
|
def test_empty_row_is_not_zero(self) -> None:
|
||||||
|
payload = _moneyflow_payload({})
|
||||||
|
self.assertFalse(payload["available"])
|
||||||
|
self.assertIsNone(payload["net_million"])
|
||||||
|
|
||||||
|
def test_real_zero_net_is_kept_when_source_exists(self) -> None:
|
||||||
|
payload = _moneyflow_payload(
|
||||||
|
{
|
||||||
|
"net_mf_amount": 0,
|
||||||
|
"buy_lg_amount": 1,
|
||||||
|
"sell_lg_amount": 1,
|
||||||
|
"buy_elg_amount": 0,
|
||||||
|
"sell_elg_amount": 0,
|
||||||
|
"buy_md_amount": 0,
|
||||||
|
"sell_md_amount": 0,
|
||||||
|
"buy_sm_amount": 0,
|
||||||
|
"sell_sm_amount": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertTrue(payload["available"])
|
||||||
|
self.assertEqual(payload["net_million"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
class LimitOverlayTests(unittest.TestCase):
|
||||||
|
def test_normalize_limit_keeps_missing_seal_as_none(self) -> None:
|
||||||
|
row = DailyMarketMixin._normalize_limit(
|
||||||
|
{
|
||||||
|
"ts_code": "000737.SZ",
|
||||||
|
"name": "北方铜业",
|
||||||
|
"close": 12.3,
|
||||||
|
"pct_chg": 10,
|
||||||
|
"amount": 1e8,
|
||||||
|
"amount_unit": "yuan",
|
||||||
|
},
|
||||||
|
"涨停",
|
||||||
|
)
|
||||||
|
self.assertIsNone(row["seal_amount_million"])
|
||||||
|
self.assertEqual(row["first_time"], "--")
|
||||||
|
|
||||||
|
def test_overlay_fills_board_times_from_official_list(self) -> None:
|
||||||
|
mixin = DailyMarketMixin()
|
||||||
|
mixin._load_limit_lists = lambda trade_date: [
|
||||||
|
{
|
||||||
|
"ts_code": "000737.SZ",
|
||||||
|
"first_time": "09:31:02",
|
||||||
|
"last_time": "10:18:11",
|
||||||
|
"fd_amount": 82000000,
|
||||||
|
"open_times": 1,
|
||||||
|
"turnover_ratio": 18.4,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mixin.realtime_aggregator = None
|
||||||
|
rows = mixin._overlay_board_fields(
|
||||||
|
[{"ts_code": "000737.SZ", "close": 12.3, "limit_type": "U"}],
|
||||||
|
"20260908",
|
||||||
|
)
|
||||||
|
self.assertEqual(rows[0]["first_time"], "09:31:02")
|
||||||
|
self.assertEqual(rows[0]["fd_amount"], 82000000)
|
||||||
|
self.assertEqual(rows[0]["turnover_ratio"], 18.4)
|
||||||
|
|
||||||
|
def test_overlay_replaces_provisional_first_board_with_observed_streak(self) -> None:
|
||||||
|
mixin = DailyMarketMixin()
|
||||||
|
mixin._load_limit_lists = lambda trade_date: []
|
||||||
|
mixin.try_limit_pool = lambda trade_date: [
|
||||||
|
{"ts_code": "000737.SZ", "limit_times": 3}
|
||||||
|
]
|
||||||
|
|
||||||
|
rows = mixin._overlay_board_fields(
|
||||||
|
[{"ts_code": "000737.SZ", "limit_times": 1, "limit_type": "U"}],
|
||||||
|
"20260909",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(rows[0]["limit_times"], 3)
|
||||||
|
|
||||||
|
def test_daily_fallback_extends_yesterday_streak(self) -> None:
|
||||||
|
mixin = DailyMarketMixin()
|
||||||
|
mixin.query = lambda *args, **kwargs: []
|
||||||
|
mixin._load_limit_lists = lambda trade_date: []
|
||||||
|
|
||||||
|
rows = mixin._derive_limits(
|
||||||
|
"20260909",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"ts_code": "000737.SZ",
|
||||||
|
"trade_date": "20260909",
|
||||||
|
"close": 11.0,
|
||||||
|
"high": 11.0,
|
||||||
|
"pct_chg": 10.0,
|
||||||
|
"amount": 100000,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
price_limits=[
|
||||||
|
{"ts_code": "000737.SZ", "up_limit": 11.0, "down_limit": 9.0}
|
||||||
|
],
|
||||||
|
basic_rows=[
|
||||||
|
{"ts_code": "000737.SZ", "name": "北方铜业", "industry": "有色"}
|
||||||
|
],
|
||||||
|
previous_limit_rows=[
|
||||||
|
{"ts_code": "000737.SZ", "limit_times": 2}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(rows[0]["limit_times"], 3)
|
||||||
|
|
||||||
|
|
||||||
|
class ShenwanRealtimeSourceTests(unittest.TestCase):
|
||||||
|
def test_transport_refuses_rt_sw_k(self) -> None:
|
||||||
|
client = TushareClient(token="demo")
|
||||||
|
with self.assertRaisesRegex(TushareError, "rt_sw_k is disabled"):
|
||||||
|
client.query("rt_sw_k", {"ts_code": "801074.SI"})
|
||||||
|
|
||||||
|
def test_outer_realtime_uses_hub_sector_quote_not_rt_sw_k(self) -> None:
|
||||||
|
client = TushareClient(token="demo")
|
||||||
|
client.query = MagicMock(side_effect=AssertionError("should not call tushare"))
|
||||||
|
client.try_sector_quote = MagicMock(return_value={
|
||||||
|
"code": "801074.SI",
|
||||||
|
"name": "工业金属",
|
||||||
|
"close": 1234.5,
|
||||||
|
"pre_close": 1200,
|
||||||
|
"change": 2.88,
|
||||||
|
"pct_change": 2.88,
|
||||||
|
"quote_date": "20260908",
|
||||||
|
"quote_time": "2026-09-08T14:50:00+08:00",
|
||||||
|
"source": "eastmoney_sw",
|
||||||
|
})
|
||||||
|
row, source, error = client._sw_outer_realtime("801074.SI", "工业金属", "20260908")
|
||||||
|
self.assertEqual(source, "eastmoney_sw")
|
||||||
|
self.assertEqual(error, "")
|
||||||
|
self.assertEqual(row["change"], 2.88)
|
||||||
|
client.query.assert_not_called()
|
||||||
|
|
||||||
|
def test_outer_waiting_state_has_no_permission_error(self) -> None:
|
||||||
|
client = TushareClient(token="demo")
|
||||||
|
client.realtime_aggregator = None
|
||||||
|
row, source, error = client._sw_outer_realtime(
|
||||||
|
"801074.SI", "工业金属", "20260908", finalized=True
|
||||||
|
)
|
||||||
|
self.assertEqual(row, {})
|
||||||
|
self.assertIn("尚未入库", error)
|
||||||
|
self.assertNotIn("权限", error)
|
||||||
|
self.assertNotIn("rt_sw_k", error)
|
||||||
|
|
||||||
|
|
||||||
|
class EastmoneyHelperTests(unittest.TestCase):
|
||||||
|
def test_limit_pool_row_keeps_board_clock(self) -> None:
|
||||||
|
parsed = _normalize_eastmoney_limit_row(
|
||||||
|
{
|
||||||
|
"c": "000737",
|
||||||
|
"m": 0,
|
||||||
|
"n": "北方铜业",
|
||||||
|
"fbt": 93102,
|
||||||
|
"lbt": 101811,
|
||||||
|
"zbc": 1,
|
||||||
|
"lbc": 2,
|
||||||
|
"hs": 18.4,
|
||||||
|
"fund": 82000000,
|
||||||
|
},
|
||||||
|
"U",
|
||||||
|
)
|
||||||
|
self.assertEqual(parsed["ts_code"], "000737.SZ")
|
||||||
|
self.assertEqual(parsed["first_time"], "09:31:02")
|
||||||
|
self.assertEqual(parsed["last_time"], "10:18:11")
|
||||||
|
self.assertEqual(parsed["fd_amount"], 82000000)
|
||||||
|
|
||||||
|
def test_stock_quote_keeps_moneyflow_when_present(self) -> None:
|
||||||
|
quote = _normalize_eastmoney_stock_quote(
|
||||||
|
{
|
||||||
|
"f43": 12.3,
|
||||||
|
"f60": 11.18,
|
||||||
|
"f46": 11.2,
|
||||||
|
"f44": 12.3,
|
||||||
|
"f45": 11.1,
|
||||||
|
"f47": 1000,
|
||||||
|
"f48": 150000000,
|
||||||
|
"f58": "北方铜业",
|
||||||
|
"f86": 0,
|
||||||
|
"f168": 8.5,
|
||||||
|
"f62": 25000000,
|
||||||
|
"f78": 3000000,
|
||||||
|
"f84": -1000000,
|
||||||
|
},
|
||||||
|
"000737.SZ",
|
||||||
|
)
|
||||||
|
self.assertEqual(quote["net_mf_amount"], 2500)
|
||||||
|
payload = _moneyflow_payload(quote)
|
||||||
|
self.assertTrue(payload["available"])
|
||||||
|
self.assertEqual(payload["net_million"], 25)
|
||||||
|
|
||||||
|
@patch.object(WebRealtimeAggregator, "_get_json")
|
||||||
|
def test_shenwan_quote_uses_eastmoney_90_prefix(self, get_json: MagicMock) -> None:
|
||||||
|
get_json.return_value = {
|
||||||
|
"rc": 0,
|
||||||
|
"data": {
|
||||||
|
"diff": [
|
||||||
|
{
|
||||||
|
"f12": "801074",
|
||||||
|
"f14": "工业金属",
|
||||||
|
"f2": 1234.5,
|
||||||
|
"f3": 2.88,
|
||||||
|
"f18": 1200,
|
||||||
|
"f17": 1205,
|
||||||
|
"f15": 1240,
|
||||||
|
"f16": 1198,
|
||||||
|
"f6": 1,
|
||||||
|
"f124": 1757319000,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
quote = WebRealtimeAggregator().eastmoney_shenwan_quote("801074.SI")
|
||||||
|
self.assertEqual(quote["source"], "eastmoney_sw")
|
||||||
|
self.assertAlmostEqual(quote["change"], 2.88)
|
||||||
|
params = get_json.call_args.args[1]
|
||||||
|
self.assertEqual(params["secids"], "90.801074")
|
||||||
|
|
||||||
|
|
||||||
|
class ChartWindowTests(unittest.TestCase):
|
||||||
|
def test_display_window_is_45_not_250(self) -> None:
|
||||||
|
from backend.features.market.charts import DAILY_CHART_LIMIT
|
||||||
|
|
||||||
|
self.assertEqual(DAILY_CHART_LIMIT, 45)
|
||||||
|
|
||||||
|
|
||||||
|
class MemberQuoteCoverageTests(unittest.TestCase):
|
||||||
|
def test_prefers_full_hub_market_over_truncated_named_quotes(self) -> None:
|
||||||
|
client = TushareClient(token="demo")
|
||||||
|
wanted = [f"{index:06d}.SZ" for index in range(205)]
|
||||||
|
market = [
|
||||||
|
{"ts_code": code, "close": 10.0, "pre_close": 9.0}
|
||||||
|
for code in wanted
|
||||||
|
]
|
||||||
|
client.try_market_quotes = MagicMock(return_value=market)
|
||||||
|
client.try_quotes = MagicMock(return_value=market[:60])
|
||||||
|
client.realtime_aggregator = MagicMock()
|
||||||
|
rows, source = client._load_member_realtime_quotes(wanted, "20260908")
|
||||||
|
self.assertEqual(len(rows), 205)
|
||||||
|
self.assertEqual(source, "datahub")
|
||||||
|
client.try_quotes.assert_not_called()
|
||||||
|
|
||||||
|
def test_hub_named_quotes_cover_members_when_market_missing(self) -> None:
|
||||||
|
client = TushareClient(token="demo")
|
||||||
|
wanted = ["000737.SZ", "000630.SZ"]
|
||||||
|
client.try_market_quotes = MagicMock(return_value=None)
|
||||||
|
client.try_quotes = MagicMock(return_value=[
|
||||||
|
{"ts_code": "000737.SZ", "close": 12.3, "pre_close": 11.2},
|
||||||
|
{"ts_code": "000630.SZ", "close": 4.5, "pre_close": 4.4},
|
||||||
|
])
|
||||||
|
client.realtime_aggregator = MagicMock()
|
||||||
|
rows, source = client._load_member_realtime_quotes(wanted, "20260908")
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(source, "datahub")
|
||||||
|
client.try_quotes.assert_called()
|
||||||
|
client.realtime_aggregator.eastmoney_stock_quotes.assert_not_called()
|
||||||
|
client.realtime_aggregator.tencent_stock_quotes.assert_not_called()
|
||||||
|
|
||||||
|
def test_delayed_hub_quotes_are_kept_not_zeroed(self) -> None:
|
||||||
|
client = TushareClient(token="demo")
|
||||||
|
delayed = [
|
||||||
|
{
|
||||||
|
"ts_code": "000737.SZ",
|
||||||
|
"close": 12.3,
|
||||||
|
"pre_close": 11.2,
|
||||||
|
"delayed": True,
|
||||||
|
"delay_seconds": 90,
|
||||||
|
"delay_notice": "主备免费行情均暂不可用,显示 90 秒前的真实快照",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
client.try_market_quotes = MagicMock(return_value=delayed)
|
||||||
|
client.try_quotes = MagicMock()
|
||||||
|
client.realtime_aggregator = MagicMock()
|
||||||
|
rows, source = client._load_member_realtime_quotes(["000737.SZ"], "20260908")
|
||||||
|
self.assertEqual(source, "datahub_delayed")
|
||||||
|
self.assertEqual(rows[0]["close"], 12.3)
|
||||||
|
client.try_quotes.assert_not_called()
|
||||||
|
|
||||||
|
def test_ignores_non_member_quotes_from_market_snapshot(self) -> None:
|
||||||
|
client = TushareClient(token="demo")
|
||||||
|
client.try_market_quotes = MagicMock(
|
||||||
|
return_value=[
|
||||||
|
{"ts_code": "000737.SZ", "close": 12.3, "pre_close": 11.2},
|
||||||
|
{"ts_code": "600000.SH", "close": 10.0, "pre_close": 9.9},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
client.try_quotes = MagicMock(return_value=[])
|
||||||
|
client._free_realtime_quotes = MagicMock(return_value=([], "empty"))
|
||||||
|
rows, _source = client._load_member_realtime_quotes(
|
||||||
|
["000737.SZ", "000630.SZ"], "20260908"
|
||||||
|
)
|
||||||
|
self.assertEqual([row["ts_code"] for row in rows], ["000737.SZ"])
|
||||||
|
|
||||||
|
def test_local_sw_members_survive_tushare_outage(self) -> None:
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from backend.data.providers import tushare_industries as module
|
||||||
|
|
||||||
|
client = TushareClient(token="demo")
|
||||||
|
stored = [
|
||||||
|
{
|
||||||
|
"ts_code": "000737.SZ",
|
||||||
|
"name": "北方铜业",
|
||||||
|
"l2_code": "801074.SI",
|
||||||
|
"in_date": "20200101",
|
||||||
|
"out_date": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
original = module._SW_MEMBER_DIR
|
||||||
|
module._SW_MEMBER_DIR = Path(tmp)
|
||||||
|
try:
|
||||||
|
client._sw_member_cache.clear()
|
||||||
|
client._write_local_sw_members("801074.SI", stored)
|
||||||
|
client.query = MagicMock(side_effect=TushareError("index_member_all down"))
|
||||||
|
members = client._sw_sector_members("801074.SI", "20260908")
|
||||||
|
finally:
|
||||||
|
module._SW_MEMBER_DIR = original
|
||||||
|
client._sw_member_cache.clear()
|
||||||
|
self.assertEqual([item["ts_code"] for item in members], ["000737.SZ"])
|
||||||
|
client.query.assert_not_called()
|
||||||
|
|
||||||
|
def test_closed_keeps_daily_inner_when_sw_daily_missing(self) -> None:
|
||||||
|
client = TushareClient(token="demo")
|
||||||
|
client.resolve_trade_context = lambda _date: ("20260908", "20260907")
|
||||||
|
client.sw_stock_industry = MagicMock(
|
||||||
|
return_value={"l2_code": "801074.SI", "l2_name": "工业金属"}
|
||||||
|
)
|
||||||
|
client._sw_sector_members = MagicMock(
|
||||||
|
return_value=[
|
||||||
|
{"ts_code": "000737.SZ", "name": "北方铜业"},
|
||||||
|
{"ts_code": "000630.SZ", "name": "铜陵有色"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
client._stock_listing_reference = MagicMock(return_value={})
|
||||||
|
client._load_daily = MagicMock(
|
||||||
|
return_value=[
|
||||||
|
{"ts_code": "000737.SZ", "name": "北方铜业", "pct_chg": 2, "amount": 1e8},
|
||||||
|
{"ts_code": "000630.SZ", "name": "铜陵有色", "pct_chg": 1, "amount": 1e8},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
client._confirmed_suspended_members = MagicMock(return_value=[])
|
||||||
|
client.query = MagicMock(return_value=[])
|
||||||
|
client._sw_realtime_sector_snapshot = MagicMock(
|
||||||
|
side_effect=AssertionError("daily inner should be kept")
|
||||||
|
)
|
||||||
|
client.try_sector_quote = MagicMock(return_value={
|
||||||
|
"code": "801074.SI",
|
||||||
|
"name": "工业金属",
|
||||||
|
"change": 1.5,
|
||||||
|
"pct_change": 1.5,
|
||||||
|
"quote_date": "20260908",
|
||||||
|
"quote_time": "2026-09-08T15:00:00+08:00",
|
||||||
|
"source": "eastmoney_sw",
|
||||||
|
})
|
||||||
|
snapshot = client.sw_sector_snapshot(
|
||||||
|
"000737.SZ", "20260908", allow_realtime_close=True
|
||||||
|
)
|
||||||
|
self.assertEqual(snapshot["quote_count"], 2)
|
||||||
|
self.assertEqual(snapshot["member_count"], 2)
|
||||||
|
self.assertTrue(snapshot["inner_precise"])
|
||||||
|
self.assertTrue(snapshot["outer_precise"])
|
||||||
|
self.assertEqual(snapshot["inner_source"], "tushare_member_daily")
|
||||||
|
self.assertEqual(snapshot["change"], 1.5)
|
||||||
|
self.assertNotIn("权限", snapshot.get("outer_error") or "")
|
||||||
|
self.assertNotIn("rt_sw_k", snapshot.get("outer_error") or "")
|
||||||
|
|
||||||
|
def test_closed_uses_complete_member_daily_when_sector_quote_is_wrong(self) -> None:
|
||||||
|
client = TushareClient(token="demo")
|
||||||
|
client.resolve_trade_context = lambda _date: ("20260909", "20260908")
|
||||||
|
client.sw_stock_industry = MagicMock(
|
||||||
|
return_value={"l2_code": "801074.SI", "l2_name": "专用设备"}
|
||||||
|
)
|
||||||
|
client._sw_sector_members = MagicMock(return_value=[
|
||||||
|
{"ts_code": "603011.SH", "name": "合锻智能"},
|
||||||
|
{"ts_code": "000821.SZ", "name": "京山轻机"},
|
||||||
|
])
|
||||||
|
client._stock_listing_reference = MagicMock(return_value={})
|
||||||
|
client._load_daily = MagicMock(return_value=[
|
||||||
|
{"ts_code": "603011.SH", "name": "合锻智能", "pct_chg": 2, "amount": 1e8},
|
||||||
|
{"ts_code": "000821.SZ", "name": "京山轻机", "pct_chg": -1, "amount": 1e8},
|
||||||
|
])
|
||||||
|
client._confirmed_suspended_members = MagicMock(return_value=[])
|
||||||
|
client.query = MagicMock(return_value=[])
|
||||||
|
client.try_sector_quote = MagicMock(return_value={
|
||||||
|
"code": "801074.SI",
|
||||||
|
"name": "托育服务",
|
||||||
|
"change": -2.19,
|
||||||
|
"quote_date": "20260909",
|
||||||
|
"quote_time": "2026-09-09T15:00:00+08:00",
|
||||||
|
"source": "eastmoney_sw",
|
||||||
|
})
|
||||||
|
|
||||||
|
snapshot = client.sw_sector_snapshot(
|
||||||
|
"603011.SH", "20260909", allow_realtime_close=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(snapshot["precise"])
|
||||||
|
self.assertEqual(snapshot["name"], "专用设备")
|
||||||
|
self.assertEqual(snapshot["change"], 0.5)
|
||||||
|
self.assertEqual(snapshot["outer_source"], "sw_member_equal_daily")
|
||||||
|
self.assertEqual(snapshot["outer_error"], "")
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from http import HTTPStatus
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from backend.application import HUB_SERVICE_HANDLERS, RequestHandler
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SERVICE_TOKEN = "hub-admin-token-for-tests"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHeaders(dict):
|
||||||
|
def get(self, name, default=""): # type: ignore[override]
|
||||||
|
return super().get(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
class HubAdminBridgeTests(unittest.TestCase):
|
||||||
|
def handler(self, path: str, token: str, calls: list[str]) -> RequestHandler:
|
||||||
|
handler = RequestHandler.__new__(RequestHandler)
|
||||||
|
handler.path = path
|
||||||
|
handler.headers = FakeHeaders({"X-Hub-Admin-Token": token} if token else {})
|
||||||
|
handler.responses = []
|
||||||
|
handler.send_json = lambda payload, status=HTTPStatus.OK, headers=None: (
|
||||||
|
handler.responses.append((status, payload))
|
||||||
|
)
|
||||||
|
handler.require_auth = lambda: calls.append("auth") or True
|
||||||
|
handler.require_csrf = lambda: calls.append("csrf") or True
|
||||||
|
handler.require_access = lambda method, route: calls.append("access") or True
|
||||||
|
return handler
|
||||||
|
|
||||||
|
def test_every_bridge_path_has_a_real_handler(self) -> None:
|
||||||
|
for path, handler_name in HUB_SERVICE_HANDLERS.items():
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertTrue(path.startswith("/api/hub-admin/"))
|
||||||
|
self.assertTrue(callable(getattr(RequestHandler, handler_name)))
|
||||||
|
|
||||||
|
def test_bridge_paths_stay_out_of_the_browser_route_registry(self) -> None:
|
||||||
|
registry = json.loads(
|
||||||
|
(ROOT / "config" / "api.config.json").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
registered = {route["path"] for route in registry["routes"]}
|
||||||
|
for path in HUB_SERVICE_HANDLERS:
|
||||||
|
with self.subTest(path=path):
|
||||||
|
self.assertNotIn(path, registered)
|
||||||
|
self.assertIsNone(RequestHandler.route_registry.resolve("POST", path))
|
||||||
|
|
||||||
|
def test_missing_or_wrong_service_token_is_rejected(self) -> None:
|
||||||
|
for token in ("", "wrong-token"):
|
||||||
|
with self.subTest(token=token), mock.patch.dict(
|
||||||
|
"os.environ", {"HUB_ADMIN_TOKEN": SERVICE_TOKEN}
|
||||||
|
):
|
||||||
|
calls: list[str] = []
|
||||||
|
handler = self.handler("/api/hub-admin/status", token, calls)
|
||||||
|
handler.hub_system_status = lambda: calls.append("dispatched")
|
||||||
|
|
||||||
|
RequestHandler.do_POST(handler)
|
||||||
|
|
||||||
|
self.assertEqual(calls, [])
|
||||||
|
status, payload = handler.responses[-1]
|
||||||
|
self.assertEqual(status, HTTPStatus.UNAUTHORIZED)
|
||||||
|
self.assertIn("服务令牌", payload["error"])
|
||||||
|
|
||||||
|
def test_unset_server_token_refuses_every_bridge_call(self) -> None:
|
||||||
|
with mock.patch.dict("os.environ", {"HUB_ADMIN_TOKEN": ""}):
|
||||||
|
calls: list[str] = []
|
||||||
|
handler = self.handler("/api/hub-admin/status", SERVICE_TOKEN, calls)
|
||||||
|
handler.hub_system_status = lambda: calls.append("dispatched")
|
||||||
|
|
||||||
|
RequestHandler.do_POST(handler)
|
||||||
|
|
||||||
|
self.assertEqual(calls, [])
|
||||||
|
self.assertEqual(handler.responses[-1][0], HTTPStatus.UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_valid_service_token_dispatches_without_session_guards(self) -> None:
|
||||||
|
for path, handler_name in HUB_SERVICE_HANDLERS.items():
|
||||||
|
with self.subTest(path=path), mock.patch.dict(
|
||||||
|
"os.environ", {"HUB_ADMIN_TOKEN": SERVICE_TOKEN}
|
||||||
|
):
|
||||||
|
calls: list[str] = []
|
||||||
|
handler = self.handler(path, SERVICE_TOKEN, calls)
|
||||||
|
setattr(handler, handler_name, lambda: calls.append(handler_name))
|
||||||
|
|
||||||
|
RequestHandler.do_POST(handler)
|
||||||
|
|
||||||
|
self.assertEqual(calls, [handler_name])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from backend.data import build_data_gateway
|
||||||
|
from backend.data.datahub.bridge import DatahubAwareTushareClient, DatahubBridge
|
||||||
|
from backend.data.datahub.client import DatahubClient
|
||||||
|
from backend.data.datahub.ifind_proxy import HubIfindProxy
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BLOCKED_HOSTS = (
|
||||||
|
"api.tushare.pro",
|
||||||
|
"push2.eastmoney.com",
|
||||||
|
"push2delay.eastmoney.com",
|
||||||
|
"push2his.eastmoney.com",
|
||||||
|
"push2ex.eastmoney.com",
|
||||||
|
"qt.gtimg.cn",
|
||||||
|
"hq.sinajs.cn",
|
||||||
|
"10jqka.com.cn",
|
||||||
|
"xuangubao.cn",
|
||||||
|
"quantapi.51ifind.com",
|
||||||
|
"51ifind.com",
|
||||||
|
)
|
||||||
|
LEFTOVER_WEBSITE_FILES = {
|
||||||
|
"backend/data/providers/ifind_client.py",
|
||||||
|
"backend/data/realtime.py",
|
||||||
|
"backend/features/market/charts.py",
|
||||||
|
"backend/data/providers/tushare_transport.py",
|
||||||
|
}
|
||||||
|
HUB_BASE = "http://127.0.0.1:8766"
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_settings() -> DatahubSettings:
|
||||||
|
return DatahubSettings(
|
||||||
|
base_url=HUB_BASE,
|
||||||
|
token="hub-token",
|
||||||
|
datasets={name: DatasetFlags(name, read=True) for name in DATASETS},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def __init__(self, payload: dict) -> None:
|
||||||
|
self.status = 200
|
||||||
|
self._raw = json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return self._raw
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *args):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def hub_payload(request) -> dict:
|
||||||
|
url = str(getattr(request, "full_url", None) or request)
|
||||||
|
if any(host in url for host in BLOCKED_HOSTS):
|
||||||
|
raise AssertionError(f"website opened blocked host: {url}")
|
||||||
|
if HUB_BASE not in url:
|
||||||
|
raise AssertionError(f"unexpected url: {url}")
|
||||||
|
path = url.split(HUB_BASE, 1)[1].split("?", 1)[0]
|
||||||
|
if path == "/v1/bars/daily":
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"ts_code": "600000.SH",
|
||||||
|
"trade_date": "20240902",
|
||||||
|
"open": 10.0,
|
||||||
|
"high": 10.4,
|
||||||
|
"low": 9.9,
|
||||||
|
"close": 10.2,
|
||||||
|
"volume": 100000,
|
||||||
|
"amount": 2000000,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {"stale": False, "staleness_seconds": 0, "source": "datahub"},
|
||||||
|
}
|
||||||
|
if path == "/v1/quotes/latest":
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"ts_code": "600000.SH",
|
||||||
|
"code": "600000",
|
||||||
|
"name": "浦发银行",
|
||||||
|
"close": 10.2,
|
||||||
|
"price": 10.2,
|
||||||
|
"pre_close": 10.0,
|
||||||
|
"open": 10.1,
|
||||||
|
"high": 10.3,
|
||||||
|
"low": 9.9,
|
||||||
|
"vol": 1000,
|
||||||
|
"amount": 2000000,
|
||||||
|
"quote_date": "20240902",
|
||||||
|
"source": "datahub",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {"stale": False, "staleness_seconds": 0, "source": "eastmoney"},
|
||||||
|
}
|
||||||
|
if path == "/v1/indexes/quotes":
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"ts_code": "000001.SH",
|
||||||
|
"code": "000001",
|
||||||
|
"name": "上证指数",
|
||||||
|
"price": 3000,
|
||||||
|
"pct_chg": 1.2,
|
||||||
|
"quote_time_epoch": 1725249600,
|
||||||
|
"source": "datahub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts_code": "399001.SZ",
|
||||||
|
"code": "399001",
|
||||||
|
"name": "深证成指",
|
||||||
|
"price": 9000,
|
||||||
|
"pct_chg": 0.8,
|
||||||
|
"quote_time_epoch": 1725249600,
|
||||||
|
"source": "datahub",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts_code": "399006.SZ",
|
||||||
|
"code": "399006",
|
||||||
|
"name": "创业板指",
|
||||||
|
"price": 1800,
|
||||||
|
"pct_chg": 0.5,
|
||||||
|
"quote_time_epoch": 1725249600,
|
||||||
|
"source": "datahub",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"meta": {"stale": False, "staleness_seconds": 0, "source": "tencent"},
|
||||||
|
}
|
||||||
|
if path == "/v1/auction":
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"ts_code": "600000.SH",
|
||||||
|
"trade_date": "20240902",
|
||||||
|
"close": 10.2,
|
||||||
|
"vol": 1000.0,
|
||||||
|
"amount": 2000.0,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {"stale": False, "staleness_seconds": 0, "source": "datahub"},
|
||||||
|
}
|
||||||
|
if path == "/v1/credentials/ifind":
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data": {"configured": True, "access_ready": True, "access_expires_at": ""},
|
||||||
|
"meta": {"source": "ifind"},
|
||||||
|
}
|
||||||
|
if path == "/v1/intraday/points":
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data": {
|
||||||
|
"entity_type": "stock",
|
||||||
|
"identifier": "600000",
|
||||||
|
"code": "600000",
|
||||||
|
"trade_date": "2024-09-02",
|
||||||
|
"previous_close": 10.0,
|
||||||
|
"points": [
|
||||||
|
{"date": "2024-09-02", "time": "09:30", "close": 10.2, "average": 10.1, "open": 10.1, "high": 10.2, "low": 10.0, "volume": 100, "amount": 1000}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"meta": {"stale": False, "source": "datahub"},
|
||||||
|
}
|
||||||
|
if path == "/v1/query":
|
||||||
|
body = json.loads(request.data.decode("utf-8") if request.data else "{}")
|
||||||
|
api_name = body.get("api_name")
|
||||||
|
if api_name == "ifind_status":
|
||||||
|
return {"schema_version": 1, "data": [{"configured": True, "access_ready": True, "access_expires_at": ""}], "meta": {"source": "ifind"}}
|
||||||
|
if api_name == "ifind_wencai":
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data": [{"股票代码": "000001.SZ", "涨停原因": "重组", "首次涨停时间": "09:42:00"}],
|
||||||
|
"meta": {"source": "ifind"},
|
||||||
|
}
|
||||||
|
if api_name == "ifind_snapshots":
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"time": "2024-09-02 09:21:00",
|
||||||
|
"thscode": "000001.SZ",
|
||||||
|
"latest": 10.5,
|
||||||
|
"preClose": 10,
|
||||||
|
"volume": 2000,
|
||||||
|
"amount": 21000,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"meta": {"source": "ifind"},
|
||||||
|
}
|
||||||
|
if api_name in {"daily", "rt_k", "stk_auction"}:
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"data": [{"ts_code": "600000.SH", "trade_date": "20240902", "close": 10.2, "amount": 2000.0}],
|
||||||
|
"meta": {"source": "datahub", "stale": False, "row_shape": "tushare"},
|
||||||
|
}
|
||||||
|
raise AssertionError(f"unexpected query api: {api_name}")
|
||||||
|
raise AssertionError(f"unexpected path: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def blocked_urlopen(request, timeout=None):
|
||||||
|
return _Resp(hub_payload(request))
|
||||||
|
|
||||||
|
|
||||||
|
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"))
|
||||||
|
wrapped = DatahubAwareTushareClient(
|
||||||
|
legacy,
|
||||||
|
DatahubBridge(flags(daily=(True, False)), client),
|
||||||
|
)
|
||||||
|
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,amount")
|
||||||
|
self.assertEqual(rows[0]["amount"], 2000.0)
|
||||||
|
self.assertEqual(legacy.calls, [])
|
||||||
|
|
||||||
|
def test_blocked_external_hosts_still_read_hub(self) -> None:
|
||||||
|
settings = _enabled_settings()
|
||||||
|
hub_client = DatahubClient(settings, urlopen=blocked_urlopen)
|
||||||
|
legacy = FakeLegacy(TushareError("blocked"))
|
||||||
|
wrapped = DatahubAwareTushareClient(legacy, DatahubBridge(settings, hub_client))
|
||||||
|
with patch("urllib.request.urlopen", blocked_urlopen):
|
||||||
|
rows = wrapped.query("daily", {"trade_date": "20240902"}, "ts_code,close,amount")
|
||||||
|
self.assertEqual(rows[0]["close"], 10.2)
|
||||||
|
self.assertEqual(rows[0]["amount"], 2000.0)
|
||||||
|
self.assertEqual(legacy.calls, [])
|
||||||
|
|
||||||
|
def test_site_features_only_reach_hub_when_external_hosts_blocked(self) -> None:
|
||||||
|
settings = _enabled_settings()
|
||||||
|
hub_client = DatahubClient(settings, urlopen=blocked_urlopen)
|
||||||
|
bridge = DatahubBridge(settings, hub_client)
|
||||||
|
with patch("urllib.request.urlopen", blocked_urlopen):
|
||||||
|
quotes = bridge.try_quotes(["600000.SH"])
|
||||||
|
indices = bridge.try_index_quotes()
|
||||||
|
chart = MarketChartClient(bridge).stock_daily("600000", "20240902")
|
||||||
|
intraday = MarketChartClient(bridge).stock_intraday("600000")
|
||||||
|
ifind = HubIfindProxy(bridge)
|
||||||
|
wencai = ifind.wencai("2024年9月2日涨停股票")
|
||||||
|
snapshots = ifind.snapshots(["000001.SZ"], ["latest"], "2024-09-02 09:15:00", "2024-09-02 09:22:00")
|
||||||
|
realtime = HubRealtimeProxy(bridge)
|
||||||
|
index_rows = realtime.tencent_indices()
|
||||||
|
stock = realtime.tencent_stock_quote("600000.SH", "20240902")
|
||||||
|
health = realtime.health_snapshot()
|
||||||
|
|
||||||
|
self.assertEqual(quotes[0]["close"], 10.2)
|
||||||
|
self.assertEqual(len(indices), 3)
|
||||||
|
self.assertEqual(chart[-1]["close"], 10.2)
|
||||||
|
self.assertEqual(intraday["source"], "datahub")
|
||||||
|
self.assertEqual(wencai[0]["股票代码"], "000001.SZ")
|
||||||
|
self.assertEqual(snapshots[0]["latest"], 10.5)
|
||||||
|
self.assertEqual([row["code"] for row in index_rows], ["000001", "399001", "399006"])
|
||||||
|
self.assertEqual(stock["close"], 10.2)
|
||||||
|
self.assertTrue(health["ready"])
|
||||||
|
self.assertTrue(ifind.configured)
|
||||||
|
|
||||||
|
def test_production_gateway_does_not_construct_external_clients(self) -> None:
|
||||||
|
source = (ROOT / "backend" / "data" / "gateway.py").read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn("IfindHttpClient", source)
|
||||||
|
self.assertNotIn("EastmoneyChartClient", source)
|
||||||
|
self.assertNotIn("WebRealtimeAggregator", source)
|
||||||
|
self.assertNotIn("TushareProvider", source)
|
||||||
|
self.assertIsNone(re.search(r"(?<![A-Za-z])TushareClient\(", source))
|
||||||
|
self.assertIn("HubIfindProxy", source)
|
||||||
|
self.assertIn("HubRealtimeProxy", source)
|
||||||
|
self.assertIn("DatahubAwareTushareClient", source)
|
||||||
|
facade = (ROOT / "backend" / "data" / "datahub" / "bridge.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(facade)
|
||||||
|
cls = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == "DatahubAwareTushareClient"
|
||||||
|
)
|
||||||
|
methods = {item.name for item in cls.body if isinstance(item, ast.FunctionDef)}
|
||||||
|
self.assertNotIn("__getattr__", methods)
|
||||||
|
self.assertIn("query", methods)
|
||||||
|
self.assertTrue(any(base.id == "DashboardMixin" for base in cls.bases if isinstance(base, ast.Name)))
|
||||||
|
|
||||||
|
def test_production_python_does_not_embed_blocked_hosts(self) -> None:
|
||||||
|
violations = []
|
||||||
|
for path in (ROOT / "backend").rglob("*.py"):
|
||||||
|
relative = path.relative_to(ROOT).as_posix()
|
||||||
|
if relative in LEFTOVER_WEBSITE_FILES:
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for host in BLOCKED_HOSTS:
|
||||||
|
if host in text:
|
||||||
|
violations.append(f"{relative} -> {host}")
|
||||||
|
self.assertEqual(violations, [])
|
||||||
|
|
||||||
|
def test_website_runtime_does_not_call_blocked_hosts_from_gateway(self) -> None:
|
||||||
|
gateway_src = (ROOT / "backend" / "data" / "gateway.py").read_text(encoding="utf-8")
|
||||||
|
self.assertNotIn("TushareProvider", gateway_src)
|
||||||
|
self.assertIsNone(re.search(r"(?<![A-Za-z])TushareClient\(", gateway_src))
|
||||||
|
self.assertIn("DatahubAwareTushareClient", gateway_src)
|
||||||
|
|
||||||
|
def test_bridge_query_has_no_legacy_call(self) -> None:
|
||||||
|
source = (ROOT / "backend" / "data" / "datahub" / "bridge.py").read_text(encoding="utf-8")
|
||||||
|
tree = ast.parse(source)
|
||||||
|
query_fn = next(
|
||||||
|
node
|
||||||
|
for node in tree.body
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name == "DatahubBridge"
|
||||||
|
for item in node.body
|
||||||
|
if isinstance(item, ast.FunctionDef) and item.name == "query"
|
||||||
|
)
|
||||||
|
called = [
|
||||||
|
ast.unparse(item.func) if hasattr(ast, "unparse") else ""
|
||||||
|
for item in ast.walk(query_fn)
|
||||||
|
if isinstance(item, ast.Call)
|
||||||
|
]
|
||||||
|
self.assertTrue(any("query_api" in text for text in called))
|
||||||
|
self.assertFalse(any("legacy_query" in text for text in called))
|
||||||
|
|
||||||
|
def test_build_gateway_uses_hub_proxies_without_opening_external_hosts(self) -> None:
|
||||||
|
settings = _enabled_settings()
|
||||||
|
with patch("urllib.request.urlopen", blocked_urlopen):
|
||||||
|
gateway = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
|
||||||
|
hub_client = DatahubClient(settings, urlopen=blocked_urlopen)
|
||||||
|
gateway.datahub.client = hub_client
|
||||||
|
rows = gateway.ifind.wencai("涨停")
|
||||||
|
quotes = gateway.realtime_observer.tencent_indices()
|
||||||
|
chart = gateway.chart_data.stock_daily("600000", "20240902")
|
||||||
|
market = gateway.tushare()
|
||||||
|
market_quotes = market.try_quotes(["600000.SH"])
|
||||||
|
auction = market.query("stk_auction", {"trade_date": "20240902"}, "")
|
||||||
|
self.assertEqual(rows[0]["涨停原因"], "重组")
|
||||||
|
self.assertEqual(len(quotes), 3)
|
||||||
|
self.assertEqual(chart[-1]["close"], 10.2)
|
||||||
|
self.assertEqual(market_quotes[0]["close"], 10.2)
|
||||||
|
self.assertEqual(auction[0]["close"], 10.2)
|
||||||
|
self.assertIsNone(market.realtime_aggregator)
|
||||||
|
self.assertEqual(market.token, "datahub")
|
||||||
|
|
||||||
|
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 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")
|
||||||
|
standalone = (ROOT / "xiaobai-datahub" / "compose.yaml").read_text(encoding="utf-8")
|
||||||
|
for text in (overlay, standalone):
|
||||||
|
self.assertIn('IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"', text)
|
||||||
|
self.assertIn('IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"', text)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -6,12 +6,26 @@ from datetime import date, datetime, timedelta, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from backend.features.market.charts import EastmoneyChartClient, MarketChartClient
|
from backend.features.market.charts import MarketChartClient
|
||||||
from database import ReviewDatabase
|
from database import ReviewDatabase
|
||||||
from backend.features.market.insights import MarketInsightsService
|
from backend.features.market.insights import MarketInsightsService
|
||||||
from server import DashboardService
|
from server import DashboardService
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDailyHub:
|
||||||
|
def __init__(self, rows: list) -> None:
|
||||||
|
self.rows = rows
|
||||||
|
|
||||||
|
def try_daily_chart(self, code, end_date, limit, dataset="daily"):
|
||||||
|
return list(self.rows)
|
||||||
|
|
||||||
|
def try_quotes(self, codes):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_index_quotes(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class FakeIfind:
|
class FakeIfind:
|
||||||
configured = True
|
configured = True
|
||||||
|
|
||||||
@@ -128,13 +142,50 @@ class IfindFeatureTests(unittest.TestCase):
|
|||||||
self.assertEqual(database.list_wencai_saved_queries(second["id"]), [])
|
self.assertEqual(database.list_wencai_saved_queries(second["id"]), [])
|
||||||
|
|
||||||
def test_ifind_daily_chart_normalizes_change(self):
|
def test_ifind_daily_chart_normalizes_change(self):
|
||||||
client = MarketChartClient(FakeIfind(), EastmoneyChartClient())
|
hub = _FakeDailyHub(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"trade_date": "2026-07-27",
|
||||||
|
"open": 10,
|
||||||
|
"high": 10.5,
|
||||||
|
"low": 9.8,
|
||||||
|
"close": 10.2,
|
||||||
|
"volume": 100,
|
||||||
|
"amount_billion": 0.01,
|
||||||
|
"change": 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"trade_date": "2026-07-28",
|
||||||
|
"open": 10.2,
|
||||||
|
"high": 10.8,
|
||||||
|
"low": 10.1,
|
||||||
|
"close": 10.5,
|
||||||
|
"volume": 120,
|
||||||
|
"amount_billion": 0.012,
|
||||||
|
"change": 2.9412,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
client = MarketChartClient(hub)
|
||||||
rows = client.stock_daily("000001", "20260728")
|
rows = client.stock_daily("000001", "20260728")
|
||||||
self.assertEqual(rows[-1]["trade_date"], "2026-07-28")
|
self.assertEqual(rows[-1]["trade_date"], "2026-07-28")
|
||||||
self.assertAlmostEqual(rows[-1]["change"], 2.9412, places=4)
|
self.assertAlmostEqual(rows[-1]["change"], 2.9412, places=4)
|
||||||
|
|
||||||
def test_ifind_daily_chart_keeps_last_traded_bar_before_market_open(self):
|
def test_ifind_daily_chart_keeps_last_traded_bar_before_market_open(self):
|
||||||
client = MarketChartClient(FakeIfindStalePreopen(), EastmoneyChartClient())
|
hub = _FakeDailyHub(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"trade_date": "2026-07-28",
|
||||||
|
"open": 10.2,
|
||||||
|
"high": 10.8,
|
||||||
|
"low": 10.1,
|
||||||
|
"close": 10.5,
|
||||||
|
"volume": 120,
|
||||||
|
"amount_billion": 0.012,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
client = MarketChartClient(hub)
|
||||||
with patch("backend.features.market.charts.datetime", FixedPreopenDatetime):
|
with patch("backend.features.market.charts.datetime", FixedPreopenDatetime):
|
||||||
rows = client.stock_daily("000001", "20260729")
|
rows = client.stock_daily("000001", "20260729")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
from backend.features.accounts.security import SecretVault
|
||||||
|
from backend.features.accounts.service import AccountService
|
||||||
|
from database import ReviewDatabase
|
||||||
|
|
||||||
|
|
||||||
|
class InviteRegistrationTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp = TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
|
||||||
|
self.bound_user_id = 0
|
||||||
|
self.service = AccountService(
|
||||||
|
database=self.database,
|
||||||
|
vault=SecretVault(SecretVault.generate_key()),
|
||||||
|
current_user_supplier=lambda: self.bound_user_id,
|
||||||
|
access_supplier=lambda: self.database.user_access(self.bound_user_id) or {},
|
||||||
|
bind_user=self._bind,
|
||||||
|
personal_field_builder=lambda *args, **kwargs: {},
|
||||||
|
auth_lock=threading.Lock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _bind(self, user_id: int) -> None:
|
||||||
|
self.bound_user_id = int(user_id)
|
||||||
|
|
||||||
|
def _bootstrap_admin(self) -> None:
|
||||||
|
self.service.register("root_admin", "Password123")
|
||||||
|
|
||||||
|
def _one_code(self) -> str:
|
||||||
|
return self.service.generate_invite_codes(1)[0]["code"]
|
||||||
|
|
||||||
|
def test_first_account_is_created_without_an_invite_code(self) -> None:
|
||||||
|
result = self.service.register("root_admin", "Password123")
|
||||||
|
self.assertEqual(result["user"]["role"], "admin")
|
||||||
|
|
||||||
|
def test_registration_requires_an_invite_code_once_an_account_exists(self) -> None:
|
||||||
|
self._bootstrap_admin()
|
||||||
|
with self.assertRaises(ValueError) as error:
|
||||||
|
self.service.register("second_user", "Password123")
|
||||||
|
self.assertIn("邀请码", str(error.exception))
|
||||||
|
self.assertEqual(self.database.count_users(), 1)
|
||||||
|
|
||||||
|
def test_unknown_used_and_revoked_codes_are_all_rejected(self) -> None:
|
||||||
|
self._bootstrap_admin()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.service.register("second_user", "Password123", "", "XB-AAAA-AAAA-AAAA")
|
||||||
|
|
||||||
|
code = self._one_code()
|
||||||
|
self.service.register("second_user", "Password123", "", code)
|
||||||
|
with self.assertRaises(ValueError) as used:
|
||||||
|
self.service.register("third_user", "Password123", "", code)
|
||||||
|
self.assertIn("已被使用", str(used.exception))
|
||||||
|
|
||||||
|
revoked = self._one_code()
|
||||||
|
self.service.revoke_invite_code(revoked)
|
||||||
|
with self.assertRaises(ValueError) as gone:
|
||||||
|
self.service.register("fourth_user", "Password123", "", revoked)
|
||||||
|
self.assertIn("作废", str(gone.exception))
|
||||||
|
self.assertEqual(self.database.count_users(), 2)
|
||||||
|
|
||||||
|
def test_invite_code_is_accepted_with_or_without_separators(self) -> None:
|
||||||
|
self._bootstrap_admin()
|
||||||
|
code = self._one_code()
|
||||||
|
self.service.register("second_user", "Password123", "", code.replace("-", "").lower())
|
||||||
|
record = self.database.invite_code(code)
|
||||||
|
self.assertEqual(record["status"], "used")
|
||||||
|
self.assertTrue(record["used_at"])
|
||||||
|
|
||||||
|
def test_concurrent_registrations_consume_one_code_once(self) -> None:
|
||||||
|
self._bootstrap_admin()
|
||||||
|
code = self._one_code()
|
||||||
|
|
||||||
|
def attempt(index: int) -> str:
|
||||||
|
try:
|
||||||
|
self.service.register(f"racer_{index}", "Password123", "", code)
|
||||||
|
return "ok"
|
||||||
|
except ValueError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=6) as pool:
|
||||||
|
outcomes = list(pool.map(attempt, range(6)))
|
||||||
|
|
||||||
|
self.assertEqual(outcomes.count("ok"), 1)
|
||||||
|
self.assertEqual(self.database.count_users(), 2)
|
||||||
|
self.assertEqual(self.database.count_invite_codes()["used"], 1)
|
||||||
|
|
||||||
|
def test_failed_account_creation_keeps_the_code_available(self) -> None:
|
||||||
|
self._bootstrap_admin()
|
||||||
|
code = self._one_code()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.service.register("root_admin", "Password123", "", code)
|
||||||
|
self.assertEqual(self.database.invite_code(code)["status"], "unused")
|
||||||
|
self.service.register("second_user", "Password123", "", code)
|
||||||
|
self.assertEqual(self.database.invite_code(code)["status"], "used")
|
||||||
|
|
||||||
|
def test_used_code_cannot_be_revoked_and_stays_reported(self) -> None:
|
||||||
|
self._bootstrap_admin()
|
||||||
|
code = self._one_code()
|
||||||
|
self.service.register("second_user", "Password123", "", code)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.service.revoke_invite_code(code)
|
||||||
|
overview = self.service.invite_overview()
|
||||||
|
# total 让页面能直接显示"共 N 个",不必自己加总
|
||||||
|
self.assertEqual(overview["summary"], {"unused": 0, "used": 1, "revoked": 0})
|
||||||
|
row = overview["codes"][0]
|
||||||
|
self.assertEqual(row["used_by_username"], "second_user")
|
||||||
|
self.assertNotIn(code, row["code_masked"])
|
||||||
|
self.assertTrue(row["code_masked"].endswith("••••"))
|
||||||
|
self.assertEqual(row["code_id"], AccountService.invite_handle(code))
|
||||||
|
|
||||||
|
def test_codes_can_be_revoked_through_their_public_handle(self) -> None:
|
||||||
|
self._bootstrap_admin()
|
||||||
|
code = self._one_code()
|
||||||
|
self.service.revoke_invite_code(AccountService.invite_handle(code))
|
||||||
|
self.assertEqual(self.database.invite_code(code)["status"], "revoked")
|
||||||
|
|
||||||
|
def test_batch_generation_is_bounded(self) -> None:
|
||||||
|
self._bootstrap_admin()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.service.generate_invite_codes(AccountService.INVITE_MAX_BATCH + 1)
|
||||||
|
created = self.service.generate_invite_codes(3, "内部测试")
|
||||||
|
self.assertEqual(len({item["code"] for item in created}), 3)
|
||||||
|
self.assertEqual(self.database.count_invite_codes()["unused"], 3)
|
||||||
|
self.assertEqual(self.service.invite_overview()["codes"][0]["note"], "内部测试")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -25,7 +25,6 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
|
|||||||
"system/password",
|
"system/password",
|
||||||
"system/membership",
|
"system/membership",
|
||||||
"system/admin",
|
"system/admin",
|
||||||
"system/members",
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
for key in keys:
|
for key in keys:
|
||||||
@@ -47,19 +46,16 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
|
|||||||
'data-system-page="profile"',
|
'data-system-page="profile"',
|
||||||
'data-system-page="password"',
|
'data-system-page="password"',
|
||||||
'data-system-page="membership"',
|
'data-system-page="membership"',
|
||||||
'data-system-page="members"',
|
|
||||||
'data-system-page="forbidden"',
|
'data-system-page="forbidden"',
|
||||||
'data-system-admin-panel="market"',
|
'data-system-admin-panel="market"',
|
||||||
"m-sys-birth-date",
|
"m-sys-birth-date",
|
||||||
"m-sys-password-current",
|
"m-sys-password-current",
|
||||||
"m-sys-token",
|
"m-sys-token",
|
||||||
"m-sys-member-limit",
|
|
||||||
"data-system-switch",
|
"data-system-switch",
|
||||||
"data-system-edit-model",
|
"data-system-datahub",
|
||||||
"data-system-open-member",
|
|
||||||
"管理员专区",
|
"管理员专区",
|
||||||
"保存密钥",
|
"刷新状态",
|
||||||
"保存分工",
|
"打开数据中枢",
|
||||||
'location.assign("/login/")',
|
'location.assign("/login/")',
|
||||||
):
|
):
|
||||||
self.assertIn(marker, pages)
|
self.assertIn(marker, pages)
|
||||||
@@ -75,12 +71,11 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
|
|||||||
for name in (
|
for name in (
|
||||||
"data-system-save-birth",
|
"data-system-save-birth",
|
||||||
"data-system-save-password",
|
"data-system-save-password",
|
||||||
"data-system-add-model",
|
|
||||||
"data-system-save-models",
|
|
||||||
"data-system-save-market",
|
"data-system-save-market",
|
||||||
"data-system-refresh",
|
"data-system-refresh",
|
||||||
"data-system-toggle-refresh",
|
"data-system-toggle-refresh",
|
||||||
"data-system-save-model",
|
"data-system-backfill",
|
||||||
|
"data-system-datahub",
|
||||||
):
|
):
|
||||||
self.assertIn(name, pages)
|
self.assertIn(name, pages)
|
||||||
self.assertNotIn(name + '">', pages)
|
self.assertNotIn(name + '">', pages)
|
||||||
|
|||||||
@@ -133,6 +133,13 @@ class FakeFreeAggregator:
|
|||||||
def tencent_market_quotes(self, codes, expected_date=""):
|
def tencent_market_quotes(self, codes, expected_date=""):
|
||||||
return self.eastmoney_market_quotes(expected_date)
|
return self.eastmoney_market_quotes(expected_date)
|
||||||
|
|
||||||
|
def tencent_stock_quotes(self, codes, expected_date="", minimum=None):
|
||||||
|
wanted = {str(code).strip() for code in (codes or []) if str(code).strip()}
|
||||||
|
rows = self.tencent_market_quotes(codes, expected_date)
|
||||||
|
if not wanted:
|
||||||
|
return rows
|
||||||
|
return [row for row in rows if str(row.get("ts_code") or "") in wanted]
|
||||||
|
|
||||||
def eastmoney_indices(self):
|
def eastmoney_indices(self):
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -170,13 +177,29 @@ class RealtimeDashboardTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_realtime_stock_quote_uses_cached_industry(self):
|
def test_realtime_stock_quote_uses_cached_industry(self):
|
||||||
self.client._load_realtime_reference("20260720", "20260717")
|
self.client._load_realtime_reference("20260720", "20260717")
|
||||||
quote = self.client.realtime_stock_quote("000003.SZ")
|
self.client.try_quotes = lambda codes: [
|
||||||
|
row for row in FREE_QUOTES if row["ts_code"] in set(codes)
|
||||||
|
]
|
||||||
|
calls = []
|
||||||
|
original_query = self.client.query
|
||||||
|
self.client.query = lambda api, params=None, fields="": (
|
||||||
|
calls.append((api, dict(params or {})))
|
||||||
|
or original_query(api, params, fields)
|
||||||
|
)
|
||||||
|
quote = self.client.realtime_stock_quote("000003.SZ", "20260720")
|
||||||
|
|
||||||
self.assertEqual(quote["name"], "丙")
|
self.assertEqual(quote["name"], "丙")
|
||||||
self.assertEqual(quote["sector"], "元器件")
|
self.assertEqual(quote["sector"], "元器件")
|
||||||
self.assertAlmostEqual(quote["change"], 6.6667)
|
self.assertAlmostEqual(quote["change"], 6.6667)
|
||||||
self.assertEqual(quote["amount_billion"], 3.0)
|
self.assertEqual(quote["amount_billion"], 3.0)
|
||||||
self.assertAlmostEqual(quote["turnover_rate"], 0.01)
|
self.assertAlmostEqual(quote["turnover_rate"], 0.01)
|
||||||
|
self.assertEqual(quote["trade_date"], "20260720")
|
||||||
|
history_calls = [
|
||||||
|
params for api, params in calls
|
||||||
|
if api in {"daily", "daily_basic"} and params.get("start_date")
|
||||||
|
]
|
||||||
|
self.assertTrue(history_calls)
|
||||||
|
self.assertTrue(all(params.get("end_date") == "20260719" for params in history_calls))
|
||||||
|
|
||||||
def test_close_dashboard_marks_official_limit_data(self):
|
def test_close_dashboard_marks_official_limit_data(self):
|
||||||
dashboard = self.client.dashboard("20260720")
|
dashboard = self.client.dashboard("20260720")
|
||||||
@@ -249,7 +272,7 @@ class RealtimeDashboardTests(unittest.TestCase):
|
|||||||
self.assertEqual(dashboard["meta"]["quote_count"], 3)
|
self.assertEqual(dashboard["meta"]["quote_count"], 3)
|
||||||
self.assertEqual(dashboard["overview"]["limit_up_count"], 0)
|
self.assertEqual(dashboard["overview"]["limit_up_count"], 0)
|
||||||
|
|
||||||
def test_rt_k_permission_error_falls_back_to_free_quotes(self):
|
def test_hub_quotes_used_when_rt_k_denied(self):
|
||||||
original_query = self.client.query
|
original_query = self.client.query
|
||||||
|
|
||||||
def query(api_name, params=None, fields=""):
|
def query(api_name, params=None, fields=""):
|
||||||
@@ -258,21 +281,20 @@ class RealtimeDashboardTests(unittest.TestCase):
|
|||||||
return original_query(api_name, params, fields)
|
return original_query(api_name, params, fields)
|
||||||
|
|
||||||
self.client.query = query
|
self.client.query = query
|
||||||
self.client.realtime_aggregator = FakeFreeAggregator()
|
self.client.try_market_quotes = lambda trade_date: list(FREE_QUOTES)
|
||||||
TushareClient._realtime_reference_cache.clear()
|
TushareClient._realtime_reference_cache.clear()
|
||||||
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
||||||
|
|
||||||
self.assertTrue(dashboard["meta"]["realtime"])
|
self.assertTrue(dashboard["meta"]["realtime"])
|
||||||
self.assertEqual(dashboard["meta"]["quote_source"], "eastmoney_clist")
|
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
|
||||||
self.assertEqual(dashboard["meta"]["trade_date"], "2026-07-20")
|
self.assertEqual(dashboard["meta"]["trade_date"], "2026-07-20")
|
||||||
self.assertEqual(dashboard["meta"]["quote_count"], 3)
|
self.assertEqual(dashboard["meta"]["quote_count"], 3)
|
||||||
self.assertEqual(dashboard["overview"]["limit_up_count"], 1)
|
self.assertEqual(dashboard["overview"]["limit_up_count"], 1)
|
||||||
self.assertEqual(dashboard["overview"]["limit_down_count"], 1)
|
self.assertEqual(dashboard["overview"]["limit_down_count"], 1)
|
||||||
self.assertEqual(dashboard["overview"]["amount_billion"], 6.0)
|
self.assertEqual(dashboard["overview"]["amount_billion"], 6.0)
|
||||||
self.assertIn("东财免费实时", dashboard["meta"]["notice"])
|
self.assertIn("数据中枢", dashboard["meta"]["notice"])
|
||||||
self.assertEqual(dashboard["meta"]["indices"][0]["price"], 3800.12)
|
|
||||||
|
|
||||||
def test_rt_k_empty_result_falls_back_to_free_quotes(self):
|
def test_hub_quotes_used_when_rt_k_empty(self):
|
||||||
original_query = self.client.query
|
original_query = self.client.query
|
||||||
|
|
||||||
def query(api_name, params=None, fields=""):
|
def query(api_name, params=None, fields=""):
|
||||||
@@ -281,29 +303,27 @@ class RealtimeDashboardTests(unittest.TestCase):
|
|||||||
return original_query(api_name, params, fields)
|
return original_query(api_name, params, fields)
|
||||||
|
|
||||||
self.client.query = query
|
self.client.query = query
|
||||||
self.client.realtime_aggregator = FakeFreeAggregator()
|
self.client.try_market_quotes = lambda trade_date: list(FREE_QUOTES)
|
||||||
TushareClient._realtime_reference_cache.clear()
|
TushareClient._realtime_reference_cache.clear()
|
||||||
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
||||||
self.assertEqual(dashboard["meta"]["quote_source"], "eastmoney_clist")
|
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
|
||||||
self.assertEqual(str(dashboard["meta"]["trade_date"]).replace("-", ""), "20260720")
|
self.assertEqual(str(dashboard["meta"]["trade_date"]).replace("-", ""), "20260720")
|
||||||
|
|
||||||
def test_rt_k_and_free_source_failure_keeps_today_error(self):
|
def test_hub_failure_keeps_today_error(self):
|
||||||
original_query = self.client.query
|
original_query = self.client.query
|
||||||
|
|
||||||
def query(api_name, params=None, fields=""):
|
def query(api_name, params=None, fields=""):
|
||||||
if api_name == "rt_k":
|
if api_name == "rt_k":
|
||||||
raise TushareError("没有接口访问权限")
|
raise TushareError("数据中枢行情暂不可用")
|
||||||
return original_query(api_name, params, fields)
|
return original_query(api_name, params, fields)
|
||||||
|
|
||||||
self.client.query = query
|
self.client.query = query
|
||||||
self.client.realtime_aggregator = FakeFreeAggregator(fail=True)
|
|
||||||
TushareClient._realtime_reference_cache.clear()
|
TushareClient._realtime_reference_cache.clear()
|
||||||
with self.assertRaises(TushareError) as ctx:
|
with self.assertRaises(TushareError) as ctx:
|
||||||
self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
||||||
self.assertIn("当天盘中实时行情不可用", str(ctx.exception))
|
self.assertIn("当天盘中实时行情不可用", str(ctx.exception))
|
||||||
self.assertIn("没有接口访问权限", str(ctx.exception))
|
|
||||||
|
|
||||||
def test_rt_k_and_eastmoney_failure_falls_back_to_tencent(self):
|
def test_hub_failover_is_invisible_to_website(self):
|
||||||
original_query = self.client.query
|
original_query = self.client.query
|
||||||
|
|
||||||
def query(api_name, params=None, fields=""):
|
def query(api_name, params=None, fields=""):
|
||||||
@@ -311,20 +331,13 @@ class RealtimeDashboardTests(unittest.TestCase):
|
|||||||
raise TushareError("没有接口访问权限")
|
raise TushareError("没有接口访问权限")
|
||||||
return original_query(api_name, params, fields)
|
return original_query(api_name, params, fields)
|
||||||
|
|
||||||
class TencentOnlyAggregator(FakeFreeAggregator):
|
|
||||||
def eastmoney_market_quotes(self, expected_date=""):
|
|
||||||
raise RealtimeAggregateError("eastmoney blocked")
|
|
||||||
|
|
||||||
def tencent_market_quotes(self, codes, expected_date=""):
|
|
||||||
return list(FREE_QUOTES)
|
|
||||||
|
|
||||||
self.client.query = query
|
self.client.query = query
|
||||||
self.client.realtime_aggregator = TencentOnlyAggregator()
|
self.client.try_market_quotes = lambda trade_date: list(FREE_QUOTES)
|
||||||
TushareClient._realtime_reference_cache.clear()
|
TushareClient._realtime_reference_cache.clear()
|
||||||
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
dashboard = self.client._realtime_dashboard("20260720", "20260720", "20260717")
|
||||||
self.assertEqual(dashboard["meta"]["quote_source"], "tencent_qt")
|
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
|
||||||
self.assertEqual(str(dashboard["meta"]["trade_date"]).replace("-", ""), "20260720")
|
self.assertEqual(str(dashboard["meta"]["trade_date"]).replace("-", ""), "20260720")
|
||||||
self.assertIn("腾讯免费实时", dashboard["meta"]["notice"])
|
self.assertIn("数据中枢", dashboard["meta"]["notice"])
|
||||||
self.assertEqual(dashboard["overview"]["amount_billion"], 6.0)
|
self.assertEqual(dashboard["overview"]["amount_billion"], 6.0)
|
||||||
|
|
||||||
def test_normalize_eastmoney_quote_maps_units_and_exchange(self):
|
def test_normalize_eastmoney_quote_maps_units_and_exchange(self):
|
||||||
@@ -383,10 +396,9 @@ class RealtimeDashboardTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_gateway_dashboard_uses_bound_market_quotes(self) -> None:
|
def test_gateway_dashboard_uses_bound_market_quotes(self) -> None:
|
||||||
from backend.data import build_data_gateway
|
from backend.data import build_data_gateway
|
||||||
|
from backend.data.datahub.bridge import DatahubAwareTushareClient
|
||||||
from backend.data.datahub.client import DatahubResponse
|
from backend.data.datahub.client import DatahubResponse
|
||||||
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
from backend.data.datahub.settings import DATASETS, DatahubSettings, DatasetFlags
|
||||||
from backend.data.gateway import DataGateway
|
|
||||||
from backend.data.providers.tushare import TushareProvider
|
|
||||||
|
|
||||||
quotes = [
|
quotes = [
|
||||||
{
|
{
|
||||||
@@ -423,6 +435,13 @@ class RealtimeDashboardTests(unittest.TestCase):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls = []
|
self.calls = []
|
||||||
|
|
||||||
|
def query_api(self, api_name, params=None, fields=""):
|
||||||
|
rows = FakeRealtimeClient("tok").query(api_name, params or {}, fields)
|
||||||
|
return DatahubResponse(
|
||||||
|
data=rows,
|
||||||
|
meta={"source": "datahub", "stale": False, "staleness_seconds": 0, "row_shape": "tushare"},
|
||||||
|
)
|
||||||
|
|
||||||
def quotes_latest(self, **params):
|
def quotes_latest(self, **params):
|
||||||
return self.get("/v1/quotes/latest", params)
|
return self.get("/v1/quotes/latest", params)
|
||||||
|
|
||||||
@@ -438,30 +457,17 @@ class RealtimeDashboardTests(unittest.TestCase):
|
|||||||
datasets = {name: DatasetFlags(name) for name in DATASETS}
|
datasets = {name: DatasetFlags(name) for name in DATASETS}
|
||||||
datasets["quotes"] = DatasetFlags("quotes", read=True, shadow=False)
|
datasets["quotes"] = DatasetFlags("quotes", read=True, shadow=False)
|
||||||
settings = DatahubSettings(base_url="http://127.0.0.1:9", token="tok", datasets=datasets)
|
settings = DatahubSettings(base_url="http://127.0.0.1:9", token="tok", datasets=datasets)
|
||||||
base = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
|
gateway = build_data_gateway({"tushare_token": "tok"}, datahub_settings=settings)
|
||||||
gateway = DataGateway(
|
|
||||||
policy=base.policy,
|
|
||||||
quality=base.quality,
|
|
||||||
tushare_provider=TushareProvider(
|
|
||||||
lambda: "tok",
|
|
||||||
client_factory=lambda token: FakeRealtimeClient(token),
|
|
||||||
),
|
|
||||||
ifind_provider=base.ifind_provider,
|
|
||||||
chart_data=base.chart_data,
|
|
||||||
realtime_observer=base.realtime_observer,
|
|
||||||
datahub=base.datahub,
|
|
||||||
)
|
|
||||||
gateway.datahub.client = QuoteHub()
|
gateway.datahub.client = QuoteHub()
|
||||||
wrapped = gateway.tushare()
|
wrapped = gateway.tushare()
|
||||||
inner = wrapped._legacy
|
wrapped.clock = lambda: datetime(2026, 7, 20, 10, 30, tzinfo=timezone(timedelta(hours=8)))
|
||||||
inner.clock = lambda: datetime(2026, 7, 20, 10, 30, tzinfo=timezone(timedelta(hours=8)))
|
wrapped.realtime_aggregator = FakeFreeAggregator(fail=True)
|
||||||
inner.realtime_aggregator = FakeFreeAggregator(fail=True)
|
DatahubAwareTushareClient._realtime_reference_cache.clear()
|
||||||
TushareClient._realtime_reference_cache.clear()
|
|
||||||
dashboard = wrapped.dashboard("20260720")
|
dashboard = wrapped.dashboard("20260720")
|
||||||
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
|
self.assertEqual(dashboard["meta"]["quote_source"], "datahub")
|
||||||
self.assertIn("/v1/quotes/latest", gateway.datahub.client.calls)
|
self.assertIn("/v1/quotes/latest", gateway.datahub.client.calls)
|
||||||
self.assertTrue(callable(getattr(inner, "try_market_quotes", None)))
|
self.assertTrue(callable(getattr(type(wrapped), "try_market_quotes", None)))
|
||||||
self.assertFalse(hasattr(type(inner), "try_market_quotes"))
|
self.assertFalse(hasattr(wrapped, "_legacy"))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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):
|
class FixedMarketDatetime(datetime):
|
||||||
fixed_now = datetime(2026, 7, 31, 10, 30).astimezone()
|
fixed_now = datetime(2026, 7, 31, 10, 30).astimezone()
|
||||||
|
|
||||||
@@ -195,6 +207,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
self.service.ifind = None
|
self.service.ifind = None
|
||||||
self.service.realtime_aggregator = None
|
self.service.realtime_aggregator = None
|
||||||
self.service.chart_data = 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
|
RealtimeClientStub.quote_calls = 0
|
||||||
DeniedRealtimeClientStub.quote_calls = 0
|
DeniedRealtimeClientStub.quote_calls = 0
|
||||||
|
|
||||||
@@ -218,9 +232,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
"moneyflow": {},
|
"moneyflow": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||||
"backend.features.market.service.TushareClient", RealtimeClientStub
|
|
||||||
):
|
|
||||||
result = self.service._prepare_stock_detail(cached, "002141", today)
|
result = self.service._prepare_stock_detail(cached, "002141", today)
|
||||||
|
|
||||||
self.assertEqual(result["meta"]["trade_date"], FixedMarketDatetime.fixed_now.strftime("%Y-%m-%d"))
|
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},
|
"stock": {"code": "002141", "price": 10, "change": 1.2},
|
||||||
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
|
"prices": [{"trade_date": historical, "close": 10, "change": 1.2}],
|
||||||
}
|
}
|
||||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||||
"backend.features.market.service.TushareClient", RealtimeClientStub
|
|
||||||
):
|
|
||||||
result = self.service._prepare_stock_detail(payload, "002141", historical)
|
result = self.service._prepare_stock_detail(payload, "002141", historical)
|
||||||
|
|
||||||
self.assertEqual(result["stock"]["change"], 1.2)
|
self.assertEqual(result["stock"]["change"], 1.2)
|
||||||
@@ -279,9 +289,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
with patch("backend.features.market.service.datetime", FixedPreopenDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedPreopenDatetime):
|
||||||
"backend.features.market.service.TushareClient", RealtimeClientStub
|
|
||||||
):
|
|
||||||
result = self.service._prepare_stock_detail(payload, "002141", today)
|
result = self.service._prepare_stock_detail(payload, "002141", today)
|
||||||
|
|
||||||
self.assertEqual(result["meta"]["trade_date"], yesterday)
|
self.assertEqual(result["meta"]["trade_date"], yesterday)
|
||||||
@@ -294,11 +302,10 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
|
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
|
||||||
aggregator = FreeQuoteAggregator(_free_quote())
|
aggregator = FreeQuoteAggregator(_free_quote())
|
||||||
self.service.realtime_aggregator = aggregator
|
self.service.realtime_aggregator = aggregator
|
||||||
|
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||||
DeniedRealtimeClientStub.quote_calls = 0
|
DeniedRealtimeClientStub.quote_calls = 0
|
||||||
|
|
||||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
|
||||||
):
|
|
||||||
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
||||||
|
|
||||||
bar = result["prices"][-1]
|
bar = result["prices"][-1]
|
||||||
@@ -319,14 +326,18 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
def test_today_detail_falls_back_to_eastmoney_then_intraday(self):
|
def test_today_detail_falls_back_to_eastmoney_then_intraday(self):
|
||||||
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
|
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
|
||||||
aggregator = FreeQuoteAggregator(
|
aggregator = FreeQuoteAggregator(
|
||||||
_free_quote("eastmoney_stock", ts_code="600000.SH", name="浦发银行"),
|
_free_quote(
|
||||||
|
"eastmoney_stock",
|
||||||
|
ts_code="600000.SH",
|
||||||
|
name="浦发银行",
|
||||||
|
net_mf_amount=12,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.service.realtime_aggregator = aggregator
|
self.service.realtime_aggregator = aggregator
|
||||||
|
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||||
DeniedRealtimeClientStub.quote_calls = 0
|
DeniedRealtimeClientStub.quote_calls = 0
|
||||||
|
|
||||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
|
||||||
):
|
|
||||||
result = self.service._prepare_stock_detail(_history_payload("600000"), "600000", today)
|
result = self.service._prepare_stock_detail(_history_payload("600000"), "600000", today)
|
||||||
|
|
||||||
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
|
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
|
||||||
@@ -360,9 +371,7 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
|
||||||
):
|
|
||||||
result = self.service._prepare_stock_detail(_history_payload("300750"), "300750", today)
|
result = self.service._prepare_stock_detail(_history_payload("300750"), "300750", today)
|
||||||
|
|
||||||
bar = result["prices"][-1]
|
bar = result["prices"][-1]
|
||||||
@@ -379,11 +388,10 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
|
today = FixedMarketDatetime.fixed_now.strftime("%Y%m%d")
|
||||||
self.service.realtime_aggregator = FreeQuoteAggregator(fail=True)
|
self.service.realtime_aggregator = FreeQuoteAggregator(fail=True)
|
||||||
self.service.chart_data = IntradayChartStub([], trade_date="2026-07-30")
|
self.service.chart_data = IntradayChartStub([], trade_date="2026-07-30")
|
||||||
|
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||||
DeniedRealtimeClientStub.quote_calls = 0
|
DeniedRealtimeClientStub.quote_calls = 0
|
||||||
|
|
||||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
|
||||||
):
|
|
||||||
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
||||||
|
|
||||||
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-30")
|
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-30")
|
||||||
@@ -396,10 +404,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
self.service.realtime_aggregator = FreeQuoteAggregator(
|
self.service.realtime_aggregator = FreeQuoteAggregator(
|
||||||
_free_quote(quote_time_epoch=int(datetime(2026, 7, 31, 11, 30).timestamp()))
|
_free_quote(quote_time_epoch=int(datetime(2026, 7, 31, 11, 30).timestamp()))
|
||||||
)
|
)
|
||||||
|
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||||
DeniedRealtimeClientStub.quote_calls = 0
|
DeniedRealtimeClientStub.quote_calls = 0
|
||||||
with patch("backend.features.market.service.datetime", FixedLunchDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedLunchDatetime):
|
||||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
|
||||||
):
|
|
||||||
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
result = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
||||||
|
|
||||||
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
|
self.assertEqual(result["prices"][-1]["trade_date"], "2026-07-31")
|
||||||
@@ -408,10 +415,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
def test_after_close_keeps_forming_bar_until_official_ready(self):
|
def test_after_close_keeps_forming_bar_until_official_ready(self):
|
||||||
today = FixedAfterCloseDatetime.fixed_now.strftime("%Y%m%d")
|
today = FixedAfterCloseDatetime.fixed_now.strftime("%Y%m%d")
|
||||||
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
|
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
|
||||||
|
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||||
DeniedRealtimeClientStub.quote_calls = 0
|
DeniedRealtimeClientStub.quote_calls = 0
|
||||||
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime):
|
||||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
|
||||||
):
|
|
||||||
forming = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
forming = self.service._prepare_stock_detail(_history_payload(), "002141", today)
|
||||||
self.assertEqual(forming["prices"][-1]["trade_date"], "2026-07-31")
|
self.assertEqual(forming["prices"][-1]["trade_date"], "2026-07-31")
|
||||||
self.assertTrue(forming["prices"][-1]["realtime"])
|
self.assertTrue(forming["prices"][-1]["realtime"])
|
||||||
@@ -430,9 +436,8 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
RealtimeClientStub.quote_calls = 0
|
RealtimeClientStub.quote_calls = 0
|
||||||
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime), patch(
|
self.service._market_client_override = RealtimeClientStub("datahub")
|
||||||
"backend.features.market.service.TushareClient", RealtimeClientStub
|
with patch("backend.features.market.service.datetime", FixedAfterCloseDatetime):
|
||||||
):
|
|
||||||
replaced = self.service._prepare_stock_detail(official, "002141", today)
|
replaced = self.service._prepare_stock_detail(official, "002141", today)
|
||||||
|
|
||||||
self.assertEqual(replaced["prices"][-1]["close"], 10.7)
|
self.assertEqual(replaced["prices"][-1]["close"], 10.7)
|
||||||
@@ -457,10 +462,9 @@ class StockDetailRealtimeTests(unittest.TestCase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
|
self.service.realtime_aggregator = FreeQuoteAggregator(_free_quote())
|
||||||
|
self.service._market_client_override = DeniedRealtimeClientStub("datahub")
|
||||||
DeniedRealtimeClientStub.quote_calls = 0
|
DeniedRealtimeClientStub.quote_calls = 0
|
||||||
with patch("backend.features.market.service.datetime", FixedMarketDatetime), patch(
|
with patch("backend.features.market.service.datetime", FixedMarketDatetime):
|
||||||
"backend.features.market.service.TushareClient", DeniedRealtimeClientStub
|
|
||||||
):
|
|
||||||
result = self.service._prepare_stock_detail(payload, "002141", today)
|
result = self.service._prepare_stock_detail(payload, "002141", today)
|
||||||
|
|
||||||
self.assertEqual(len(result["prices"]), 2)
|
self.assertEqual(len(result["prices"]), 2)
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ registry, and verification tools.
|
|||||||
`backend/features/*/routes.py` owners.
|
`backend/features/*/routes.py` owners.
|
||||||
- `python tools/build_architecture_inventory.py [--check]`: generate or verify
|
- `python tools/build_architecture_inventory.py [--check]`: generate or verify
|
||||||
`config/architecture-inventory.json` from the current source tree.
|
`config/architecture-inventory.json` from the current source tree.
|
||||||
|
- `python tools/verify_datahub_console.py`: end-to-end self-test for the Data Hub console
|
||||||
|
bridge — invite-code single use, admin-only gate, credential masking, model pool and
|
||||||
|
membership round-trips, service-token checks. Starts both services on temporary ports with
|
||||||
|
temporary data directories and restores the repository state on exit.
|
||||||
|
- `python tools/verify_datahub_console_ui.py [--shots <dir>]`: the browser pass over the same
|
||||||
|
sandbox (gate, credential editor, vendor model pool, members and invite codes, day/night
|
||||||
|
themes, 1030px narrow layout). Requires Playwright and a local Chromium.
|
||||||
- `python tools/backfill_recent_snapshots.py --account <admin> [--lookback 60] [--dry-run]`:
|
- `python tools/backfill_recent_snapshots.py --account <admin> [--lookback 60] [--dry-run]`:
|
||||||
auditable recent trading-day dashboard snapshot backfill. See
|
auditable recent trading-day dashboard snapshot backfill. See
|
||||||
`docs/maintenance/行情历史补档.md`.
|
`docs/maintenance/行情历史补档.md`.
|
||||||
|
|||||||
@@ -218,11 +218,10 @@ def build() -> dict[str, Any]:
|
|||||||
),
|
),
|
||||||
"external_data_adapters": [
|
"external_data_adapters": [
|
||||||
{"provider": "tushare", "path": "backend/data/providers/tushare_client.py", "runtime_role": "stable client facade for primary deterministic market data"},
|
{"provider": "tushare", "path": "backend/data/providers/tushare_client.py", "runtime_role": "stable client facade for primary deterministic market data"},
|
||||||
{"provider": "datahub", "path": "backend/data/datahub/client.py", "runtime_role": "optional official EOD read path behind per-dataset flags"},
|
{"provider": "datahub", "path": "backend/data/datahub/client.py", "runtime_role": "website-only read path; official EOD, live quotes, and licensed iFinD"},
|
||||||
{"provider": "ifind", "path": "backend/data/providers/ifind_client.py", "runtime_role": "realtime, charts, snapshots, enrichment"},
|
{"provider": "ifind", "path": "xiaobai-datahub/datahub/adapters/ifind.py", "runtime_role": "licensed iFinD source inside the data hub"},
|
||||||
{"provider": "eastmoney", "path": "backend/features/market/charts.py", "runtime_role": "display chart fallback"},
|
{"provider": "eastmoney", "path": "xiaobai-datahub/datahub/adapters/eastmoney.py", "runtime_role": "free realtime quotes and shenwan inside the data hub"},
|
||||||
{"provider": "eastmoney", "path": "backend/data/realtime.py", "runtime_role": "isolated realtime observation and intraday dashboard fallback"},
|
{"provider": "tencent", "path": "xiaobai-datahub/datahub/adapters/tencent.py", "runtime_role": "free index and stock quotes inside the data hub"},
|
||||||
{"provider": "tencent", "path": "backend/data/realtime.py", "runtime_role": "index observation and intraday quote fallback"},
|
|
||||||
],
|
],
|
||||||
"provider_domains": [
|
"provider_domains": [
|
||||||
{"provider": "tushare", "path": "backend/data/providers/tushare_transport.py", "responsibility": "HTTP transport and provider errors"},
|
{"provider": "tushare", "path": "backend/data/providers/tushare_transport.py", "responsibility": "HTTP transport and provider errors"},
|
||||||
@@ -240,9 +239,9 @@ def build() -> dict[str, Any]:
|
|||||||
{"client": "DatahubClient", "owner": "backend/data/gateway.py"},
|
{"client": "DatahubClient", "owner": "backend/data/gateway.py"},
|
||||||
{"client": "DatahubBridge", "owner": "backend/data/gateway.py"},
|
{"client": "DatahubBridge", "owner": "backend/data/gateway.py"},
|
||||||
{"client": "DatahubAwareTushareClient", "owner": "backend/data/gateway.py"},
|
{"client": "DatahubAwareTushareClient", "owner": "backend/data/gateway.py"},
|
||||||
{"client": "IfindHttpClient", "owner": "backend/data/gateway.py"},
|
{"client": "HubIfindProxy", "owner": "backend/data/gateway.py"},
|
||||||
|
{"client": "HubRealtimeProxy", "owner": "backend/data/gateway.py"},
|
||||||
{"client": "MarketChartClient", "owner": "backend/data/gateway.py"},
|
{"client": "MarketChartClient", "owner": "backend/data/gateway.py"},
|
||||||
{"client": "WebRealtimeAggregator", "owner": "backend/data/gateway.py"},
|
|
||||||
],
|
],
|
||||||
"heaven_service_owners": {
|
"heaven_service_owners": {
|
||||||
"facade": "backend/features/heaven/service.py",
|
"facade": "backend/features/heaven/service.py",
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
"""数据中枢控制台端到端自测:主站与中枢真的对话一遍,不是 mock。
|
||||||
|
|
||||||
|
跑法:python tools/verify_datahub_console.py
|
||||||
|
覆盖邀请码一次性注册、管理员门禁、凭证掩码、模型池与会员桥接读写、服务令牌校验。
|
||||||
|
两个服务都起在临时端口 + 临时数据目录,跑完自动清理,不碰任何现网数据。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import http.cookies
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
# 从 tools/ 下运行,仓库根不在 sys.path 上;主站包按仓库根导入。
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
HUB_TOKEN = "smoke-hub-admin-token-0123456789abcdef"
|
||||||
|
PASSWORD = "SmokePass123"
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def free_port() -> int:
|
||||||
|
with socket.socket() as sock:
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
return int(sock.getsockname()[1])
|
||||||
|
|
||||||
|
|
||||||
|
def check(label: str, ok: bool, detail: str = "") -> None:
|
||||||
|
print(f" {'PASS' if ok else 'FAIL'} {label}{(' — ' + detail) if detail else ''}")
|
||||||
|
if not ok:
|
||||||
|
FAILURES.append(label)
|
||||||
|
|
||||||
|
|
||||||
|
def request(url: str, payload=None, method="GET", headers=None, cookie="") -> tuple[int, dict, str]:
|
||||||
|
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
||||||
|
req = urllib.request.Request(url, data=data, method=method)
|
||||||
|
req.add_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
for key, value in (headers or {}).items():
|
||||||
|
req.add_header(key, value)
|
||||||
|
if cookie:
|
||||||
|
req.add_header("Cookie", cookie)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as response:
|
||||||
|
raw = response.read().decode("utf-8")
|
||||||
|
set_cookie = response.headers.get("Set-Cookie") or ""
|
||||||
|
return response.status, _parse(raw), set_cookie
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
return exc.code, _parse(exc.read().decode("utf-8")), exc.headers.get("Set-Cookie") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(raw: str) -> dict:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {"_raw": raw[:200]}
|
||||||
|
return parsed if isinstance(parsed, dict) else {"_list": parsed}
|
||||||
|
|
||||||
|
|
||||||
|
def session_cookie(header: str) -> str:
|
||||||
|
jar = http.cookies.SimpleCookie()
|
||||||
|
jar.load(header)
|
||||||
|
morsel = jar.get("xiaobai_session")
|
||||||
|
return f"xiaobai_session={morsel.value}" if morsel else ""
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for(url: str, seconds: float = 20.0) -> bool:
|
||||||
|
deadline = time.time() + seconds
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(url, timeout=2)
|
||||||
|
return True
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.25)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
REVIEW_DB = ROOT / "data" / "review.db"
|
||||||
|
ENV_FILE = ROOT / ".env"
|
||||||
|
|
||||||
|
|
||||||
|
class RepoSandbox:
|
||||||
|
"""主站的库路径和 .env 都写死在仓库里,跑之前挪开、跑完原样放回。"""
|
||||||
|
|
||||||
|
def __enter__(self) -> "RepoSandbox":
|
||||||
|
self.stash = Path(tempfile.mkdtemp(prefix="hel560-stash-"))
|
||||||
|
for path in (REVIEW_DB, ENV_FILE):
|
||||||
|
if path.exists():
|
||||||
|
shutil.copy2(path, self.stash / path.name)
|
||||||
|
if REVIEW_DB.exists():
|
||||||
|
REVIEW_DB.unlink() # 自测需要一个空库来验证"首个账号免邀请码"
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc_info: object) -> None:
|
||||||
|
for path in (REVIEW_DB, ENV_FILE):
|
||||||
|
saved = self.stash / path.name
|
||||||
|
if saved.exists():
|
||||||
|
shutil.copy2(saved, path)
|
||||||
|
elif path.exists():
|
||||||
|
path.unlink()
|
||||||
|
for extra in REVIEW_DB.parent.glob("review.db-*"):
|
||||||
|
extra.unlink()
|
||||||
|
shutil.rmtree(self.stash, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def start_review(workdir: Path, port: int) -> None:
|
||||||
|
os.environ["HUB_ADMIN_TOKEN"] = HUB_TOKEN
|
||||||
|
from backend.application import RequestHandler, SERVICE # noqa: F401
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", port), RequestHandler)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def start_hub(workdir: Path, port: int, review_port: int) -> None:
|
||||||
|
sys.path.insert(0, str(ROOT / "xiaobai-datahub"))
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
os.environ.update(
|
||||||
|
{
|
||||||
|
"DATAHUB_DB_PATH": str(workdir / "hub.db"),
|
||||||
|
"DATAHUB_BACKUP_DIR": str(workdir / "backups"),
|
||||||
|
"DATAHUB_ENCRYPTION_KEY": Fernet.generate_key().decode(),
|
||||||
|
"DATAHUB_TOKEN": "smoke-datahub-token",
|
||||||
|
"HUB_ADMIN_TOKEN": HUB_TOKEN,
|
||||||
|
"REVIEW_BASE_URL": f"http://127.0.0.1:{review_port}",
|
||||||
|
"REVIEW_PUBLIC_URL": f"http://127.0.0.1:{review_port}",
|
||||||
|
"DATAHUB_SCHEDULER_ENABLED": "0",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
from datahub.httpapp import make_handler
|
||||||
|
from datahub.hub import Hub
|
||||||
|
from datahub.settings import load_settings
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
hub = Hub(load_settings(os.environ))
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", port), make_handler(hub))
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if any(argument in {"-h", "--help"} for argument in sys.argv[1:]):
|
||||||
|
print(__doc__.strip())
|
||||||
|
return 0
|
||||||
|
workdir = Path(tempfile.mkdtemp(prefix="hel560-smoke-"))
|
||||||
|
review_port, hub_port = free_port(), free_port()
|
||||||
|
review = f"http://127.0.0.1:{review_port}"
|
||||||
|
hub = f"http://127.0.0.1:{hub_port}"
|
||||||
|
with RepoSandbox():
|
||||||
|
start_review(workdir, review_port)
|
||||||
|
if not wait_for(f"{review}/api/session"):
|
||||||
|
print("主站没起来")
|
||||||
|
return 1
|
||||||
|
start_hub(workdir, hub_port, review_port)
|
||||||
|
if not wait_for(f"{hub}/livez"):
|
||||||
|
print("数据中枢没起来")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("\n[1] 首个账号免邀请码,之后注册强制邀请码")
|
||||||
|
status, body, cookie_header = request(f"{review}/api/auth/register", {"username": "boss", "password": PASSWORD}, "POST")
|
||||||
|
check("首个账号可直接注册(自动成为管理员)", status == 201, f"{status} {body.get('error', '')}")
|
||||||
|
admin_cookie = session_cookie(cookie_header)
|
||||||
|
status, body, _ = request(f"{review}/api/auth/register", {"username": "nobody", "password": PASSWORD}, "POST")
|
||||||
|
check("第二个账号没邀请码被拒", status >= 400 and "邀请码" in str(body.get("error", "")), f"{status} {body.get('error', '')}")
|
||||||
|
|
||||||
|
print("\n[2] 未登录 / 非管理员进不了数据中枢")
|
||||||
|
status, body, _ = request(f"{hub}/admin/api/session")
|
||||||
|
check("未登录访问控制台返回 401 并给出主站登录地址", status == 401 and "/login/" in str(body.get("login_url", "")), f"{status} {body}")
|
||||||
|
|
||||||
|
print("\n[3] 主站管理员会话直接进控制台(跨端口共享 cookie)")
|
||||||
|
status, session, _ = request(f"{hub}/admin/api/session", cookie=admin_cookie)
|
||||||
|
check("带主站会话访问控制台返回 200", status == 200, f"{status} {session}")
|
||||||
|
check("控制台回显主站用户名", session.get("username") == "boss", str(session.get("username")))
|
||||||
|
csrf = str(session.get("csrf") or "")
|
||||||
|
check("下发了 CSRF 令牌", len(csrf) >= 32, csrf[:12])
|
||||||
|
write_headers = {"X-CSRF-Token": csrf}
|
||||||
|
|
||||||
|
print("\n[4] 写接口必须带 CSRF")
|
||||||
|
status, body, _ = request(f"{hub}/admin/api/invites/create", {"count": 1}, "POST", cookie=admin_cookie)
|
||||||
|
check("缺 CSRF 的写请求被拒", status == 401, f"{status} {body}")
|
||||||
|
|
||||||
|
print("\n[5] 控制台生成邀请码 → 注册消耗一次 → 二次使用失败")
|
||||||
|
status, created, _ = request(f"{hub}/admin/api/invites/create", {"count": 2}, "POST", write_headers, admin_cookie)
|
||||||
|
check("控制台生成邀请码成功", status == 200 and len(created.get("created") or []) == 2, f"{status} {created.get('error', '')}")
|
||||||
|
codes = [item["code"] for item in created.get("created") or []]
|
||||||
|
check("列表只给掩码,不回明文", all("•" in row["code_masked"] for row in created.get("codes") or []))
|
||||||
|
status, body, member_cookie_header = request(
|
||||||
|
f"{review}/api/auth/register", {"username": "xiaochen", "password": PASSWORD, "invite_code": codes[0]}, "POST"
|
||||||
|
)
|
||||||
|
check("凭邀请码注册成功", status == 201, f"{status} {body.get('error', '')}")
|
||||||
|
member_cookie = session_cookie(member_cookie_header)
|
||||||
|
status, body, _ = request(
|
||||||
|
f"{review}/api/auth/register", {"username": "again", "password": PASSWORD, "invite_code": codes[0]}, "POST"
|
||||||
|
)
|
||||||
|
check("同一邀请码第二次注册被拒", status >= 400, f"{status} {body.get('error', '')}")
|
||||||
|
|
||||||
|
print("\n[6] 作废后的邀请码不能注册")
|
||||||
|
handles = {row["code_masked"][:7]: row["code_id"] for row in created.get("codes") or []}
|
||||||
|
target = handles.get(codes[1][:7])
|
||||||
|
status, body, _ = request(f"{hub}/admin/api/invites/revoke", {"code_id": target}, "POST", write_headers, admin_cookie)
|
||||||
|
check("控制台作废未使用的邀请码", status == 200, f"{status} {body.get('error', '')}")
|
||||||
|
status, body, _ = request(
|
||||||
|
f"{review}/api/auth/register", {"username": "revoked", "password": PASSWORD, "invite_code": codes[1]}, "POST"
|
||||||
|
)
|
||||||
|
check("已作废邀请码无法注册", status >= 400, f"{status} {body.get('error', '')}")
|
||||||
|
|
||||||
|
print("\n[7] 普通会员账号进不了控制台")
|
||||||
|
status, body, _ = request(f"{hub}/admin/api/session", cookie=member_cookie)
|
||||||
|
check("非管理员访问控制台返回 403", status == 403, f"{status} {body}")
|
||||||
|
status, body, _ = request(f"{hub}/admin/api/members", cookie=member_cookie)
|
||||||
|
check("非管理员读会员接口同样 403", status == 403, f"{status} {body}")
|
||||||
|
|
||||||
|
print("\n[8] 数据源凭证在线写入 + 掩码回显")
|
||||||
|
status, body, _ = request(
|
||||||
|
f"{hub}/admin/api/credentials/tushare", {"tushare_token": "tok-abcdefgh1234"}, "POST", write_headers, admin_cookie
|
||||||
|
)
|
||||||
|
check("Tushare Token 保存成功", status == 200, f"{status} {body.get('error', '')}")
|
||||||
|
status, sources, _ = request(f"{hub}/admin/api/sources", cookie=admin_cookie)
|
||||||
|
tushare = next((row for row in sources.get("items") or [] if row.get("provider") == "tushare"), {})
|
||||||
|
credential = tushare.get("credential") or {}
|
||||||
|
check("数据源卡回显掩码而非明文", credential.get("configured") and "1234" in str(credential.get("last4")), str(credential))
|
||||||
|
check("接口不回传明文 Token", "tok-abcdefgh1234" not in json.dumps(sources, ensure_ascii=False))
|
||||||
|
|
||||||
|
print("\n[9] 模型池 / 会员 / 邀请码三页都能从控制台读到")
|
||||||
|
for label, path in (("模型池", "/admin/api/models"), ("会员", "/admin/api/members"), ("邀请码", "/admin/api/invites")):
|
||||||
|
status, body, _ = request(f"{hub}{path}", cookie=admin_cookie)
|
||||||
|
check(f"{label}接口可读", status == 200, f"{status} {body.get('error', '')}")
|
||||||
|
|
||||||
|
print("\n[10] 控制台改模型池 → 主站落库")
|
||||||
|
models = [{"id": "smoke-main", "name": "冒烟主模型", "model": "gpt-4o", "base_url": "https://api.openai.com/v1", "api_key": "sk-smoke-key-9911"}]
|
||||||
|
status, body, _ = request(
|
||||||
|
f"{hub}/admin/api/models/save", {"models": models, "primary_model_id": "smoke-main"}, "POST", write_headers, admin_cookie
|
||||||
|
)
|
||||||
|
check("控制台保存模型池成功", status == 200, f"{status} {body.get('error', '')}")
|
||||||
|
groups = body.get("groups") or []
|
||||||
|
check("按供应商归组返回", len(groups) == 1 and groups[0]["base_url"] == "https://api.openai.com/v1", str(groups)[:120])
|
||||||
|
check("供应商显示密钥后四位而非明文", groups and groups[0].get("key_last4") == "9911", str(groups[0].get("key_last4") if groups else ""))
|
||||||
|
check("模型接口不回传明文密钥", "sk-smoke-key-9911" not in json.dumps(body, ensure_ascii=False))
|
||||||
|
status, status_body, _ = request(
|
||||||
|
f"{review}/api/admin/settings", None, "GET", {"X-Hub-Admin-Token": HUB_TOKEN}, admin_cookie
|
||||||
|
)
|
||||||
|
status, mainsite, _ = request(f"{review}/api/hub-admin/status", {}, "POST", {"X-Hub-Admin-Token": HUB_TOKEN})
|
||||||
|
pool = (mainsite.get("llm") or {}).get("models") or []
|
||||||
|
check("主站确实存下了这个模型", any(m["id"] == "smoke-main" for m in pool), str([m.get("id") for m in pool]))
|
||||||
|
|
||||||
|
print("\n[11] 会员额度与会员开通经控制台落到主站")
|
||||||
|
status, body, _ = request(f"{hub}/admin/api/members/quota", {"member_daily_limit": 88}, "POST", write_headers, admin_cookie)
|
||||||
|
check("保存会员每日额度成功", status == 200 and (body.get("membership") or {}).get("member_daily_limit") == 88, f"{status} {body.get('membership')}")
|
||||||
|
member_id = next((u["id"] for u in body.get("users") or [] if u["username"] == "xiaochen"), 0)
|
||||||
|
status, body, _ = request(
|
||||||
|
f"{hub}/admin/api/members/save", {"user_id": member_id, "status": "active", "duration": "3_months"}, "POST", write_headers, admin_cookie
|
||||||
|
)
|
||||||
|
row = next((u for u in body.get("users") or [] if u["id"] == member_id), {})
|
||||||
|
check("开通 3 个月会员生效", status == 200 and row.get("membership_status") == "active" and row.get("membership_expires_at"), f"{status} {row.get('membership_status')} {row.get('membership_expires_at')}")
|
||||||
|
|
||||||
|
print("\n[12] 桥接令牌是唯一信任边界")
|
||||||
|
status, body, _ = request(f"{review}/api/hub-admin/status", {}, "POST", {"X-Hub-Admin-Token": "wrong-token"})
|
||||||
|
check("桥接端点拒绝错误令牌", status == 401, f"{status} {body}")
|
||||||
|
status, body, _ = request(f"{review}/api/hub-admin/status", {}, "POST")
|
||||||
|
check("桥接端点拒绝无令牌", status == 401, f"{status} {body}")
|
||||||
|
status, body, _ = request(f"{review}/api/hub-admin/invites", {}, "POST", {"X-Hub-Admin-Token": HUB_TOKEN}, admin_cookie)
|
||||||
|
check("带正确令牌可读邀请码", status == 200, f"{status} {body.get('error', '')}")
|
||||||
|
|
||||||
|
print("\n[13] 退出登录会真的销毁主站会话")
|
||||||
|
status, body, _ = request(f"{hub}/admin/api/logout", {}, "POST", write_headers, admin_cookie)
|
||||||
|
check("控制台退出返回主站登录地址", status == 200 and "/login/" in str(body.get("login_url", "")), f"{status} {body}")
|
||||||
|
status, body, _ = request(f"{review}/api/session", cookie=admin_cookie)
|
||||||
|
check("主站会话已失效", not (body.get("authenticated") or body.get("user")), str(body)[:120])
|
||||||
|
|
||||||
|
print("\n[14] 并发使用同一邀请码只成功一次")
|
||||||
|
os.environ["HUB_ADMIN_TOKEN"] = HUB_TOKEN
|
||||||
|
with sqlite3.connect(REVIEW_DB) as connection:
|
||||||
|
rows = connection.execute("SELECT status, COUNT(*) FROM invite_codes GROUP BY status").fetchall()
|
||||||
|
counts = dict(rows)
|
||||||
|
check("邀请码状态落库正确(1 已用 / 1 已作废)", counts.get("used") == 1 and counts.get("revoked") == 1, str(counts))
|
||||||
|
shutil.rmtree(workdir, ignore_errors=True)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
if FAILURES:
|
||||||
|
print(f"FAILED {len(FAILURES)} 项:")
|
||||||
|
for item in FAILURES:
|
||||||
|
print(" - " + item)
|
||||||
|
return 1
|
||||||
|
print("数据中枢控制台端到端自测全部通过")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
"""数据中枢控制台浏览器自测:真的打开控制台,点一遍新页面。
|
||||||
|
|
||||||
|
跑法:python tools/verify_datahub_console_ui.py [--shots 目录]
|
||||||
|
覆盖门禁、凭证区、模型池(拉取失败→手动录入)、会员与邀请码、日夜主题、1030 窄屏。
|
||||||
|
与 verify_datahub_console.py 共用沙箱:临时端口 + 临时库,跑完把仓库状态原样放回。
|
||||||
|
需要 Playwright 与本地 Chromium。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import verify_datahub_console as backend
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
def chrome_path() -> str | None:
|
||||||
|
"""Playwright 默认渠道是 msedge;这里优先用它自带的 chromium。
|
||||||
|
|
||||||
|
CHROMIUM_PATH 可显式指定;否则在 Playwright 缓存里找一份(版本号会随
|
||||||
|
Playwright 升级变化,所以按目录名匹配而不写死)。缺 GTK 系 so 时,用
|
||||||
|
LD_LIBRARY_PATH 指向本地补齐的库目录再跑本脚本。
|
||||||
|
"""
|
||||||
|
explicit = os.environ.get("CHROMIUM_PATH")
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
cache = Path.home() / ".cache/ms-playwright"
|
||||||
|
builds = sorted(cache.glob("chromium-*/chrome-linux*/chrome"), reverse=True)
|
||||||
|
return str(builds[0]) if builds else None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def check(label: str, ok: bool, detail: str = "") -> None:
|
||||||
|
print(f" {'PASS' if ok else 'FAIL'} {label}{(' — ' + detail) if detail else ''}")
|
||||||
|
if not ok:
|
||||||
|
FAILURES.append(label)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if any(argument in {"-h", "--help"} for argument in sys.argv[1:]):
|
||||||
|
print(__doc__.strip())
|
||||||
|
return 0
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
shots = Path(sys.argv[sys.argv.index("--shots") + 1]) if "--shots" in sys.argv else None
|
||||||
|
if shots:
|
||||||
|
shots.mkdir(parents=True, exist_ok=True)
|
||||||
|
workdir = Path(backend.tempfile.mkdtemp(prefix="hel560-ui-"))
|
||||||
|
review_port, hub_port = backend.free_port(), backend.free_port()
|
||||||
|
review, hub = f"http://127.0.0.1:{review_port}", f"http://127.0.0.1:{hub_port}"
|
||||||
|
with backend.RepoSandbox():
|
||||||
|
backend.start_review(workdir, review_port)
|
||||||
|
backend.wait_for(f"{review}/api/session")
|
||||||
|
backend.start_hub(workdir, hub_port, review_port)
|
||||||
|
backend.wait_for(f"{hub}/livez")
|
||||||
|
|
||||||
|
status, _, cookie_header = backend.request(
|
||||||
|
f"{review}/api/auth/register", {"username": "boss", "password": backend.PASSWORD}, "POST"
|
||||||
|
)
|
||||||
|
admin_cookie = backend.session_cookie(cookie_header).split("=", 1)[1]
|
||||||
|
|
||||||
|
with sync_playwright() as play:
|
||||||
|
browser = play.chromium.launch(executable_path=chrome_path())
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
def new_page(width: int, logged_in: bool):
|
||||||
|
context = browser.new_context(viewport={"width": width, "height": 900})
|
||||||
|
if logged_in:
|
||||||
|
context.add_cookies([
|
||||||
|
{"name": "xiaobai_session", "value": admin_cookie, "domain": "127.0.0.1", "path": "/"}
|
||||||
|
])
|
||||||
|
page = context.new_page()
|
||||||
|
page.on("pageerror", lambda exc: errors.append(f"{width}px pageerror: {exc}"))
|
||||||
|
page.on("console", lambda msg: errors.append(f"{width}px console.{msg.type}: {msg.text}")
|
||||||
|
if msg.type == "error" else None)
|
||||||
|
page.on("response", lambda res: errors.append(f"{width}px HTTP {res.status} {res.url}")
|
||||||
|
if res.status >= 400 else None)
|
||||||
|
return context, page
|
||||||
|
|
||||||
|
print("\n[UI-1] 未登录时只看到门禁,不再有独立登录表单")
|
||||||
|
context, page = new_page(1440, logged_in=False)
|
||||||
|
page.goto(f"{hub}/admin/", wait_until="networkidle")
|
||||||
|
check("门禁面板可见", page.is_visible("#gate-view"))
|
||||||
|
check("控制台外壳隐藏", page.is_hidden("#appRoot"))
|
||||||
|
check("提示去主站登录", "登录" in page.inner_text("#gate-desc"), page.inner_text("#gate-desc")[:40])
|
||||||
|
check("给出主站登录链接", "8765" in (page.get_attribute("#gate-login", "href") or "") or
|
||||||
|
str(review_port) in (page.get_attribute("#gate-login", "href") or ""))
|
||||||
|
check("页面里没有独立账号输入框", page.locator("#login-form, #change-form").count() == 0)
|
||||||
|
if shots:
|
||||||
|
page.screenshot(path=str(shots / "ui-gate.png"), full_page=True)
|
||||||
|
context.close()
|
||||||
|
|
||||||
|
print("\n[UI-2] 带主站管理员会话直接进控制台")
|
||||||
|
context, page = new_page(1440, logged_in=True)
|
||||||
|
page.goto(f"{hub}/admin/", wait_until="networkidle")
|
||||||
|
page.wait_for_selector("#appRoot:not([hidden])", timeout=15000)
|
||||||
|
check("控制台外壳渲染", page.is_visible("#appRoot"))
|
||||||
|
check("右上角显示主站用户名", page.inner_text("#who").strip() == "boss", page.inner_text("#who"))
|
||||||
|
check("导航含模型池与会员管理", page.locator('[data-nav="models"]').count() == 1
|
||||||
|
and page.locator('[data-nav="members"]').count() == 1)
|
||||||
|
|
||||||
|
print("\n[UI-3] 数据源页带可编辑凭证区")
|
||||||
|
page.click('[data-nav="sources"]')
|
||||||
|
page.wait_for_selector('[data-cred-form="tushare"]', timeout=10000)
|
||||||
|
check("Tushare 卡出现凭证输入框", page.locator('[data-cred-input="tushare_token"]').count() == 1)
|
||||||
|
check("iFinD 卡也能在线填凭证", page.locator('[data-cred-input="ifind_refresh_token"]').count() == 1)
|
||||||
|
check("凭证输入是密码框(不回显明文)",
|
||||||
|
page.get_attribute('[data-cred-input="tushare_token"]', "type") == "password")
|
||||||
|
check("原有接口清单没被删掉", page.locator("table.dtable").count() >= 1)
|
||||||
|
if shots:
|
||||||
|
page.screenshot(path=str(shots / "ui-sources-night.png"), full_page=True)
|
||||||
|
|
||||||
|
print("\n[UI-4] 模型池按供应商组织,可拉取/手填")
|
||||||
|
page.click('[data-nav="models"]')
|
||||||
|
page.wait_for_selector("#addVendor", timeout=10000)
|
||||||
|
check("有新增供应商入口", page.is_visible("#addVendor"))
|
||||||
|
check("有调用编排(主/辅模型)", page.locator("#primaryModel").count() == 1 and page.locator("#fallbackModel").count() == 1)
|
||||||
|
check("顶部条给出供应商与模型数", "供应商" in page.inner_text(".strip") and "主模型" in page.inner_text(".strip"))
|
||||||
|
page.select_option("#newVendorPreset", "https://api.openai.com/v1")
|
||||||
|
page.click("#addVendor")
|
||||||
|
page.wait_for_selector('[data-vend-fetch="https://api.openai.com/v1"]', timeout=10000)
|
||||||
|
check("供应商卡有获取模型列表按钮", page.is_visible('[data-vend-fetch="https://api.openai.com/v1"]'))
|
||||||
|
check("供应商卡有 BASE URL 与 API KEY 两个字段",
|
||||||
|
page.locator('[data-vend-url="https://api.openai.com/v1"]').count() == 1
|
||||||
|
and page.locator('[data-vend-key="https://api.openai.com/v1"]').count() == 1)
|
||||||
|
check("供应商卡可单独保存", page.is_visible('[data-vend-save="https://api.openai.com/v1"]'))
|
||||||
|
|
||||||
|
print("\n[UI-4b] 拉取失败后退回卡内手动录入(不弹系统对话框)")
|
||||||
|
page.fill('[data-vend-key="https://api.openai.com/v1"]', "sk-invalid-for-smoke")
|
||||||
|
with page.expect_response(lambda res: "/models/fetch" in res.url, timeout=20000):
|
||||||
|
page.click('[data-vend-fetch="https://api.openai.com/v1"]')
|
||||||
|
page.wait_for_selector('[data-vend-manual-input="https://api.openai.com/v1"]', timeout=15000)
|
||||||
|
check("拉取失败给出失败提示", "拉取失败" in page.inner_text(".vend-note.bad"),
|
||||||
|
page.inner_text(".vend-note.bad")[:80])
|
||||||
|
check("失败后出现手动录入输入框", page.is_visible('[data-vend-manual-input="https://api.openai.com/v1"]'))
|
||||||
|
page.fill('[data-vend-manual-input="https://api.openai.com/v1"]', "gpt-4o")
|
||||||
|
with page.expect_response(lambda res: "/models/save" in res.url, timeout=20000) as saved:
|
||||||
|
page.click('[data-vend-manual-add="https://api.openai.com/v1"]')
|
||||||
|
check("手动录入的模型保存成功", saved.value.status == 200, str(saved.value.status))
|
||||||
|
page.wait_for_selector(".model-row", timeout=15000)
|
||||||
|
row = page.inner_text(".model-row")
|
||||||
|
check("模型行显示名称/供应商/测试与删除", "gpt-4o" in row and "OpenAI" in row
|
||||||
|
and page.locator("[data-model-test]").count() >= 1
|
||||||
|
and page.locator("[data-model-remove]").count() >= 1, row.replace("\n", " | ")[:100])
|
||||||
|
check("首个模型自动成为主模型", "主模型" in row, row.replace("\n", " | ")[:80])
|
||||||
|
if shots:
|
||||||
|
page.screenshot(path=str(shots / "ui-models-night.png"), full_page=True)
|
||||||
|
|
||||||
|
print("\n[UI-5] 会员管理 + 邀请码:生成、复制、作废")
|
||||||
|
page.click('[data-nav="members"]')
|
||||||
|
page.wait_for_selector("#createInvites", timeout=10000)
|
||||||
|
check("会员表渲染出主站账号", "boss" in page.inner_text("table.dtable"))
|
||||||
|
check("有会员额度输入与保存", page.locator("#memberQuota").count() == 1 and page.locator("#saveQuota").count() == 1)
|
||||||
|
page.click("#createInvites")
|
||||||
|
page.wait_for_selector("[data-invite-copy]", timeout=10000)
|
||||||
|
code_text = page.inner_text("td.invite-code")
|
||||||
|
check("生成后当次显示完整邀请码", code_text.count("-") >= 3 and "•" not in code_text, code_text)
|
||||||
|
check("未使用的码可复制可作废",
|
||||||
|
page.locator("[data-invite-copy]").count() >= 1 and page.locator("[data-invite-revoke]").count() >= 1)
|
||||||
|
page.once("dialog", lambda dialog: dialog.accept())
|
||||||
|
revoke_response = None
|
||||||
|
with page.expect_response(lambda res: "/invites/revoke" in res.url, timeout=10000) as caught:
|
||||||
|
page.click("[data-invite-revoke]")
|
||||||
|
revoke_response = caught.value
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
check("作废接口返回 200", revoke_response.status == 200,
|
||||||
|
f"{revoke_response.status} {revoke_response.text()[:120]}")
|
||||||
|
invite_row = page.inner_text("tr:has(td.invite-code)")
|
||||||
|
check("作废后该行状态变为作废", "作废" in invite_row, invite_row.replace("\n", " | ")[:120])
|
||||||
|
check("作废后不再显示完整码,只留掩码", "•" in page.inner_text("td.invite-code"),
|
||||||
|
page.inner_text("td.invite-code"))
|
||||||
|
check("作废后复制与作废按钮都收起",
|
||||||
|
page.locator("[data-invite-copy]").count() == 0 and page.locator("[data-invite-revoke]").count() == 0)
|
||||||
|
if shots:
|
||||||
|
page.screenshot(path=str(shots / "ui-members-night.png"), full_page=True)
|
||||||
|
|
||||||
|
print("\n[UI-6] 日间 / 夜间主题切换")
|
||||||
|
check("默认夜间", page.get_attribute("html", "data-theme") == "night")
|
||||||
|
page.click("#themeBtn")
|
||||||
|
page.wait_for_timeout(300)
|
||||||
|
check("切到日间后 data-theme=day", page.get_attribute("html", "data-theme") == "day")
|
||||||
|
body_bg = page.evaluate("getComputedStyle(document.body).backgroundColor")
|
||||||
|
check("日间底色是浅色", _is_light(body_bg), body_bg)
|
||||||
|
check("按钮文案回切为夜间", page.inner_text("#themeBtn").strip() == "夜间", page.inner_text("#themeBtn"))
|
||||||
|
if shots:
|
||||||
|
page.screenshot(path=str(shots / "ui-members-day.png"), full_page=True)
|
||||||
|
page.click('[data-nav="sources"]')
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
page.screenshot(path=str(shots / "ui-sources-day.png"), full_page=True)
|
||||||
|
page.click('[data-nav="models"]')
|
||||||
|
page.wait_for_timeout(600)
|
||||||
|
page.screenshot(path=str(shots / "ui-models-day.png"), full_page=True)
|
||||||
|
page.click("#themeBtn")
|
||||||
|
page.reload(wait_until="networkidle")
|
||||||
|
page.wait_for_selector("#appRoot:not([hidden])", timeout=15000)
|
||||||
|
check("主题选择刷新后保持", page.get_attribute("html", "data-theme") == "night")
|
||||||
|
context.close()
|
||||||
|
|
||||||
|
print("\n[UI-7] 窄屏 1030px:输入框与按钮竖排不重叠")
|
||||||
|
context, page = new_page(1030, logged_in=True)
|
||||||
|
page.goto(f"{hub}/admin/#sources", wait_until="networkidle")
|
||||||
|
page.wait_for_selector('[data-cred-form="tushare"]', timeout=15000)
|
||||||
|
# 窄屏下"输入框一行、按钮整排落到下一行"是样图 1030 的硬要求
|
||||||
|
stacked = page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
const bad = [];
|
||||||
|
document.querySelectorAll('.cred-box').forEach((box) => {
|
||||||
|
const input = box.querySelector('input');
|
||||||
|
const button = box.querySelector('.pbtn');
|
||||||
|
if (!input || !button) return;
|
||||||
|
const a = input.getBoundingClientRect();
|
||||||
|
const b = button.getBoundingClientRect();
|
||||||
|
const overlap = a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom;
|
||||||
|
if (overlap) bad.push(box.dataset.credForm + ':重叠');
|
||||||
|
if (b.top < a.bottom - 1) bad.push(box.dataset.credForm + ':同行');
|
||||||
|
});
|
||||||
|
return bad;
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
check("凭证输入框与按钮竖排不重叠", stacked == [], str(stacked))
|
||||||
|
clipped = page.evaluate(
|
||||||
|
"() => [...document.querySelectorAll('input, select, .pbtn, .tbtn')]"
|
||||||
|
".filter((el) => el.getBoundingClientRect().right > window.innerWidth + 1).length"
|
||||||
|
)
|
||||||
|
check("窄屏没有控件溢出视口", clipped == 0, str(clipped))
|
||||||
|
if shots:
|
||||||
|
page.screenshot(path=str(shots / "ui-sources-1030.png"), full_page=True)
|
||||||
|
page.goto(f"{hub}/admin/#models", wait_until="networkidle")
|
||||||
|
page.wait_for_selector(".vend-line", timeout=15000)
|
||||||
|
# 供应商卡的地址/Key/按钮在宽屏并排,窄屏必须整列竖排
|
||||||
|
model_stacked = page.evaluate(
|
||||||
|
"""() => {
|
||||||
|
const line = document.querySelector('.vend-line');
|
||||||
|
const kids = [...line.children];
|
||||||
|
const bad = [];
|
||||||
|
for (let i = 1; i < kids.length; i += 1) {
|
||||||
|
const prev = kids[i - 1].getBoundingClientRect();
|
||||||
|
const cur = kids[i].getBoundingClientRect();
|
||||||
|
if (cur.top < prev.bottom - 1) bad.push(i);
|
||||||
|
}
|
||||||
|
return bad;
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
check("供应商卡地址/Key/按钮窄屏竖排", model_stacked == [], str(model_stacked))
|
||||||
|
model_clipped = page.evaluate(
|
||||||
|
"() => [...document.querySelectorAll('input, select, .pbtn, .tbtn, .model-row')]"
|
||||||
|
".filter((el) => el.getBoundingClientRect().right > window.innerWidth + 1).length"
|
||||||
|
)
|
||||||
|
check("模型页窄屏没有控件溢出视口", model_clipped == 0, str(model_clipped))
|
||||||
|
if shots:
|
||||||
|
page.screenshot(path=str(shots / "ui-models-1030.png"), full_page=True)
|
||||||
|
page.goto(f"{hub}/admin/#members", wait_until="networkidle")
|
||||||
|
page.wait_for_timeout(800)
|
||||||
|
page.screenshot(path=str(shots / "ui-members-1030.png"), full_page=True)
|
||||||
|
context.close()
|
||||||
|
|
||||||
|
print("\n[UI-8] 全程无 JS 异常与服务端错误")
|
||||||
|
# 预期噪音:未登录时门禁本来就会拿到 401;favicon 本项目没提供。
|
||||||
|
def expected(line: str) -> bool:
|
||||||
|
if "favicon" in line:
|
||||||
|
return True
|
||||||
|
if "401" in line and "/admin/api/session" in line:
|
||||||
|
return True
|
||||||
|
if "400" in line and "/admin/api/models/fetch" in line:
|
||||||
|
return True # UI-4b 故意用错 Key 拉取,400 是本轮要验的正确行为
|
||||||
|
if "console.error: Failed to load resource" in line:
|
||||||
|
return True # 上面两类的浏览器侧复述,URL 已单独判过
|
||||||
|
return False
|
||||||
|
|
||||||
|
crashes = [line for line in errors if "pageerror" in line]
|
||||||
|
server_errors = [line for line in errors if "HTTP 5" in line]
|
||||||
|
unexpected = [line for line in errors if not expected(line) and "pageerror" not in line
|
||||||
|
and "HTTP 5" not in line]
|
||||||
|
check("没有 JS 未捕获异常", crashes == [], "; ".join(crashes[:3]))
|
||||||
|
check("没有 5xx 服务端错误", server_errors == [], "; ".join(server_errors[:3]))
|
||||||
|
check("没有其它意外失败请求", unexpected == [], "; ".join(unexpected[:3]))
|
||||||
|
browser.close()
|
||||||
|
backend.shutil.rmtree(workdir, ignore_errors=True)
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
if FAILURES:
|
||||||
|
print(f"FAILED {len(FAILURES)} 项:")
|
||||||
|
for item in FAILURES:
|
||||||
|
print(" - " + item)
|
||||||
|
return 1
|
||||||
|
print("数据中枢控制台浏览器自测全部通过")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _is_light(colour: str) -> bool:
|
||||||
|
numbers = [int(part) for part in colour.replace("rgba", "").replace("rgb", "").strip("() ").split(",")[:3]]
|
||||||
|
return sum(numbers) / 3 > 160
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -4,10 +4,19 @@ DATAHUB_ENCRYPTION_KEY=
|
|||||||
# Consumer API token for /v1 (32+ random bytes, shown once). Never log this value.
|
# Consumer API token for /v1 (32+ random bytes, shown once). Never log this value.
|
||||||
DATAHUB_TOKEN=
|
DATAHUB_TOKEN=
|
||||||
|
|
||||||
# Initial admin password for /admin. Forced change on first login.
|
# 控制台没有独立账号:/admin 用小白复盘主站的管理员账号登录(HEL-560)。
|
||||||
DATAHUB_ADMIN_PASSWORD=
|
# 主站的服务端地址(容器内互访)+ 双方共享的桥接令牌,两者缺一控制台无法校验登录。
|
||||||
|
REVIEW_BASE_URL=http://xiaobai-review:8765
|
||||||
|
HUB_ADMIN_TOKEN=
|
||||||
|
|
||||||
|
# 浏览器可达的主站地址;留空时按请求 Host 推导 http://<host>:8765。
|
||||||
|
REVIEW_PUBLIC_URL=
|
||||||
|
|
||||||
# Tushare Pro token. Stored encrypted after first launch; never returned by API or admin pages.
|
# Tushare Pro token. Stored encrypted after first launch; never returned by API or admin pages.
|
||||||
TUSHARE_TOKEN=
|
TUSHARE_TOKEN=
|
||||||
|
|
||||||
|
# Optional licensed iFinD tokens. Used only inside the hub; the website never dials 51ifind.com.
|
||||||
|
IFIND_REFRESH_TOKEN=
|
||||||
|
IFIND_ACCESS_TOKEN=
|
||||||
|
|
||||||
TZ=Asia/Shanghai
|
TZ=Asia/Shanghai
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
- 盘中观察(provisional):东财/腾讯指数报价、个股最新价、全市场快照、分时点(`/v1/quotes/latest` 不传 codes 即全市场,`/v1/indexes/quotes` `/v1/intraday/points`);永不写入 eod_* 正式表
|
- 盘中观察(provisional):东财/腾讯指数报价、个股最新价、全市场快照、分时点(`/v1/quotes/latest` 不传 codes 即全市场,`/v1/indexes/quotes` `/v1/intraday/points`);永不写入 eod_* 正式表
|
||||||
- 暂存 → 校验 → 整批原子发布 → 可回滚
|
- 暂存 → 校验 → 整批原子发布 → 可回滚
|
||||||
- `/v1` 稳定接口(`X-Datahub-Token`)
|
- `/v1` 稳定接口(`X-Datahub-Token`)
|
||||||
- `/admin/` 最小管理后台(总览 / 数据源 / 调度 / 发布 / 数据集 / 审计)
|
- `/admin/` 统一管理控制台(总览 / 数据源配置 / 模型池 / 会员管理 / 数据血缘),日间与夜间两套配色
|
||||||
- 同花顺/选股宝/AKShare/iFinD 适配器位仍预留;东财/腾讯已接入盘中观察
|
- 同花顺/选股宝/AKShare/iFinD 适配器位仍预留;东财/腾讯已接入盘中观察
|
||||||
|
|
||||||
## 单位口径(相对现站)
|
## 单位口径(相对现站)
|
||||||
@@ -33,16 +33,43 @@
|
|||||||
cd xiaobai-datahub
|
cd xiaobai-datahub
|
||||||
python -m venv .venv && .venv/bin/pip install -r requirements.txt
|
python -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 填入 DATAHUB_ENCRYPTION_KEY / DATAHUB_TOKEN / DATAHUB_ADMIN_PASSWORD / TUSHARE_TOKEN
|
# 填入 DATAHUB_ENCRYPTION_KEY / DATAHUB_TOKEN / HUB_ADMIN_TOKEN / TUSHARE_TOKEN
|
||||||
|
# HUB_ADMIN_TOKEN 与主站 .env 同名变量必须一致;控制台没有独立账号,用主站管理员账号登录
|
||||||
# 生成 Fernet 密钥:
|
# 生成 Fernet 密钥:
|
||||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
.venv/bin/python server.py --host 127.0.0.1 --port 8766
|
.venv/bin/python server.py --host 127.0.0.1 --port 8766
|
||||||
```
|
```
|
||||||
|
|
||||||
- 管理后台:http://127.0.0.1:8766/admin/
|
- 管理控制台:http://127.0.0.1:8766/admin/
|
||||||
- 存活检查:http://127.0.0.1:8766/livez (无需 token)
|
- 存活检查:http://127.0.0.1:8766/livez (无需 token)
|
||||||
- `/v1/*` 必须带请求头 `X-Datahub-Token`
|
- `/v1/*` 必须带请求头 `X-Datahub-Token`
|
||||||
|
|
||||||
|
## 管理控制台的账号与权限(HEL-560)
|
||||||
|
|
||||||
|
控制台**没有自己的账号体系**,也不再有独立登录页和改密页:
|
||||||
|
|
||||||
|
- 登录状态取自主站 `xiaobai_session` cookie。主站与中枢同主机不同端口,浏览器会自动带上该 cookie,因此在主站登录后直接打开 8766 即可进入;未登录会看到门禁面板并给出主站登录入口。
|
||||||
|
- 仅管理员可进。每个页面与每个 `/admin/api/*` 接口都在服务端校验会话与 `role=admin`,非管理员一律 403,前端隐藏与否不作为权限依据。
|
||||||
|
- 校验方式是服务间桥接:中枢把 cookie 交给主站 `/api/hub-admin/session` 换回用户身份,结果缓存数秒。桥接凭 `HUB_ADMIN_TOKEN`(与主站 `.env` 同名变量必须一致),主站在任何处理器之前先校验它。
|
||||||
|
- CSRF 令牌由会话派生(HMAC),随 `GET /admin/api/session` 下发,写操作必须带 `X-CSRF-Token`。
|
||||||
|
- 回滚、回补等危险操作仍需二次确认密码,校验走主站 `/api/hub-admin/password/check`,中枢不存密码。
|
||||||
|
- 「退出」会请求主站注销该会话并跳回主站登录页。
|
||||||
|
|
||||||
|
需要的环境变量:
|
||||||
|
|
||||||
|
| 变量 | 位置 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| `HUB_ADMIN_TOKEN` | 主站 + 中枢 | 服务间桥接令牌,两侧必须一致,缺失则控制台无法校验会话 |
|
||||||
|
| `REVIEW_BASE_URL` | 中枢 | 中枢访问主站的地址(容器内一般是服务名,如 `http://xiaobai-review:8765`) |
|
||||||
|
| `REVIEW_PUBLIC_URL` | 中枢 | 浏览器可达的主站地址,用于门禁的登录跳转;留空则按当前主机名推导 |
|
||||||
|
|
||||||
|
## 从主站迁入的两块配置
|
||||||
|
|
||||||
|
- **模型池**:按供应商组织(同一 API 地址下可挂多个模型),填好地址与 Key 后可自动拉取 `/models` 勾选纳入;供应商不支持或拉取失败时用卡内「手动录入」兜底。主 / 辅模型分工在「调用编排」里指定。密钥加密存于主站,界面只回显后四位。
|
||||||
|
- **会员与邀请码**:会员开通 / 续期 / 停用、每日调用额度,以及一次性邀请码的生成、复制、作废。注册必须提交有效邀请码,每个码只能成功注册一次(并发提交也只有一个成功)。列表只显示掩码,完整码仅在生成瞬间与「复制」动作中可得。
|
||||||
|
|
||||||
|
数据仍归主站所有(同一个 `review.db`),中枢只是唯一的管理入口;主站页面上原本的模型池与会员管理分区已移除,「数据中枢」按钮指向 8766。
|
||||||
|
|
||||||
## Docker(独立 compose,不改现网 review 服务)
|
## Docker(独立 compose,不改现网 review 服务)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+1997
-261
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,103 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" data-theme="night">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xiaobai-datahub 管理后台</title>
|
<title>小白复盘 · 数据中枢</title>
|
||||||
<link rel="stylesheet" href="/admin/styles.css" />
|
<link rel="stylesheet" href="/admin/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<section id="login-view" class="panel auth-panel">
|
|
||||||
<h1>数据中枢</h1>
|
|
||||||
<p class="muted">内网管理后台,用于查看源状态、调度和盘后发布批次。</p>
|
|
||||||
<form id="login-form">
|
|
||||||
<label>账号 <input name="username" value="hub_admin" autocomplete="username" /></label>
|
|
||||||
<label>密码 <input name="password" type="password" autocomplete="current-password" /></label>
|
|
||||||
<button type="submit">登录</button>
|
|
||||||
<p id="login-error" class="error" hidden></p>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="change-view" class="panel auth-panel" hidden>
|
<!-- 数据中枢没有独立账号:门禁只负责把未登录/非管理员引回主站。 -->
|
||||||
<h1>修改初始密码</h1>
|
<div id="gate-view" class="auth-wrap bg-ambient" hidden>
|
||||||
<form id="change-form">
|
<section class="panel gate-panel">
|
||||||
<label>当前密码 <input name="current" type="password" /></label>
|
<h1 id="gate-title">数据中枢</h1>
|
||||||
<label>新密码(至少 8 位) <input name="new_password" type="password" /></label>
|
<p id="gate-desc">正在校验小白复盘主站登录状态…</p>
|
||||||
<button type="submit">保存并继续</button>
|
<div class="gate-actions">
|
||||||
<p id="change-error" class="error" hidden></p>
|
<a id="gate-login" class="pbtn" href="#" hidden>去主站登录</a>
|
||||||
</form>
|
<a id="gate-site" class="tbtn" href="#" hidden>返回小白复盘</a>
|
||||||
|
<button id="gate-retry" class="tbtn" type="button">重新校验</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<section id="shell" hidden>
|
<div class="app bg-ambient" id="appRoot" hidden>
|
||||||
<header class="top">
|
<div class="bg-layer bg-gridlines z0"></div>
|
||||||
<strong>xiaobai-datahub</strong>
|
<div class="bg-layer scanlines z50"></div>
|
||||||
<span id="phase" class="pill"></span>
|
|
||||||
<span id="who" class="muted"></span>
|
<header class="hdr">
|
||||||
<button type="button" id="theme-btn" class="ghost">夜间</button>
|
<div class="hdr-in">
|
||||||
<button type="button" id="logout-btn" class="ghost">退出</button>
|
<div class="radar" style="width:26px;height:26px" id="radarLogo">
|
||||||
</header>
|
<div class="sweep radar-sweep"></div>
|
||||||
<nav>
|
<div class="ring1" style="inset:5.72px"></div>
|
||||||
<button data-page="overview" class="active">总览</button>
|
<div class="ring2" style="inset:9.88px"></div>
|
||||||
<button data-page="sources">数据源</button>
|
<div class="center"></div>
|
||||||
<button data-page="jobs">调度任务</button>
|
<div class="blip pulse-dot" id="radarBlipOk" style="left:64%;top:30%;background:var(--mint)"></div>
|
||||||
<button data-page="release">盘后发布</button>
|
<div class="blip pulse-dot" id="radarBlipBad" style="left:30%;top:62%;background:var(--rd);animation-delay:.6s;display:none"></div>
|
||||||
<button data-page="datasets">数据集</button>
|
</div>
|
||||||
<button data-page="audit">审计</button>
|
<div class="brand"><span class="b1">小白复盘 <span style="color:var(--cy)">·</span> 数据中枢</span><span class="b2">DATA-HUB</span></div>
|
||||||
|
<span class="vsep"></span>
|
||||||
|
<nav class="nav" id="navEl">
|
||||||
|
<button class="navbtn" data-nav="overview"><span class="tri">▸</span>运行总览<span class="en">OVERVIEW</span></button>
|
||||||
|
<button class="navbtn" data-nav="sources"><span class="tri">▸</span>数据源配置<span class="en">SOURCES</span></button>
|
||||||
|
<button class="navbtn" data-nav="models"><span class="tri">▸</span>模型池<span class="en">MODELS</span></button>
|
||||||
|
<button class="navbtn" data-nav="members"><span class="tri">▸</span>会员管理<span class="en">MEMBERS</span></button>
|
||||||
|
<button class="navbtn" data-nav="lineage"><span class="tri">▸</span>数据血缘<span class="en">LINEAGE</span></button>
|
||||||
</nav>
|
</nav>
|
||||||
<main id="page"></main>
|
<span class="flex1"></span>
|
||||||
</section>
|
<span class="mdtag" id="phaseTag"></span>
|
||||||
|
<span class="livespan" id="liveSpan"><span class="livedot pulse-dot"></span>LIVE</span>
|
||||||
|
<span class="clock num" id="clock"><span id="ckD"></span><span class="csep">|</span><span class="ct"><span id="ckH"></span><span class="blink cc">:</span><span id="ckM"></span><span class="blink cc">:</span><span id="ckS"></span></span></span>
|
||||||
|
<span class="vsep"></span>
|
||||||
|
<button class="tbtn" id="opsBtn" style="font-size:10px">调度 / 发布 / 审计</button>
|
||||||
|
<button class="tbtn" id="themeBtn" style="font-size:10px">日间</button>
|
||||||
|
<button class="tbtn" id="calmBtn" style="font-size:10px">减少动态</button>
|
||||||
|
<span id="who" class="muted" style="font-size:10px"></span>
|
||||||
|
<button class="tbtn" id="logout-btn" style="font-size:10px">退出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main" id="mainEl"></main>
|
||||||
|
|
||||||
|
<footer class="tape">
|
||||||
|
<span class="tape-label" id="tapeLed"><span class="led rev pulse"></span>EVENT TAPE</span>
|
||||||
|
<div class="tape-view"><div class="marquee-track" id="marqueeTrack"><span style="display:inline-flex;align-items:center" id="tapeA"></span><span style="display:inline-flex;align-items:center" id="tapeB"></span></div></div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-mask" id="drawerMask">
|
||||||
|
<aside class="drawer">
|
||||||
|
<div class="drawer-hd">
|
||||||
|
<span class="ttl">调度 / 发布 / 审计</span>
|
||||||
|
<span class="sub" id="drawerSub"></span>
|
||||||
|
<button class="drawer-close" id="drawerClose">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-tabs" id="drawerTabs">
|
||||||
|
<button class="drawer-tab" data-dtab="jobs">调度任务</button>
|
||||||
|
<button class="drawer-tab" data-dtab="release">盘后发布</button>
|
||||||
|
<button class="drawer-tab" data-dtab="audit">审计</button>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-body" id="drawerBody"></div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-mask" id="modalMask">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 id="modalTitle">危险操作确认</h3>
|
||||||
|
<p id="modalDesc"></p>
|
||||||
|
<label>主站账号密码 <input id="modalPassword" type="password" autocomplete="current-password" /></label>
|
||||||
|
<label id="modalConfirmWrap">请输入确认词 <span id="modalConfirmWord" class="num" style="color:var(--amb)"></span> <input id="modalConfirm" type="text" autocomplete="off" /></label>
|
||||||
|
<p class="modal-err" id="modalErr"></p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="tbtn" id="modalCancel">取消</button>
|
||||||
|
<button class="tbtn danger" id="modalOk">确认执行</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toastEl"></div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<script src="/admin/app.js"></script>
|
<script src="/admin/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user