主站 - 新增 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>
173 lines
7.5 KiB
Python
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 "自定义供应商"
|