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