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:
施工员
2026-09-16 11:44:09 +08:00
co-authored by multica-agent
parent 3203574b6a
commit 3eaa36a8d5
69 changed files with 3678 additions and 1573 deletions
+7
View File
@@ -17,6 +17,13 @@ registry, and verification tools.
`backend/features/*/routes.py` owners.
- `python tools/build_architecture_inventory.py [--check]`: generate or verify
`config/architecture-inventory.json` from the current source tree.
- `python tools/verify_datahub_console.py`: end-to-end self-test for the Data Hub console
bridge — invite-code single use, admin-only gate, credential masking, model pool and
membership round-trips, service-token checks. Starts both services on temporary ports with
temporary data directories and restores the repository state on exit.
- `python tools/verify_datahub_console_ui.py [--shots <dir>]`: the browser pass over the same
sandbox (gate, credential editor, vendor model pool, members and invite codes, day/night
themes, 1030px narrow layout). Requires Playwright and a local Chromium.
- `python tools/backfill_recent_snapshots.py --account <admin> [--lookback 60] [--dry-run]`:
auditable recent trading-day dashboard snapshot backfill. See
`docs/maintenance/行情历史补档.md`.
+297
View File
@@ -0,0 +1,297 @@
"""数据中枢控制台端到端自测:主站与中枢真的对话一遍,不是 mock。
跑法:python tools/verify_datahub_console.py
覆盖邀请码一次性注册、管理员门禁、凭证掩码、模型池与会员桥接读写、服务令牌校验。
两个服务都起在临时端口 + 临时数据目录,跑完自动清理,不碰任何现网数据。
"""
from __future__ import annotations
import http.cookies
import json
import os
import shutil
import socket
import sqlite3
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
# 从 tools/ 下运行,仓库根不在 sys.path 上;主站包按仓库根导入。
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
HUB_TOKEN = "smoke-hub-admin-token-0123456789abcdef"
PASSWORD = "SmokePass123"
FAILURES: list[str] = []
def free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return int(sock.getsockname()[1])
def check(label: str, ok: bool, detail: str = "") -> None:
print(f" {'PASS' if ok else 'FAIL'} {label}{('' + detail) if detail else ''}")
if not ok:
FAILURES.append(label)
def request(url: str, payload=None, method="GET", headers=None, cookie="") -> tuple[int, dict, str]:
data = json.dumps(payload).encode("utf-8") if payload is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Content-Type", "application/json; charset=utf-8")
for key, value in (headers or {}).items():
req.add_header(key, value)
if cookie:
req.add_header("Cookie", cookie)
try:
with urllib.request.urlopen(req, timeout=15) as response:
raw = response.read().decode("utf-8")
set_cookie = response.headers.get("Set-Cookie") or ""
return response.status, _parse(raw), set_cookie
except urllib.error.HTTPError as exc:
return exc.code, _parse(exc.read().decode("utf-8")), exc.headers.get("Set-Cookie") or ""
def _parse(raw: str) -> dict:
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
return {"_raw": raw[:200]}
return parsed if isinstance(parsed, dict) else {"_list": parsed}
def session_cookie(header: str) -> str:
jar = http.cookies.SimpleCookie()
jar.load(header)
morsel = jar.get("xiaobai_session")
return f"xiaobai_session={morsel.value}" if morsel else ""
def wait_for(url: str, seconds: float = 20.0) -> bool:
deadline = time.time() + seconds
while time.time() < deadline:
try:
urllib.request.urlopen(url, timeout=2)
return True
except urllib.error.HTTPError:
return True
except OSError:
time.sleep(0.25)
return False
REVIEW_DB = ROOT / "data" / "review.db"
ENV_FILE = ROOT / ".env"
class RepoSandbox:
"""主站的库路径和 .env 都写死在仓库里,跑之前挪开、跑完原样放回。"""
def __enter__(self) -> "RepoSandbox":
self.stash = Path(tempfile.mkdtemp(prefix="hel560-stash-"))
for path in (REVIEW_DB, ENV_FILE):
if path.exists():
shutil.copy2(path, self.stash / path.name)
if REVIEW_DB.exists():
REVIEW_DB.unlink() # 自测需要一个空库来验证"首个账号免邀请码"
return self
def __exit__(self, *exc_info: object) -> None:
for path in (REVIEW_DB, ENV_FILE):
saved = self.stash / path.name
if saved.exists():
shutil.copy2(saved, path)
elif path.exists():
path.unlink()
for extra in REVIEW_DB.parent.glob("review.db-*"):
extra.unlink()
shutil.rmtree(self.stash, ignore_errors=True)
def start_review(workdir: Path, port: int) -> None:
os.environ["HUB_ADMIN_TOKEN"] = HUB_TOKEN
from backend.application import RequestHandler, SERVICE # noqa: F401
from http.server import ThreadingHTTPServer
server = ThreadingHTTPServer(("127.0.0.1", port), RequestHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
def start_hub(workdir: Path, port: int, review_port: int) -> None:
sys.path.insert(0, str(ROOT / "xiaobai-datahub"))
from cryptography.fernet import Fernet
os.environ.update(
{
"DATAHUB_DB_PATH": str(workdir / "hub.db"),
"DATAHUB_BACKUP_DIR": str(workdir / "backups"),
"DATAHUB_ENCRYPTION_KEY": Fernet.generate_key().decode(),
"DATAHUB_TOKEN": "smoke-datahub-token",
"HUB_ADMIN_TOKEN": HUB_TOKEN,
"REVIEW_BASE_URL": f"http://127.0.0.1:{review_port}",
"REVIEW_PUBLIC_URL": f"http://127.0.0.1:{review_port}",
"DATAHUB_SCHEDULER_ENABLED": "0",
}
)
from datahub.httpapp import make_handler
from datahub.hub import Hub
from datahub.settings import load_settings
from http.server import ThreadingHTTPServer
hub = Hub(load_settings(os.environ))
server = ThreadingHTTPServer(("127.0.0.1", port), make_handler(hub))
threading.Thread(target=server.serve_forever, daemon=True).start()
def main() -> int:
workdir = Path(tempfile.mkdtemp(prefix="hel560-smoke-"))
review_port, hub_port = free_port(), free_port()
review = f"http://127.0.0.1:{review_port}"
hub = f"http://127.0.0.1:{hub_port}"
with RepoSandbox():
start_review(workdir, review_port)
if not wait_for(f"{review}/api/session"):
print("主站没起来")
return 1
start_hub(workdir, hub_port, review_port)
if not wait_for(f"{hub}/livez"):
print("数据中枢没起来")
return 1
print("\n[1] 首个账号免邀请码,之后注册强制邀请码")
status, body, cookie_header = request(f"{review}/api/auth/register", {"username": "boss", "password": PASSWORD}, "POST")
check("首个账号可直接注册(自动成为管理员)", status == 201, f"{status} {body.get('error', '')}")
admin_cookie = session_cookie(cookie_header)
status, body, _ = request(f"{review}/api/auth/register", {"username": "nobody", "password": PASSWORD}, "POST")
check("第二个账号没邀请码被拒", status >= 400 and "邀请码" in str(body.get("error", "")), f"{status} {body.get('error', '')}")
print("\n[2] 未登录 / 非管理员进不了数据中枢")
status, body, _ = request(f"{hub}/admin/api/session")
check("未登录访问控制台返回 401 并给出主站登录地址", status == 401 and "/login/" in str(body.get("login_url", "")), f"{status} {body}")
print("\n[3] 主站管理员会话直接进控制台(跨端口共享 cookie)")
status, session, _ = request(f"{hub}/admin/api/session", cookie=admin_cookie)
check("带主站会话访问控制台返回 200", status == 200, f"{status} {session}")
check("控制台回显主站用户名", session.get("username") == "boss", str(session.get("username")))
csrf = str(session.get("csrf") or "")
check("下发了 CSRF 令牌", len(csrf) >= 32, csrf[:12])
write_headers = {"X-CSRF-Token": csrf}
print("\n[4] 写接口必须带 CSRF")
status, body, _ = request(f"{hub}/admin/api/invites/create", {"count": 1}, "POST", cookie=admin_cookie)
check("缺 CSRF 的写请求被拒", status == 401, f"{status} {body}")
print("\n[5] 控制台生成邀请码 → 注册消耗一次 → 二次使用失败")
status, created, _ = request(f"{hub}/admin/api/invites/create", {"count": 2}, "POST", write_headers, admin_cookie)
check("控制台生成邀请码成功", status == 200 and len(created.get("created") or []) == 2, f"{status} {created.get('error', '')}")
codes = [item["code"] for item in created.get("created") or []]
check("列表只给掩码,不回明文", all("" in row["code_masked"] for row in created.get("codes") or []))
status, body, member_cookie_header = request(
f"{review}/api/auth/register", {"username": "xiaochen", "password": PASSWORD, "invite_code": codes[0]}, "POST"
)
check("凭邀请码注册成功", status == 201, f"{status} {body.get('error', '')}")
member_cookie = session_cookie(member_cookie_header)
status, body, _ = request(
f"{review}/api/auth/register", {"username": "again", "password": PASSWORD, "invite_code": codes[0]}, "POST"
)
check("同一邀请码第二次注册被拒", status >= 400, f"{status} {body.get('error', '')}")
print("\n[6] 作废后的邀请码不能注册")
handles = {row["code_masked"][:7]: row["code_id"] for row in created.get("codes") or []}
target = handles.get(codes[1][:7])
status, body, _ = request(f"{hub}/admin/api/invites/revoke", {"code_id": target}, "POST", write_headers, admin_cookie)
check("控制台作废未使用的邀请码", status == 200, f"{status} {body.get('error', '')}")
status, body, _ = request(
f"{review}/api/auth/register", {"username": "revoked", "password": PASSWORD, "invite_code": codes[1]}, "POST"
)
check("已作废邀请码无法注册", status >= 400, f"{status} {body.get('error', '')}")
print("\n[7] 普通会员账号进不了控制台")
status, body, _ = request(f"{hub}/admin/api/session", cookie=member_cookie)
check("非管理员访问控制台返回 403", status == 403, f"{status} {body}")
status, body, _ = request(f"{hub}/admin/api/members", cookie=member_cookie)
check("非管理员读会员接口同样 403", status == 403, f"{status} {body}")
print("\n[8] 数据源凭证在线写入 + 掩码回显")
status, body, _ = request(
f"{hub}/admin/api/credentials/tushare", {"tushare_token": "tok-abcdefgh1234"}, "POST", write_headers, admin_cookie
)
check("Tushare Token 保存成功", status == 200, f"{status} {body.get('error', '')}")
status, sources, _ = request(f"{hub}/admin/api/sources", cookie=admin_cookie)
tushare = next((row for row in sources.get("items") or [] if row.get("provider") == "tushare"), {})
credential = tushare.get("credential") or {}
check("数据源卡回显掩码而非明文", credential.get("configured") and "1234" in str(credential.get("last4")), str(credential))
check("接口不回传明文 Token", "tok-abcdefgh1234" not in json.dumps(sources, ensure_ascii=False))
print("\n[9] 模型池 / 会员 / 邀请码三页都能从控制台读到")
for label, path in (("模型池", "/admin/api/models"), ("会员", "/admin/api/members"), ("邀请码", "/admin/api/invites")):
status, body, _ = request(f"{hub}{path}", cookie=admin_cookie)
check(f"{label}接口可读", status == 200, f"{status} {body.get('error', '')}")
print("\n[10] 控制台改模型池 → 主站落库")
models = [{"id": "smoke-main", "name": "冒烟主模型", "model": "gpt-4o", "base_url": "https://api.openai.com/v1", "api_key": "sk-smoke-key-9911"}]
status, body, _ = request(
f"{hub}/admin/api/models/save", {"models": models, "primary_model_id": "smoke-main"}, "POST", write_headers, admin_cookie
)
check("控制台保存模型池成功", status == 200, f"{status} {body.get('error', '')}")
groups = body.get("groups") or []
check("按供应商归组返回", len(groups) == 1 and groups[0]["base_url"] == "https://api.openai.com/v1", str(groups)[:120])
check("供应商显示密钥后四位而非明文", groups and groups[0].get("key_last4") == "9911", str(groups[0].get("key_last4") if groups else ""))
check("模型接口不回传明文密钥", "sk-smoke-key-9911" not in json.dumps(body, ensure_ascii=False))
status, status_body, _ = request(
f"{review}/api/admin/settings", None, "GET", {"X-Hub-Admin-Token": HUB_TOKEN}, admin_cookie
)
status, mainsite, _ = request(f"{review}/api/hub-admin/status", {}, "POST", {"X-Hub-Admin-Token": HUB_TOKEN})
pool = (mainsite.get("llm") or {}).get("models") or []
check("主站确实存下了这个模型", any(m["id"] == "smoke-main" for m in pool), str([m.get("id") for m in pool]))
print("\n[11] 会员额度与会员开通经控制台落到主站")
status, body, _ = request(f"{hub}/admin/api/members/quota", {"member_daily_limit": 88}, "POST", write_headers, admin_cookie)
check("保存会员每日额度成功", status == 200 and (body.get("membership") or {}).get("member_daily_limit") == 88, f"{status} {body.get('membership')}")
member_id = next((u["id"] for u in body.get("users") or [] if u["username"] == "xiaochen"), 0)
status, body, _ = request(
f"{hub}/admin/api/members/save", {"user_id": member_id, "status": "active", "duration": "3_months"}, "POST", write_headers, admin_cookie
)
row = next((u for u in body.get("users") or [] if u["id"] == member_id), {})
check("开通 3 个月会员生效", status == 200 and row.get("membership_status") == "active" and row.get("membership_expires_at"), f"{status} {row.get('membership_status')} {row.get('membership_expires_at')}")
print("\n[12] 桥接令牌是唯一信任边界")
status, body, _ = request(f"{review}/api/hub-admin/status", {}, "POST", {"X-Hub-Admin-Token": "wrong-token"})
check("桥接端点拒绝错误令牌", status == 401, f"{status} {body}")
status, body, _ = request(f"{review}/api/hub-admin/status", {}, "POST")
check("桥接端点拒绝无令牌", status == 401, f"{status} {body}")
status, body, _ = request(f"{review}/api/hub-admin/invites", {}, "POST", {"X-Hub-Admin-Token": HUB_TOKEN}, admin_cookie)
check("带正确令牌可读邀请码", status == 200, f"{status} {body.get('error', '')}")
print("\n[13] 退出登录会真的销毁主站会话")
status, body, _ = request(f"{hub}/admin/api/logout", {}, "POST", write_headers, admin_cookie)
check("控制台退出返回主站登录地址", status == 200 and "/login/" in str(body.get("login_url", "")), f"{status} {body}")
status, body, _ = request(f"{review}/api/session", cookie=admin_cookie)
check("主站会话已失效", not (body.get("authenticated") or body.get("user")), str(body)[:120])
print("\n[14] 并发使用同一邀请码只成功一次")
os.environ["HUB_ADMIN_TOKEN"] = HUB_TOKEN
with sqlite3.connect(REVIEW_DB) as connection:
rows = connection.execute("SELECT status, COUNT(*) FROM invite_codes GROUP BY status").fetchall()
counts = dict(rows)
check("邀请码状态落库正确(1 已用 / 1 已作废)", counts.get("used") == 1 and counts.get("revoked") == 1, str(counts))
shutil.rmtree(workdir, ignore_errors=True)
print("\n" + "=" * 60)
if FAILURES:
print(f"FAILED {len(FAILURES)} 项:")
for item in FAILURES:
print(" - " + item)
return 1
print("数据中枢控制台端到端自测全部通过")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+296
View File
@@ -0,0 +1,296 @@
"""数据中枢控制台浏览器自测:真的打开控制台,点一遍新页面。
跑法:python tools/verify_datahub_console_ui.py [--shots 目录]
覆盖门禁、凭证区、模型池(拉取失败→手动录入)、会员与邀请码、日夜主题、1030 窄屏。
与 verify_datahub_console.py 共用沙箱:临时端口 + 临时库,跑完把仓库状态原样放回。
需要 Playwright 与本地 Chromium。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
from playwright.sync_api import sync_playwright
import verify_datahub_console as backend
ROOT = Path(__file__).resolve().parents[1]
FAILURES: list[str] = []
def chrome_path() -> str | None:
"""Playwright 默认渠道是 msedge;这里优先用它自带的 chromium。
CHROMIUM_PATH 可显式指定;否则在 Playwright 缓存里找一份(版本号会随
Playwright 升级变化,所以按目录名匹配而不写死)。缺 GTK 系 so 时,用
LD_LIBRARY_PATH 指向本地补齐的库目录再跑本脚本。
"""
explicit = os.environ.get("CHROMIUM_PATH")
if explicit:
return explicit
cache = Path.home() / ".cache/ms-playwright"
builds = sorted(cache.glob("chromium-*/chrome-linux*/chrome"), reverse=True)
return str(builds[0]) if builds else None
def check(label: str, ok: bool, detail: str = "") -> None:
print(f" {'PASS' if ok else 'FAIL'} {label}{('' + detail) if detail else ''}")
if not ok:
FAILURES.append(label)
def main() -> int:
shots = Path(sys.argv[sys.argv.index("--shots") + 1]) if "--shots" in sys.argv else None
if shots:
shots.mkdir(parents=True, exist_ok=True)
workdir = Path(backend.tempfile.mkdtemp(prefix="hel560-ui-"))
review_port, hub_port = backend.free_port(), backend.free_port()
review, hub = f"http://127.0.0.1:{review_port}", f"http://127.0.0.1:{hub_port}"
with backend.RepoSandbox():
backend.start_review(workdir, review_port)
backend.wait_for(f"{review}/api/session")
backend.start_hub(workdir, hub_port, review_port)
backend.wait_for(f"{hub}/livez")
status, _, cookie_header = backend.request(
f"{review}/api/auth/register", {"username": "boss", "password": backend.PASSWORD}, "POST"
)
admin_cookie = backend.session_cookie(cookie_header).split("=", 1)[1]
with sync_playwright() as play:
browser = play.chromium.launch(executable_path=chrome_path())
errors: list[str] = []
def new_page(width: int, logged_in: bool):
context = browser.new_context(viewport={"width": width, "height": 900})
if logged_in:
context.add_cookies([
{"name": "xiaobai_session", "value": admin_cookie, "domain": "127.0.0.1", "path": "/"}
])
page = context.new_page()
page.on("pageerror", lambda exc: errors.append(f"{width}px pageerror: {exc}"))
page.on("console", lambda msg: errors.append(f"{width}px console.{msg.type}: {msg.text}")
if msg.type == "error" else None)
page.on("response", lambda res: errors.append(f"{width}px HTTP {res.status} {res.url}")
if res.status >= 400 else None)
return context, page
print("\n[UI-1] 未登录时只看到门禁,不再有独立登录表单")
context, page = new_page(1440, logged_in=False)
page.goto(f"{hub}/admin/", wait_until="networkidle")
check("门禁面板可见", page.is_visible("#gate-view"))
check("控制台外壳隐藏", page.is_hidden("#appRoot"))
check("提示去主站登录", "登录" in page.inner_text("#gate-desc"), page.inner_text("#gate-desc")[:40])
check("给出主站登录链接", "8765" in (page.get_attribute("#gate-login", "href") or "") or
str(review_port) in (page.get_attribute("#gate-login", "href") or ""))
check("页面里没有独立账号输入框", page.locator("#login-form, #change-form").count() == 0)
if shots:
page.screenshot(path=str(shots / "ui-gate.png"), full_page=True)
context.close()
print("\n[UI-2] 带主站管理员会话直接进控制台")
context, page = new_page(1440, logged_in=True)
page.goto(f"{hub}/admin/", wait_until="networkidle")
page.wait_for_selector("#appRoot:not([hidden])", timeout=15000)
check("控制台外壳渲染", page.is_visible("#appRoot"))
check("右上角显示主站用户名", page.inner_text("#who").strip() == "boss", page.inner_text("#who"))
check("导航含模型池与会员管理", page.locator('[data-nav="models"]').count() == 1
and page.locator('[data-nav="members"]').count() == 1)
print("\n[UI-3] 数据源页带可编辑凭证区")
page.click('[data-nav="sources"]')
page.wait_for_selector('[data-cred-form="tushare"]', timeout=10000)
check("Tushare 卡出现凭证输入框", page.locator('[data-cred-input="tushare_token"]').count() == 1)
check("iFinD 卡也能在线填凭证", page.locator('[data-cred-input="ifind_refresh_token"]').count() == 1)
check("凭证输入是密码框(不回显明文)",
page.get_attribute('[data-cred-input="tushare_token"]', "type") == "password")
check("原有接口清单没被删掉", page.locator("table.dtable").count() >= 1)
if shots:
page.screenshot(path=str(shots / "ui-sources-night.png"), full_page=True)
print("\n[UI-4] 模型池按供应商组织,可拉取/手填")
page.click('[data-nav="models"]')
page.wait_for_selector("#addVendor", timeout=10000)
check("有新增供应商入口", page.is_visible("#addVendor"))
check("有调用编排(主/辅模型)", page.locator("#primaryModel").count() == 1 and page.locator("#fallbackModel").count() == 1)
check("顶部条给出供应商与模型数", "供应商" in page.inner_text(".strip") and "主模型" in page.inner_text(".strip"))
page.select_option("#newVendorPreset", "https://api.openai.com/v1")
page.click("#addVendor")
page.wait_for_selector('[data-vend-fetch="https://api.openai.com/v1"]', timeout=10000)
check("供应商卡有获取模型列表按钮", page.is_visible('[data-vend-fetch="https://api.openai.com/v1"]'))
check("供应商卡有 BASE URL 与 API KEY 两个字段",
page.locator('[data-vend-url="https://api.openai.com/v1"]').count() == 1
and page.locator('[data-vend-key="https://api.openai.com/v1"]').count() == 1)
check("供应商卡可单独保存", page.is_visible('[data-vend-save="https://api.openai.com/v1"]'))
print("\n[UI-4b] 拉取失败后退回卡内手动录入(不弹系统对话框)")
page.fill('[data-vend-key="https://api.openai.com/v1"]', "sk-invalid-for-smoke")
with page.expect_response(lambda res: "/models/fetch" in res.url, timeout=20000):
page.click('[data-vend-fetch="https://api.openai.com/v1"]')
page.wait_for_selector('[data-vend-manual-input="https://api.openai.com/v1"]', timeout=15000)
check("拉取失败给出失败提示", "拉取失败" in page.inner_text(".vend-note.bad"),
page.inner_text(".vend-note.bad")[:80])
check("失败后出现手动录入输入框", page.is_visible('[data-vend-manual-input="https://api.openai.com/v1"]'))
page.fill('[data-vend-manual-input="https://api.openai.com/v1"]', "gpt-4o")
with page.expect_response(lambda res: "/models/save" in res.url, timeout=20000) as saved:
page.click('[data-vend-manual-add="https://api.openai.com/v1"]')
check("手动录入的模型保存成功", saved.value.status == 200, str(saved.value.status))
page.wait_for_selector(".model-row", timeout=15000)
row = page.inner_text(".model-row")
check("模型行显示名称/供应商/测试与删除", "gpt-4o" in row and "OpenAI" in row
and page.locator("[data-model-test]").count() >= 1
and page.locator("[data-model-remove]").count() >= 1, row.replace("\n", " | ")[:100])
check("首个模型自动成为主模型", "主模型" in row, row.replace("\n", " | ")[:80])
if shots:
page.screenshot(path=str(shots / "ui-models-night.png"), full_page=True)
print("\n[UI-5] 会员管理 + 邀请码:生成、复制、作废")
page.click('[data-nav="members"]')
page.wait_for_selector("#createInvites", timeout=10000)
check("会员表渲染出主站账号", "boss" in page.inner_text("table.dtable"))
check("有会员额度输入与保存", page.locator("#memberQuota").count() == 1 and page.locator("#saveQuota").count() == 1)
page.click("#createInvites")
page.wait_for_selector("[data-invite-copy]", timeout=10000)
code_text = page.inner_text("td.invite-code")
check("生成后当次显示完整邀请码", code_text.count("-") >= 3 and "" not in code_text, code_text)
check("未使用的码可复制可作废",
page.locator("[data-invite-copy]").count() >= 1 and page.locator("[data-invite-revoke]").count() >= 1)
page.once("dialog", lambda dialog: dialog.accept())
revoke_response = None
with page.expect_response(lambda res: "/invites/revoke" in res.url, timeout=10000) as caught:
page.click("[data-invite-revoke]")
revoke_response = caught.value
page.wait_for_timeout(600)
check("作废接口返回 200", revoke_response.status == 200,
f"{revoke_response.status} {revoke_response.text()[:120]}")
invite_row = page.inner_text("tr:has(td.invite-code)")
check("作废后该行状态变为作废", "作废" in invite_row, invite_row.replace("\n", " | ")[:120])
check("作废后不再显示完整码,只留掩码", "" in page.inner_text("td.invite-code"),
page.inner_text("td.invite-code"))
check("作废后复制与作废按钮都收起",
page.locator("[data-invite-copy]").count() == 0 and page.locator("[data-invite-revoke]").count() == 0)
if shots:
page.screenshot(path=str(shots / "ui-members-night.png"), full_page=True)
print("\n[UI-6] 日间 / 夜间主题切换")
check("默认夜间", page.get_attribute("html", "data-theme") == "night")
page.click("#themeBtn")
page.wait_for_timeout(300)
check("切到日间后 data-theme=day", page.get_attribute("html", "data-theme") == "day")
body_bg = page.evaluate("getComputedStyle(document.body).backgroundColor")
check("日间底色是浅色", _is_light(body_bg), body_bg)
check("按钮文案回切为夜间", page.inner_text("#themeBtn").strip() == "夜间", page.inner_text("#themeBtn"))
if shots:
page.screenshot(path=str(shots / "ui-members-day.png"), full_page=True)
page.click('[data-nav="sources"]')
page.wait_for_timeout(600)
page.screenshot(path=str(shots / "ui-sources-day.png"), full_page=True)
page.click('[data-nav="models"]')
page.wait_for_timeout(600)
page.screenshot(path=str(shots / "ui-models-day.png"), full_page=True)
page.click("#themeBtn")
page.reload(wait_until="networkidle")
page.wait_for_selector("#appRoot:not([hidden])", timeout=15000)
check("主题选择刷新后保持", page.get_attribute("html", "data-theme") == "night")
context.close()
print("\n[UI-7] 窄屏 1030px:输入框与按钮竖排不重叠")
context, page = new_page(1030, logged_in=True)
page.goto(f"{hub}/admin/#sources", wait_until="networkidle")
page.wait_for_selector('[data-cred-form="tushare"]', timeout=15000)
# 窄屏下"输入框一行、按钮整排落到下一行"是样图 1030 的硬要求
stacked = page.evaluate(
"""() => {
const bad = [];
document.querySelectorAll('.cred-box').forEach((box) => {
const input = box.querySelector('input');
const button = box.querySelector('.pbtn');
if (!input || !button) return;
const a = input.getBoundingClientRect();
const b = button.getBoundingClientRect();
const overlap = a.right > b.left && a.left < b.right && a.bottom > b.top && a.top < b.bottom;
if (overlap) bad.push(box.dataset.credForm + ':重叠');
if (b.top < a.bottom - 1) bad.push(box.dataset.credForm + ':同行');
});
return bad;
}"""
)
check("凭证输入框与按钮竖排不重叠", stacked == [], str(stacked))
clipped = page.evaluate(
"() => [...document.querySelectorAll('input, select, .pbtn, .tbtn')]"
".filter((el) => el.getBoundingClientRect().right > window.innerWidth + 1).length"
)
check("窄屏没有控件溢出视口", clipped == 0, str(clipped))
if shots:
page.screenshot(path=str(shots / "ui-sources-1030.png"), full_page=True)
page.goto(f"{hub}/admin/#models", wait_until="networkidle")
page.wait_for_selector(".vend-line", timeout=15000)
# 供应商卡的地址/Key/按钮在宽屏并排,窄屏必须整列竖排
model_stacked = page.evaluate(
"""() => {
const line = document.querySelector('.vend-line');
const kids = [...line.children];
const bad = [];
for (let i = 1; i < kids.length; i += 1) {
const prev = kids[i - 1].getBoundingClientRect();
const cur = kids[i].getBoundingClientRect();
if (cur.top < prev.bottom - 1) bad.push(i);
}
return bad;
}"""
)
check("供应商卡地址/Key/按钮窄屏竖排", model_stacked == [], str(model_stacked))
model_clipped = page.evaluate(
"() => [...document.querySelectorAll('input, select, .pbtn, .tbtn, .model-row')]"
".filter((el) => el.getBoundingClientRect().right > window.innerWidth + 1).length"
)
check("模型页窄屏没有控件溢出视口", model_clipped == 0, str(model_clipped))
if shots:
page.screenshot(path=str(shots / "ui-models-1030.png"), full_page=True)
page.goto(f"{hub}/admin/#members", wait_until="networkidle")
page.wait_for_timeout(800)
page.screenshot(path=str(shots / "ui-members-1030.png"), full_page=True)
context.close()
print("\n[UI-8] 全程无 JS 异常与服务端错误")
# 预期噪音:未登录时门禁本来就会拿到 401;favicon 本项目没提供。
def expected(line: str) -> bool:
if "favicon" in line:
return True
if "401" in line and "/admin/api/session" in line:
return True
if "400" in line and "/admin/api/models/fetch" in line:
return True # UI-4b 故意用错 Key 拉取,400 是本轮要验的正确行为
if "console.error: Failed to load resource" in line:
return True # 上面两类的浏览器侧复述,URL 已单独判过
return False
crashes = [line for line in errors if "pageerror" in line]
server_errors = [line for line in errors if "HTTP 5" in line]
unexpected = [line for line in errors if not expected(line) and "pageerror" not in line
and "HTTP 5" not in line]
check("没有 JS 未捕获异常", crashes == [], "; ".join(crashes[:3]))
check("没有 5xx 服务端错误", server_errors == [], "; ".join(server_errors[:3]))
check("没有其它意外失败请求", unexpected == [], "; ".join(unexpected[:3]))
browser.close()
backend.shutil.rmtree(workdir, ignore_errors=True)
print("\n" + "=" * 60)
if FAILURES:
print(f"FAILED {len(FAILURES)} 项:")
for item in FAILURES:
print(" - " + item)
return 1
print("数据中枢控制台浏览器自测全部通过")
return 0
def _is_light(colour: str) -> bool:
numbers = [int(part) for part in colour.replace("rgba", "").replace("rgb", "").strip("() ").split(",")[:3]]
return sum(numbers) / 3 > 160
if __name__ == "__main__":
raise SystemExit(main())