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
+41 -8
View File
@@ -16,12 +16,23 @@ from datahub.timeutil import isoformat, now_shanghai, session_phase, yyyymmdd
class AdminAPI:
def __init__(self, db: HubDB, pipeline: Pipeline, scheduler: Scheduler, auth: AuthService, ifind: Any = None) -> None:
def __init__(
self,
db: HubDB,
pipeline: Pipeline,
scheduler: Scheduler,
auth: AuthService,
ifind: Any = None,
site_auth: Any = None,
) -> None:
self.db = db
self.pipeline = pipeline
self.scheduler = scheduler
self.auth = auth
self.ifind = ifind
# HEL-560: dangerous operations confirm against the review site account
# that is driving the console, not against a console-local password.
self.site_auth = site_auth
def overview(self) -> dict[str, Any]:
today = yyyymmdd(now_shanghai())
@@ -211,18 +222,34 @@ class AdminAPI:
def audit(self) -> dict[str, Any]:
return {"items": self.db.fetchall("SELECT * FROM audit_log ORDER BY id DESC LIMIT 200")}
def rollback(self, dataset: str, trade_date: str, password: str, confirm: str, actor: str) -> dict[str, Any]:
self._dangerous(password, confirm, f"{dataset}:{trade_date}")
def rollback(
self,
dataset: str,
trade_date: str,
password: str,
confirm: str,
actor: str,
actor_id: int = 0,
) -> dict[str, Any]:
self._dangerous(password, confirm, f"{dataset}:{trade_date}", actor_id)
result = self.pipeline.rollback(dataset, trade_date, actor=actor)
return result
def backfill(self, dataset: str, trade_date: str, password: str, confirm: str, actor: str) -> dict[str, Any]:
def backfill(
self,
dataset: str,
trade_date: str,
password: str,
confirm: str,
actor: str,
actor_id: int = 0,
) -> dict[str, Any]:
day = yyyymmdd(trade_date or now_shanghai())
if dataset == "history":
self._dangerous(password, confirm, "history:full")
self._dangerous(password, confirm, "history:full", actor_id)
result = self.pipeline.backfill_history(day)
else:
self._dangerous(password, confirm, f"{dataset}:{day}")
self._dangerous(password, confirm, f"{dataset}:{day}", actor_id)
if dataset == "reference":
result = self.pipeline.ingest_reference(day)
elif dataset in OFFICIAL_DATASETS or dataset == STOCKS_DATASET:
@@ -244,12 +271,18 @@ class AdminAPI:
self.pipeline.audit(actor, "backfill", f"{dataset}:{day}", json.dumps({"ok": True}))
return result
def _dangerous(self, password: str, confirm: str, expected: str) -> None:
if not self.auth.confirm_password(password):
def _dangerous(self, password: str, confirm: str, expected: str, actor_id: int = 0) -> None:
if not self._confirm_password(actor_id, password):
raise ApiError("UNAUTHORIZED", "二次确认密码错误")
if confirm.strip() != expected:
raise ApiError("INVALID_ARGUMENT", f"确认词必须为 {expected}")
def _confirm_password(self, actor_id: int, password: str) -> bool:
"""Second factor for destructive ops: the operator's review-site password."""
if self.site_auth is None:
raise ApiError("UNAVAILABLE", "主站桥接未配置,无法校验管理员口令")
return bool(self.site_auth.confirm_password(actor_id, password))
def _public_calls(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out = []
+10 -124
View File
@@ -1,39 +1,12 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import os
import secrets
from datetime import timedelta
from typing import Any
from datahub.crypto import SecretVault, mask_secret
from datahub.db import HubDB
from datahub.timeutil import isoformat, now_shanghai
from datahub.timeutil import isoformat
PBKDF2_ROUNDS = 200_000
SESSION_HOURS = 12
LOGIN_FAIL_LIMIT = 5
LOCK_MINUTES = 10
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
raw_salt = salt or os.urandom(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), raw_salt, PBKDF2_ROUNDS, dklen=32)
return (
base64.urlsafe_b64encode(raw_salt).decode("ascii"),
base64.urlsafe_b64encode(digest).decode("ascii"),
)
def verify_password(password: str, salt_text: str, expected_hash: str) -> bool:
try:
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
_, actual = hash_password(password, salt)
except (ValueError, TypeError):
return False
return hmac.compare_digest(actual, expected_hash)
def token_hash(token: str) -> str:
@@ -41,12 +14,18 @@ def token_hash(token: str) -> str:
class AuthService:
def __init__(self, db: HubDB, vault: SecretVault, api_token: str, admin_password: str) -> None:
"""Machine credentials only: the `/v1` API token and the provider secrets.
Operator accounts live on the review site (HEL-560) — the console verifies
them through `SiteAuth`, so nothing here authenticates a person.
"""
def __init__(self, db: HubDB, vault: SecretVault, api_token: str) -> None:
self.db = db
self.vault = vault
self._bootstrap(api_token, admin_password)
self._bootstrap(api_token)
def _bootstrap(self, api_token: str, admin_password: str) -> None:
def _bootstrap(self, api_token: str) -> None:
if api_token:
existing = self.db.fetchone("SELECT token_hash FROM api_tokens WHERE name = ?", ("review",))
hashed = token_hash(api_token)
@@ -61,17 +40,6 @@ class AuthService:
"UPDATE api_tokens SET token_hash = ?, last4 = ? WHERE name = ?",
(hashed, last4, "review"),
)
admin = self.db.fetchone("SELECT id FROM hub_admin WHERE username = ?", ("hub_admin",))
if admin is None and admin_password:
salt, hashed = hash_password(admin_password)
now = isoformat()
self.db.execute(
"""
INSERT INTO hub_admin(username, password_salt, password_hash, password_must_change, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)
""",
("hub_admin", salt, hashed, now, now),
)
def check_api_token(self, supplied: str) -> bool:
if not supplied:
@@ -82,88 +50,6 @@ class AuthService:
)
return row is not None
def login(self, username: str, password: str) -> dict[str, Any]:
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", (username,))
if not user:
raise PermissionError("账号或密码错误")
now = now_shanghai()
locked_until = user.get("locked_until")
if locked_until:
try:
from datetime import datetime
if datetime.fromisoformat(str(locked_until)) > now:
raise PermissionError("账号已锁定,请稍后再试")
except ValueError:
pass
if not verify_password(password, str(user["password_salt"]), str(user["password_hash"])):
fails = int(user["failed_attempts"] or 0) + 1
lock = isoformat(now + timedelta(minutes=LOCK_MINUTES)) if fails >= LOGIN_FAIL_LIMIT else None
self.db.execute(
"UPDATE hub_admin SET failed_attempts = ?, locked_until = ? WHERE id = ?",
(fails, lock, user["id"]),
)
raise PermissionError("账号或密码错误")
self.db.execute(
"UPDATE hub_admin SET failed_attempts = 0, locked_until = NULL WHERE id = ?",
(user["id"],),
)
session = secrets.token_urlsafe(32)
csrf = secrets.token_urlsafe(24)
expires = isoformat(now + timedelta(hours=SESSION_HOURS))
self.db.execute(
"INSERT INTO hub_sessions(token_hash, csrf_token, expires_at, created_at) VALUES (?,?,?,?)",
(token_hash(session), csrf, expires, isoformat(now)),
)
return {
"session": session,
"csrf": csrf,
"must_change": bool(user["password_must_change"]),
"expires_at": expires,
}
def session_user(self, raw_token: str) -> dict[str, Any] | None:
if not raw_token:
return None
row = self.db.fetchone(
"SELECT * FROM hub_sessions WHERE token_hash = ?",
(token_hash(raw_token),),
)
if not row:
return None
if str(row["expires_at"]) < isoformat():
self.db.execute("DELETE FROM hub_sessions WHERE token_hash = ?", (row["token_hash"],))
return None
admin = self.db.fetchone("SELECT username, password_must_change FROM hub_admin WHERE username = ?", ("hub_admin",))
return {
"username": (admin or {}).get("username") or "hub_admin",
"csrf_token": row["csrf_token"],
"must_change": bool((admin or {}).get("password_must_change")),
"token_hash": row["token_hash"],
}
def logout(self, raw_token: str) -> None:
if raw_token:
self.db.execute("DELETE FROM hub_sessions WHERE token_hash = ?", (token_hash(raw_token),))
def change_password(self, current: str, new_password: str) -> None:
if len(new_password) < 8:
raise ValueError("新密码至少 8 位")
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", ("hub_admin",))
if not user or not verify_password(current, str(user["password_salt"]), str(user["password_hash"])):
raise PermissionError("当前密码错误")
salt, hashed = hash_password(new_password)
self.db.execute(
"UPDATE hub_admin SET password_salt=?, password_hash=?, password_must_change=0, updated_at=? WHERE id=?",
(salt, hashed, isoformat(), user["id"]),
)
def confirm_password(self, password: str) -> bool:
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", ("hub_admin",))
if not user:
return False
return verify_password(password, str(user["password_salt"]), str(user["password_hash"]))
def credential_status(self, name: str) -> dict[str, Any]:
row = self.db.fetchone("SELECT last4, updated_at FROM credentials WHERE name = ?", (name,))
if not row:
-17
View File
@@ -24,24 +24,7 @@ CREATE TABLE IF NOT EXISTS credentials (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hub_admin (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_salt TEXT NOT NULL,
password_hash TEXT NOT NULL,
password_must_change INTEGER NOT NULL DEFAULT 1,
failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hub_sessions (
token_hash TEXT PRIMARY KEY,
csrf_token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS api_tokens (
token_hash TEXT PRIMARY KEY,
+100 -31
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import json
import mimetypes
import secrets
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -12,9 +11,9 @@ from urllib.parse import unquote, urlparse
from datahub.hub import Hub
from datahub.logutil import configure_logging, get_logger
from datahub.serving import ApiError, parse_query
from datahub.siteauth import SITE_SESSION_COOKIE, SiteBridgeError
LOGGER = get_logger()
SESSION_COOKIE = "datahub_session"
class HubRequestHandler(BaseHTTPRequestHandler):
@@ -85,37 +84,48 @@ class HubRequestHandler(BaseHTTPRequestHandler):
self._json(payload, HTTPStatus.OK)
def _admin_api(self, method: str, path: str) -> None:
if path == "/admin/api/login" and method == "POST":
body = self._read_json()
result = self.hub.auth.login(str(body.get("username") or "hub_admin"), str(body.get("password") or ""))
session_token = self._cookie_value(SITE_SESSION_COOKIE)
user = self._site_user(session_token)
if user is None:
if path == "/admin/api/session" and method == "GET":
self._json(
{"authenticated": False, "login_url": self._login_url()},
HTTPStatus.UNAUTHORIZED,
)
return
raise ApiError("UNAUTHORIZED", "请先在小白复盘主站登录")
if not user["is_admin"]:
self._json(
{"ok": True, "must_change": result["must_change"], "csrf": result["csrf"]},
HTTPStatus.OK,
extra_headers=[self._cookie(result["session"])],
{
"error": {"code": "PERMISSION_DENIED", "message": "数据中枢仅管理员可进入"},
"authenticated": True,
"is_admin": False,
"username": user["username"],
},
HTTPStatus.FORBIDDEN,
)
return
user = self.hub.auth.session_user(self._cookie_value(SESSION_COOKIE))
if not user:
raise ApiError("UNAUTHORIZED", "请先登录")
if method == "POST" and path != "/admin/api/login":
csrf = self.headers.get("X-CSRF-Token", "")
if not csrf or not secrets.compare_digest(csrf, str(user["csrf_token"])):
raise ApiError("UNAUTHORIZED", "CSRF 校验失败")
if path == "/admin/api/logout" and method == "POST":
self.hub.auth.logout(self._cookie_value(SESSION_COOKIE))
self._json({"ok": True}, HTTPStatus.OK, extra_headers=[self._cookie("", clear=True)])
return
if method == "POST" and not self.hub.site_auth.check_csrf(session_token, self.headers.get("X-CSRF-Token", "")):
raise ApiError("UNAUTHORIZED", "CSRF 校验失败")
if path == "/admin/api/session" and method == "GET":
self._json({"username": user["username"], "must_change": user["must_change"], "csrf": user["csrf_token"]}, HTTPStatus.OK)
self._json(
{
"authenticated": True,
"is_admin": True,
"username": user["username"],
"csrf": self.hub.site_auth.csrf_token(session_token),
"review_url": self._review_url(),
},
HTTPStatus.OK,
)
return
if path == "/admin/api/change-password" and method == "POST":
body = self._read_json()
self.hub.auth.change_password(str(body.get("current") or ""), str(body.get("new_password") or ""))
self.hub.pipeline.audit(user["username"], "change_password", "hub_admin", "")
self._json({"ok": True}, HTTPStatus.OK)
if path == "/admin/api/logout" and method == "POST":
self.hub.site_auth.logout(session_token)
self.hub.pipeline.audit(user["username"], "logout", "site_session", "")
self._json({"ok": True, "login_url": self._login_url()}, HTTPStatus.OK)
return
if self._console_api(method, path, user):
return
if user["must_change"] and path not in {"/admin/api/change-password", "/admin/api/session"}:
raise ApiError("UNAUTHORIZED", "请先修改初始密码")
if path == "/admin/api/overview" and method == "GET":
self._json(self.hub.admin.overview(), HTTPStatus.OK)
return
@@ -175,6 +185,7 @@ class HubRequestHandler(BaseHTTPRequestHandler):
str(body.get("password") or ""),
str(body.get("confirm") or ""),
user["username"],
int(user["id"]),
)
self._json(result, HTTPStatus.OK)
return
@@ -186,11 +197,73 @@ class HubRequestHandler(BaseHTTPRequestHandler):
str(body.get("password") or ""),
str(body.get("confirm") or ""),
user["username"],
int(user["id"]),
)
self._json(result, HTTPStatus.OK)
return
raise ApiError("INVALID_ARGUMENT", f"unknown admin endpoint: {path}")
def _site_user(self, session_token: str) -> dict[str, Any] | None:
try:
return self.hub.site_auth.verify(session_token)
except SiteBridgeError as exc:
raise ApiError("UNAVAILABLE", str(exc)) from exc
def _review_url(self) -> str:
"""Browser-reachable review site URL.
In production both services sit on the same host behind different
ports, so the console derives the site URL from the Host header the
browser used; REVIEW_PUBLIC_URL overrides that when they do not.
"""
configured = self.hub.settings.review_public_url
if configured:
return configured.rstrip("/")
host = (self.headers.get("Host") or "").split(":")[0] or "127.0.0.1"
return f"http://{host}:8765"
def _login_url(self) -> str:
return f"{self._review_url()}/login/"
def _console_api(self, method: str, path: str, user: dict[str, Any]) -> bool:
"""Endpoints backed by the review site: model pool, members, invites.
Returns True when the request was handled so the caller can fall
through to the hub-owned endpoints otherwise.
"""
console = self.hub.site_console
if path == "/admin/api/models" and method == "GET":
self._json(console.models(), HTTPStatus.OK)
elif path == "/admin/api/models/save" and method == "POST":
self._json(console.save_models(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/models/test" and method == "POST":
self._json(console.test_model(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/models/fetch" and method == "POST":
self._json(console.fetch_models(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/members" and method == "GET":
self._json(console.members(), HTTPStatus.OK)
elif path == "/admin/api/members/save" and method == "POST":
self._json(console.save_member(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/members/quota" and method == "POST":
self._json(console.save_quota(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/invites" and method == "GET":
self._json(console.invites(), HTTPStatus.OK)
elif path == "/admin/api/invites/create" and method == "POST":
self._json(console.create_invites(self._read_json(allow_empty=True), int(user["id"])), HTTPStatus.OK)
elif path == "/admin/api/invites/revoke" and method == "POST":
self._json(console.revoke_invite(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/credentials/tushare" and method == "POST":
payload = self.hub.put_tushare_credentials(self._read_json())
self.hub.pipeline.audit(user["username"], "store_credential", "tushare_token", "")
self._json(payload, HTTPStatus.OK)
elif path == "/admin/api/credentials/ifind" and method == "POST":
payload = self.hub.put_ifind_credentials(self._read_json())
self.hub.pipeline.audit(user["username"], "store_credential", "ifind_tokens", "")
self._json(payload, HTTPStatus.OK)
else:
return False
return True
def _admin_static(self, path: str) -> None:
relative = path[len("/admin"):].lstrip("/") or "index.html"
candidate = (self.hub.static_dir / relative).resolve()
@@ -239,10 +312,6 @@ class HubRequestHandler(BaseHTTPRequestHandler):
morsel = cookie.get(name)
return morsel.value if morsel else ""
def _cookie(self, value: str, clear: bool = False) -> str:
max_age = 0 if clear else 12 * 3600
return f"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}"
def _json(self, payload: dict[str, Any], status: HTTPStatus, extra_headers: list[str] | None = None) -> None:
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
+37 -3
View File
@@ -16,10 +16,17 @@ 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) -> None:
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
@@ -31,7 +38,7 @@ class Hub:
# 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, settings.admin_password)
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)
@@ -56,9 +63,36 @@ class Hub:
self.lkg = LastKnownGood(self.db)
self.scheduler = Scheduler(self.db, self.pipeline)
self.api = V1API(self.db, self.pipeline, settings, ifind=self.ifind)
self.admin = AdminAPI(self.db, self.pipeline, self.scheduler, self.auth, 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
+9 -2
View File
@@ -24,7 +24,12 @@ class Settings:
port: int = 8766
encryption_key: str = ""
api_token: str = ""
admin_password: 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 = ""
@@ -122,7 +127,9 @@ def load_settings(
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(),
admin_password=str(environ.get("DATAHUB_ADMIN_PASSWORD") 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(),
+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"))
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
from typing import Any
from datahub.serving import ApiError
from datahub.siteauth import SiteBridge, SiteBridgeError
VENDOR_PRESETS: tuple[dict[str, str], ...] = (
{"id": "openai", "label": "OpenAI", "base_url": "https://api.openai.com/v1"},
{"id": "deepseek", "label": "DeepSeek", "base_url": "https://api.deepseek.com/v1"},
{"id": "moonshot", "label": "Moonshot", "base_url": "https://api.moonshot.cn/v1"},
{"id": "dashscope", "label": "阿里云百炼", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1"},
{"id": "zhipu", "label": "智谱 GLM", "base_url": "https://open.bigmodel.cn/api/paas/v4"},
{"id": "siliconflow", "label": "SiliconFlow", "base_url": "https://api.siliconflow.cn/v1"},
)
class SiteConsole:
"""Console-side view of the data the review site still owns.
The model pool, member roster and invite codes live in the review site's
database — this console reads and writes them over the bridge instead of
copying them, so there is exactly one source of truth. Every method turns a
bridge failure into an ``ApiError`` the console frontend already knows how
to render.
"""
def __init__(self, bridge: SiteBridge) -> None:
self.bridge = bridge
def _call(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
try:
return self.bridge.call(path, payload)
except SiteBridgeError as exc:
# 主站因入参不合法而拒绝(Key 不对、模型 ID 不合法)是操作者的问题,
# 照原样退回 400;只有主站真的不可达才算中枢侧不可用。
code = "INVALID_ARGUMENT" if exc.caller_fault else "SOURCE_UNAVAILABLE"
raise ApiError(code, str(exc)) from exc
# ---------------------------------------------------------------- models
def models(self) -> dict[str, Any]:
payload = self._call("/api/hub-admin/status")
llm = payload.get("llm") or {}
models = list(llm.get("models") or [])
return {
"vendors": [dict(preset) for preset in VENDOR_PRESETS],
"groups": _group_by_vendor(models),
"primary_model_id": str(llm.get("primary_model_id") or ""),
"fallback_model_id": str(llm.get("fallback_model_id") or ""),
"models": models,
}
def save_models(self, body: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {}
if "models" in body:
payload["models"] = body.get("models") or []
for key in ("primary_model_id", "fallback_model_id"):
if key in body:
payload[key] = str(body.get(key) or "")
if not payload:
raise ApiError("INVALID_ARGUMENT", "没有需要保存的模型配置")
self._call("/api/hub-admin/settings/save", payload)
return self.models()
def test_model(self, body: dict[str, Any]) -> dict[str, Any]:
payload = self._call(
"/api/hub-admin/settings/test",
{"model_id": str(body.get("model_id") or ""), "profile": body.get("profile") or {}},
)
return {"result": payload.get("result") or {}}
def fetch_models(self, body: dict[str, Any]) -> dict[str, Any]:
base_url = str(body.get("base_url") or "").strip()
if not base_url:
raise ApiError("INVALID_ARGUMENT", "请先填写供应商接口地址")
payload = self._call(
"/api/hub-admin/models/fetch",
{"base_url": base_url, "api_key": str(body.get("api_key") or "")},
)
return {"models": payload.get("models") or []}
# --------------------------------------------------------------- members
def members(self) -> dict[str, Any]:
payload = self._call("/api/hub-admin/members")
return {
"users": payload.get("users") or [],
"membership": payload.get("membership") or {},
}
def save_member(self, body: dict[str, Any]) -> dict[str, Any]:
payload = dict(body or {})
if not payload:
raise ApiError("INVALID_ARGUMENT", "没有需要保存的会员设置")
self._call("/api/hub-admin/membership/save", payload)
return self.members()
def save_quota(self, body: dict[str, Any]) -> dict[str, Any]:
"""Daily call quota is a system setting, not a per-user membership row."""
try:
limit = int(body.get("member_daily_limit") or 0)
except (TypeError, ValueError) as exc:
raise ApiError("INVALID_ARGUMENT", "每日调用额度必须是整数") from exc
if limit < 1:
raise ApiError("INVALID_ARGUMENT", "每日调用额度至少为 1")
self._call("/api/hub-admin/settings/save", {"member_daily_limit": limit})
return self.members()
# --------------------------------------------------------------- invites
def invites(self) -> dict[str, Any]:
payload = self._call("/api/hub-admin/invites")
return {"summary": payload.get("summary") or {}, "codes": payload.get("codes") or []}
def create_invites(self, body: dict[str, Any], created_by: int) -> dict[str, Any]:
payload = self._call(
"/api/hub-admin/invites/create",
{
"count": body.get("count") or 1,
"note": str(body.get("note") or ""),
"created_by": int(created_by or 0),
},
)
# `created` carries the plaintext codes and is the only moment they are
# ever returned; the list under `codes` is the masked roster.
return {
"created": payload.get("created") or [],
"summary": payload.get("summary") or {},
"codes": payload.get("codes") or [],
}
def revoke_invite(self, body: dict[str, Any]) -> dict[str, Any]:
reference = str(body.get("code_id") or body.get("code") or "").strip()
if not reference:
raise ApiError("INVALID_ARGUMENT", "请选择要作废的邀请码")
self._call("/api/hub-admin/invites/revoke", {"code_id": reference})
return self.invites()
def _group_by_vendor(models: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Bucket a flat model list by base URL so the console can render vendors.
The review site stores one row per model with its own base URL; the console
shows vendors with their models nested, so the base URL is the grouping key
and the preset table only supplies a friendly label when it recognises one.
"""
labels = {preset["base_url"]: preset["label"] for preset in VENDOR_PRESETS}
order: list[str] = []
buckets: dict[str, list[dict[str, Any]]] = {}
for model in models:
base_url = str(model.get("base_url") or "").strip()
if base_url not in buckets:
buckets[base_url] = []
order.append(base_url)
buckets[base_url].append(model)
groups: list[dict[str, Any]] = []
for base_url in order:
entries = buckets[base_url]
configured = next((entry for entry in entries if entry.get("api_key_last4")), None)
groups.append(
{
"base_url": base_url,
"label": labels.get(base_url) or _vendor_label(base_url),
"configured": any(entry.get("configured") for entry in entries),
"key_last4": str((configured or {}).get("api_key_last4") or ""),
"models": entries,
}
)
return groups
def _vendor_label(base_url: str) -> str:
host = base_url.split("//")[-1].split("/")[0]
return host or "自定义供应商"