Files
xiaobai-review/xiaobai-datahub/datahub/hub.py
T
施工员andmultica-agent 3eaa36a8d5 feat(HEL-560): 数据中枢接管数据源/模型池/会员,注册改一次性邀请码
主站
- 新增 m0006 invite_codes 迁移;注册强制邀请码(首个管理员除外),消码与建号
  同一事务,并发提交只有一个能成功
- 新增 /api/hub-admin/* 服务端点(共享 HUB_ADMIN_TOKEN,先于鉴权校验),供数据
  中枢桥接读写会话/密码/模型池/会员/邀请码,并提供供应商模型列表拉取
- 前端:注册表单加邀请码(桌面 login、index.html、移动端);「系统管理」改为
  「数据中枢」入口指向 8766,原模型池与会员管理分区移除,仅留「行情管理」;
  随之清理陈旧 CSS

数据中枢
- 取消独立账号:删除 hub_admin/hub_sessions 与登录、改密、锁定逻辑,改为校验
  主站 xiaobai_session,仅管理员可进,CSRF 由会话派生,危险操作二次确认走主站
- 控制台新增数据源凭证可编辑区(原有内容一项不删)、供应商制模型池(自动拉取
  /models,失败退回卡内手动录入)、会员管理与邀请码页
- 日夜双主题:颜色收敛为同名 token 换值,SVG 改用 inline style 以吃到变量

自测
- 主站 verify_baseline 通过(498 项);数据中枢 235 项通过
- tools/verify_datahub_console.py 端到端跑通两服务真实对话;
  tools/verify_datahub_console_ui.py 浏览器跑通门禁/凭证/模型池/会员/主题/1030 窄屏

Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 11:44:09 +08:00

117 lines
5.0 KiB
Python

from __future__ import annotations
from pathlib import Path
from typing import Any
from datahub.adapters.ifind import IfindAdapter
from datahub.adapters.tushare import TushareAdapter
from datahub.admin_api import AdminAPI
from datahub.auth import AuthService
from datahub.crypto import SecretVault
from datahub.db import HubDB
from datahub.governance.circuit import CircuitBreaker
from datahub.governance.lkg import LastKnownGood
from datahub.governance.ratelimit import TokenBucket
from datahub.pipeline import Pipeline
from datahub.scheduler import Scheduler
from datahub.serving import V1API
from datahub.settings import Settings, load_settings
from datahub.siteauth import SiteAuth, SiteBridge
from datahub.siteconsole import SiteConsole
class Hub:
def __init__(
self,
settings: Settings,
adapter: TushareAdapter | None = None,
site_auth: SiteAuth | None = None,
) -> None:
if not settings.encryption_key:
raise SystemExit("DATAHUB_ENCRYPTION_KEY 未配置")
self.settings = settings
self.db = HubDB(settings.db_path)
# HEL-543: carry the observability kill switch on the db handle so
# every call site that already threads `db` through (pipeline,
# realtime_serve, steward, admin_api) picks it up for free with no
# extra plumbing. Missing this attribute (e.g. a bare HubDB built
# directly in tests) defaults to enabled — see observability.is_enabled.
self.db.observability_enabled = settings.observability_enabled
self.vault = SecretVault(settings.encryption_key)
self.auth = AuthService(self.db, self.vault, settings.api_token)
token = settings.tushare_token or self.auth.load_credential("tushare_token")
if settings.tushare_token:
self.auth.store_credential("tushare_token", settings.tushare_token)
token = settings.tushare_token
refresh = settings.ifind_refresh_token or self.auth.load_credential("ifind_refresh_token") or ""
access = settings.ifind_access_token or self.auth.load_credential("ifind_access_token") or ""
if settings.ifind_refresh_token:
self.auth.store_credential("ifind_refresh_token", settings.ifind_refresh_token)
refresh = settings.ifind_refresh_token
if settings.ifind_access_token:
self.auth.store_credential("ifind_access_token", settings.ifind_access_token)
access = settings.ifind_access_token
self.adapter = adapter or TushareAdapter(token)
self.ifind = IfindAdapter(refresh, access)
self.pipeline = Pipeline(
self.db,
self.adapter,
settings,
bucket=TokenBucket(settings.tushare_rate_per_minute),
breaker=CircuitBreaker(),
)
self.lkg = LastKnownGood(self.db)
self.scheduler = Scheduler(self.db, self.pipeline)
self.api = V1API(self.db, self.pipeline, settings, ifind=self.ifind)
self.site_bridge = SiteBridge(settings.review_base_url, settings.hub_admin_token)
# Tests inject a stub so the console gate can run without a live site.
self.site_auth = site_auth or SiteAuth(self.site_bridge, settings.encryption_key)
self.site_console = SiteConsole(self.site_bridge)
self.admin = AdminAPI(
self.db,
self.pipeline,
self.scheduler,
self.auth,
ifind=self.ifind,
site_auth=self.site_auth,
)
self.static_dir = Path(__file__).resolve().parents[1] / "admin"
def put_tushare_credentials(self, body: dict[str, Any] | None) -> dict[str, Any]:
"""Store and hot-swap the Tushare token without a restart.
The adapter holds the token in memory, so writing the credential and
pushing it into the live adapter in one step is what makes the save take
effect for the next fetch instead of the next deploy.
"""
from datahub.serving import envelope
token = str((body or {}).get("tushare_token") or "").strip()
if not token:
raise ValueError("Tushare Token 不能为空")
self.auth.store_credential("tushare_token", token)
self.adapter.token = token
return envelope(self.adapter.probe(), {"source": "tushare"})
def put_ifind_credentials(self, body: dict[str, Any] | None) -> dict[str, Any]:
from datahub.serving import envelope
payload = dict(body or {})
refresh = str(payload.get("ifind_refresh_token") or "").strip()
access = str(payload.get("ifind_access_token") or "").strip()
self.auth.store_credential("ifind_refresh_token", refresh)
self.auth.store_credential("ifind_access_token", access)
self.ifind.set_credentials(refresh, access)
return envelope(self.ifind.status(), {"source": "ifind"})
def start(self) -> None:
if self.settings.scheduler_enabled:
self.scheduler.start()
def stop(self) -> None:
self.scheduler.stop()
def build_hub(settings: Settings | None = None) -> Hub:
return Hub(settings or load_settings())