Files
xiaobai-review/xiaobai-datahub/datahub/siteconsole.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

173 lines
7.5 KiB
Python

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 "自定义供应商"