Files
xiaobai-review/xiaobai-datahub/datahub/settings.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

143 lines
5.5 KiB
Python

from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DB_PATH = Path(os.environ.get("DATAHUB_DB_PATH") or (ROOT / "data" / "datahub.db"))
DEFAULT_BACKUP_DIR = Path(os.environ.get("DATAHUB_BACKUP_DIR") or (ROOT / "data" / "backups"))
DEFAULT_CONFIG_PATH = ROOT / "config" / "hub-quality.config.json"
def _load_quality(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
return json.loads(path.read_text(encoding="utf-8"))
@dataclass
class Settings:
host: str = "127.0.0.1"
port: int = 8766
encryption_key: str = ""
api_token: str = ""
# HEL-560: the console has no accounts of its own. It verifies the review
# site's session over the bridge, so it needs the site's internal base URL,
# the shared bridge token, and the browser-reachable site URL for redirects.
review_base_url: str = ""
review_public_url: str = ""
hub_admin_token: str = ""
tushare_token: str = ""
ifind_refresh_token: str = ""
ifind_access_token: str = ""
db_path: Path = DEFAULT_DB_PATH
backup_dir: Path = DEFAULT_BACKUP_DIR
quality: dict[str, Any] = field(default_factory=dict)
log_level: str = "INFO"
scheduler_enabled: bool = True
# HEL-543 kill switch: off disables the provider_call_log/provider_health
# side channel entirely (observe()/record_call() become no-ops and the
# new read-only admin endpoints report {"enabled": false}). Default on;
# existing routing/fetch/publish behavior is identical either way.
observability_enabled: bool = True
@property
def tushare_rate_per_minute(self) -> int:
return int(self.quality.get("tushare_rate_per_minute") or 300)
@property
def max_publish_attempts(self) -> int:
return int(self.quality.get("max_publish_attempts") or 5)
@property
def list_limit_default(self) -> int:
return int(self.quality.get("list_limit_default") or 5000)
@property
def list_limit_max(self) -> int:
return int(self.quality.get("list_limit_max") or 5000)
@property
def calendar_start(self) -> str:
return str(self.quality.get("calendar_start") or "20160101")
@property
def index_history_trading_days(self) -> int:
return int(self.quality.get("index_history_trading_days") or 260)
@property
def daily_history_trading_days(self) -> int:
return int(self.quality.get("daily_history_trading_days") or 250)
@property
def moneyflow_history_trading_days(self) -> int:
return int(self.quality.get("moneyflow_history_trading_days") or 60)
@property
def stocks_refresh_times(self) -> tuple[str, ...]:
raw = self.quality.get("stocks_refresh_times") or ["20:00", "23:10"]
if isinstance(raw, str):
raw = [raw]
return tuple(str(item) for item in raw)
@property
def realtime_warmup_interval_seconds(self) -> int:
return max(30, int(self.quality.get("realtime_warmup_interval_seconds") or 120))
@property
def eod_retry_start(self) -> str:
return str(self.quality.get("eod_retry_start") or "15:15")
@property
def eod_retry_interval_minutes(self) -> int:
return int(self.quality.get("eod_retry_interval_minutes") or 30)
@property
def eod_retry_cutoff(self) -> str:
return str(self.quality.get("eod_retry_cutoff") or "23:30")
@property
def revision_review_start(self) -> str:
# Before the 21:00 website shadow observation.
return str(self.quality.get("revision_review_start") or "20:00")
@property
def revision_review_interval_minutes(self) -> int:
return int(self.quality.get("revision_review_interval_minutes") or 30)
@property
def revision_review_cutoff(self) -> str:
# Last light review ~23:00; cutoff before the 23:30 observation.
return str(self.quality.get("revision_review_cutoff") or "23:20")
def load_settings(
env: dict[str, str] | None = None,
config_path: Path | None = None,
) -> Settings:
environ = env if env is not None else dict(os.environ)
quality_path = config_path or DEFAULT_CONFIG_PATH
db_path = Path(environ.get("DATAHUB_DB_PATH") or DEFAULT_DB_PATH)
backup_dir = Path(environ.get("DATAHUB_BACKUP_DIR") or DEFAULT_BACKUP_DIR)
return Settings(
host=environ.get("DATAHUB_HOST") or "127.0.0.1",
port=int(environ.get("DATAHUB_PORT") or 8766),
encryption_key=str(environ.get("DATAHUB_ENCRYPTION_KEY") or "").strip(),
api_token=str(environ.get("DATAHUB_TOKEN") or "").strip(),
review_base_url=str(environ.get("REVIEW_BASE_URL") or "http://xiaobai-review:8765").strip(),
review_public_url=str(environ.get("REVIEW_PUBLIC_URL") or "").strip(),
hub_admin_token=str(environ.get("HUB_ADMIN_TOKEN") or "").strip(),
tushare_token=str(environ.get("TUSHARE_TOKEN") or "").strip(),
ifind_refresh_token=str(environ.get("IFIND_REFRESH_TOKEN") or "").strip(),
ifind_access_token=str(environ.get("IFIND_ACCESS_TOKEN") or "").strip(),
db_path=db_path,
backup_dir=backup_dir,
quality=_load_quality(quality_path),
log_level=environ.get("DATAHUB_LOG_LEVEL") or "INFO",
scheduler_enabled=str(environ.get("DATAHUB_SCHEDULER") or "1") not in {"0", "false", "False"},
observability_enabled=str(environ.get("DATAHUB_OBSERVABILITY") or "1") not in {"0", "false", "False", "off", "OFF"},
)