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:
@@ -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"))
|
||||
Reference in New Issue
Block a user