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>
This commit is contained in:
施工员
2026-09-16 11:44:09 +08:00
co-authored by multica-agent
parent 3203574b6a
commit 3eaa36a8d5
69 changed files with 3678 additions and 1573 deletions
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
import hashlib
import hmac
import json
import threading
import time
import urllib.error
import urllib.request
from typing import Any
from datahub.logutil import get_logger
LOGGER = get_logger()
SITE_SESSION_COOKIE = "xiaobai_session"
VERIFY_CACHE_SECONDS = 20.0
BRIDGE_TIMEOUT_SECONDS = 6.0
class SiteBridgeError(RuntimeError):
"""The review site could not answer a bridge call.
``status`` carries the review site's HTTP status when it answered with one.
A 4xx there means the operator's input was rejected (bad API key, invalid
model id), which must not surface here as a console fault.
"""
def __init__(self, message: str, status: int = 0) -> None:
super().__init__(message)
self.status = status
@property
def caller_fault(self) -> bool:
return 400 <= self.status < 500
class SiteBridge:
"""Service-to-service client for the review site's ``/api/hub-admin/*`` endpoints.
The shared ``HUB_ADMIN_TOKEN`` is the only credential; the review site
checks it before any handler runs, so nothing here needs a browser session.
"""
def __init__(self, base_url: str, token: str, timeout: float = BRIDGE_TIMEOUT_SECONDS) -> None:
self.base_url = (base_url or "").rstrip("/")
self.token = token or ""
self.timeout = timeout
@property
def configured(self) -> bool:
return bool(self.base_url and self.token)
def call(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.configured:
raise SiteBridgeError("主站桥接未配置:请设置 REVIEW_BASE_URL 与 HUB_ADMIN_TOKEN")
body = json.dumps(payload or {}, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
f"{self.base_url}{path}",
data=body,
method="POST",
headers={
"Content-Type": "application/json; charset=utf-8",
"X-Hub-Admin-Token": self.token,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read()
except urllib.error.HTTPError as exc:
detail = _error_detail(exc.read())
raise SiteBridgeError(detail or f"主站返回 {exc.code}", exc.code) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise SiteBridgeError(f"主站不可达:{exc}") from exc
try:
parsed = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SiteBridgeError("主站返回的不是合法 JSON") from exc
if not isinstance(parsed, dict):
raise SiteBridgeError("主站返回的不是合法 JSON")
if parsed.get("error"):
raise SiteBridgeError(str(parsed["error"]))
return parsed
def _error_detail(raw: bytes) -> str:
try:
parsed = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return ""
if isinstance(parsed, dict) and parsed.get("error"):
return str(parsed["error"])
return ""
class SiteAuth:
"""Admin gate for the console: the review site owns accounts, we only verify.
The console has no accounts of its own. Every request carries the review
site's ``xiaobai_session`` cookie (same host, different port, so the browser
sends it), which we hand to the site for verification. Results are cached
for a few seconds so a page full of panels does not fan out one bridge call
per request.
CSRF is stateless: the token is an HMAC of the session token under a server
secret, so it is unguessable without the secret yet needs no storage and
stays valid exactly as long as the session does.
"""
def __init__(self, bridge: SiteBridge, secret: str, cache_seconds: float = VERIFY_CACHE_SECONDS) -> None:
self.bridge = bridge
self._secret = (secret or "").encode("utf-8")
self._cache_seconds = cache_seconds
self._cache: dict[str, tuple[float, dict[str, Any] | None]] = {}
self._lock = threading.Lock()
def verify(self, session_token: str) -> dict[str, Any] | None:
if not session_token:
return None
key = hashlib.sha256(session_token.encode("utf-8")).hexdigest()
now = time.monotonic()
with self._lock:
cached = self._cache.get(key)
if cached and cached[0] > now:
return cached[1]
user = self._verify_remote(session_token)
with self._lock:
self._cache[key] = (now + self._cache_seconds, user)
if len(self._cache) > 256:
self._prune(now)
return user
def _prune(self, now: float) -> None:
for cached_key in [key for key, (expires, _) in self._cache.items() if expires <= now]:
self._cache.pop(cached_key, None)
def _verify_remote(self, session_token: str) -> dict[str, Any] | None:
payload = self.bridge.call("/api/hub-admin/session", {"session_token": session_token})
if not payload.get("authenticated"):
return None
user = payload.get("user") or {}
return {
"id": int(user.get("id") or 0),
"username": str(user.get("username") or ""),
"role": str(user.get("role") or "user"),
"is_admin": bool(user.get("is_admin")),
}
def invalidate(self, session_token: str) -> None:
key = hashlib.sha256(session_token.encode("utf-8")).hexdigest()
with self._lock:
self._cache.pop(key, None)
def csrf_token(self, session_token: str) -> str:
digest = hashlib.sha256(session_token.encode("utf-8")).digest()
return hmac.new(self._secret, digest, hashlib.sha256).hexdigest()
def check_csrf(self, session_token: str, supplied: str) -> bool:
if not supplied:
return False
return hmac.compare_digest(self.csrf_token(session_token), supplied)
def logout(self, session_token: str) -> None:
self.invalidate(session_token)
if not session_token:
return
try:
self.bridge.call("/api/hub-admin/session/logout", {"session_token": session_token})
except SiteBridgeError:
LOGGER.warning("site logout bridge call failed")
def confirm_password(self, user_id: int, password: str) -> bool:
if not password:
return False
payload = self.bridge.call(
"/api/hub-admin/password/check",
{"user_id": int(user_id), "password": password},
)
return bool(payload.get("verified"))