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:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"))
|
||||
@@ -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 "自定义供应商"
|
||||
Reference in New Issue
Block a user