Files
xiaobai-review/backend/http/handler.py
T
施工员andmultica-agent 3eaa36a8d5 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>
2026-09-16 11:44:09 +08:00

215 lines
8.3 KiB
Python

from __future__ import annotations
import json
import mimetypes
import os
import secrets
from collections.abc import Iterable
from http import HTTPStatus
from http.cookies import SimpleCookie
from typing import Any
from urllib.parse import unquote
from backend.bootstrap.config import (
DEVICE_COOKIE,
DEVICE_MAX_AGE,
SESSION_COOKIE,
SESSION_MAX_AGE,
STATIC_DIR,
)
from backend.features.accounts.security import token_hash
from backend.http.context import correlation_id
from backend.http.errors import normalize_error_payload
class HttpTransportMixin:
"""Original HTTP transport, static-file, session and access behavior."""
application_service: Any
route_registry: Any
def cookie_value(self, name: str) -> str:
cookie = SimpleCookie()
try:
cookie.load(self.headers.get("Cookie", ""))
except Exception:
return ""
morsel = cookie.get(name)
return morsel.value if morsel else ""
def session_token(self) -> str:
return self.cookie_value(SESSION_COOKIE)
def device_token(self) -> str:
return self.cookie_value(DEVICE_COOKIE)
def require_auth(self, send_error: bool = True) -> bool:
raw_token = self.session_token()
service = self.application_service
user = service.database.session_user(token_hash(raw_token)) if raw_token else None
if not user:
if send_error:
self.send_json({"error": "请先登录。"}, HTTPStatus.UNAUTHORIZED)
return False
self.auth_user = user
service.bind_user(int(user["id"]))
return True
def require_csrf(self) -> bool:
supplied = self.headers.get("X-CSRF-Token", "")
expected = str(getattr(self, "auth_user", {}).get("csrf_token") or "")
if not supplied or not secrets.compare_digest(supplied, expected):
self.send_json({"error": "请求校验失败,请刷新页面后重试。"}, HTTPStatus.FORBIDDEN)
return False
return True
def require_service_token(self) -> bool:
expected = str(os.environ.get("HUB_ADMIN_TOKEN") or "").strip()
supplied = self.headers.get("X-Hub-Admin-Token", "")
if not expected or not supplied or not secrets.compare_digest(supplied, expected):
self.send_json({"error": "服务令牌校验失败。"}, HTTPStatus.UNAUTHORIZED)
return False
return True
def require_admin(self) -> bool:
if str(getattr(self, "auth_user", {}).get("role") or "user") != "admin":
self.send_json({"error": "需要管理员权限。"}, HTTPStatus.FORBIDDEN)
return False
return True
def require_member(self) -> bool:
if self.application_service.membership()["active"]:
return True
self.send_json(
{"error": "该功能仅对有效会员开放,请联系管理员开通会员。", "code": "membership_required"},
HTTPStatus.FORBIDDEN,
)
return False
def require_access(self, method: str, path: str) -> bool:
route = self.route_registry.resolve(method, path)
if route is None:
self.send_json({"error": "Not found"}, HTTPStatus.NOT_FOUND)
return False
role = route.access
if role == "public":
return True
if role == "admin":
return self.require_admin()
if role == "member":
return self.require_member()
return True
def _cookie_header(self, name: str, value: str, max_age: int) -> str:
cookie = f"{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
if self.headers.get("X-Forwarded-Proto", "").lower() == "https":
cookie += "; Secure"
return cookie
def session_cookie(self, value: str, clear: bool = False) -> str:
return self._cookie_header(SESSION_COOKIE, value, 0 if clear else SESSION_MAX_AGE)
def device_cookie(self, value: str, clear: bool = False) -> str:
return self._cookie_header(DEVICE_COOKIE, value, 0 if clear else DEVICE_MAX_AGE)
def read_json_body(self, allow_empty: bool = False) -> dict[str, Any]:
length = int(self.headers.get("Content-Length", "0"))
if length == 0 and allow_empty:
return {}
if length <= 0 or length > 65536:
raise ValueError("请求内容为空或过大。")
raw = self.rfile.read(length)
try:
payload = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
raise ValueError("请求不是合法 JSON。") from None
if not isinstance(payload, dict):
raise ValueError("请求不是合法 JSON。")
return payload
def serve_static(self, request_path: str) -> None:
relative = unquote(request_path).lstrip("/") or "index.html"
candidate = (STATIC_DIR / relative).resolve()
try:
candidate.relative_to(STATIC_DIR.resolve())
except ValueError:
self.send_error(HTTPStatus.FORBIDDEN)
return
if candidate.is_dir():
candidate = (candidate / "index.html").resolve()
try:
candidate.relative_to(STATIC_DIR.resolve())
except ValueError:
self.send_error(HTTPStatus.FORBIDDEN)
return
if not candidate.is_file():
candidate = STATIC_DIR / "index.html"
try:
content = candidate.read_bytes()
except OSError:
self.send_error(HTTPStatus.NOT_FOUND)
return
content_type = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
if content_type.startswith("text/") or content_type in {"application/javascript", "application/json"}:
content_type += "; charset=utf-8"
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(content)
def send_json(
self,
payload: dict[str, Any],
status: HTTPStatus = HTTPStatus.OK,
headers: dict[str, str] | list[tuple[str, str]] | tuple[tuple[str, str], ...] | None = None,
) -> None:
request_id = getattr(self, "_correlation_id", "")
if not request_id:
request_id = correlation_id(self.headers.get("X-Request-ID", ""))
self._correlation_id = request_id
payload = normalize_error_payload(payload, status, request_id)
content = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(content)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Request-ID", request_id)
header_items = headers.items() if isinstance(headers, dict) else (headers or ())
for name, value in header_items:
self.send_header(name, value)
self.end_headers()
self.wfile.write(content)
def _write_stream_event(self, payload: dict[str, Any]) -> None:
self.wfile.write(
(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8")
)
self.wfile.flush()
def send_ndjson_stream(
self,
events: Iterable[dict[str, Any]],
error_types: tuple[type[Exception], ...],
) -> None:
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Cache-Control", "no-cache, no-transform")
self.send_header("X-Accel-Buffering", "no")
self.send_header("Connection", "close")
self.end_headers()
try:
for event in events:
self._write_stream_event(event)
self._write_stream_event({"type": "done"})
except error_types as exc:
self._write_stream_event({"type": "error", "error": str(exc)})
except (BrokenPipeError, ConnectionResetError):
pass
finally:
self.close_connection = True
def log_message(self, format_string: str, *args: Any) -> None:
print(f"[{self.log_date_time_string()}] {format_string % args}")