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