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
+8 -4
View File
@@ -632,6 +632,7 @@ test("admin shell opens every primary workspace and global search", async ({ pag
await expect(page.locator("#authGate")).toBeHidden();
await openHeaderCommandMenu(page);
await expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#marketAdminButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeVisible();
await page.keyboard.press("Escape");
await page.locator("#alertButton").click();
@@ -1753,6 +1754,7 @@ test("regular account cannot see admin controls and member features are gated",
await mockApplication(page, session("user", false));
await page.goto("/index.html");
await expect(page.locator("#settingsButton")).toBeHidden();
await expect(page.locator("#marketAdminButton")).toBeHidden();
await expect(page.locator("#syncButton")).toBeHidden();
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
await page.locator('[data-view="screenerView"]').first().click();
@@ -3332,9 +3334,9 @@ test("global dialogs share the stage 18 geometry without changing account or adm
await page.locator("#closeSettingsDialog").click();
await openHeaderCommandMenu(page);
await page.locator("#settingsButton").click();
await page.locator("#marketAdminButton").click();
await expect(page.locator("#adminDialog")).toHaveAttribute("aria-labelledby", "adminDialogTitle");
await expect(page.locator("#adminSectionSelect")).toBeVisible();
await expect(page.locator("#systemMarketForm")).toBeVisible();
const adminBox = await page.locator("#adminDialog").boundingBox();
expect(adminBox.width).toBeLessThanOrEqual(902);
expect(Math.abs(adminBox.x + adminBox.width / 2 - 720)).toBeLessThanOrEqual(2);
@@ -3510,7 +3512,7 @@ test("B-199 screener review and account surfaces fit day night viewports", async
await page.locator("#closeSettingsDialog").click();
await openHeaderCommandMenu(page);
await page.locator("#settingsButton").click();
await page.locator("#marketAdminButton").click();
await expect(page.locator("#adminDialog")).toBeVisible();
await shot("admin-1600-day");
await page.locator("#closeAdminDialog").click();
@@ -3609,6 +3611,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeVisible();
await expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#marketAdminButton")).toBeVisible();
await expect(page.locator("#accountAdminBadge")).toBeVisible();
await expect(page.locator("#accountVipBadge")).toBeVisible();
await expect(page.locator("#accountButton")).toBeVisible();
@@ -3621,7 +3624,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
expect(geometry.nameFits, `${viewport.width} account name truncated`).toBe(true);
await page.locator("#refreshButton").click();
await expect(page.locator("#loadingOverlay")).toBeHidden();
await page.locator("#settingsButton").click();
await page.locator("#marketAdminButton").click();
await expect(page.locator("#adminDialog")).toBeVisible();
await page.locator("#closeAdminDialog").click();
await page.locator("#accountButton").click();
@@ -3668,6 +3671,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeHidden();
await expect(page.locator("#settingsButton")).toBeHidden();
await expect(page.locator("#marketAdminButton")).toBeHidden();
await expect(page.locator("#accountAdminBadge")).toBeHidden();
await expect(page.locator("#accountVipBadge")).toBeVisible();
await expect(page.locator("#accountName")).toHaveText("normal_user");
+4 -9
View File
@@ -264,8 +264,7 @@ const SYSTEM_PAGES = [
["system/profile", "账号资料"],
["system/password", "修改密码"],
["system/membership", "会员状态"],
["system/admin", "系统设置"],
["system/members", "会员管理"],
["system/admin", "行情管理"],
];
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
@@ -360,8 +359,7 @@ test("system management pages render real content instead of placeholders", asyn
await expect(page.locator(".m-sys-grid")).toBeVisible();
await navigateToFeature(page, "system/admin");
await expect(page.locator("#m-sys-token")).toBeVisible();
await navigateToFeature(page, "system/members");
await expect(page.locator("#m-sys-member-limit")).toBeVisible();
await expect(page.locator("[data-system-datahub]")).toBeVisible();
});
test("system home groups entries and keeps admin-only items gated", async ({ page }) => {
@@ -403,10 +401,7 @@ test("system settings tabs, model editor, delete confirm and theme toggle work",
await page.locator("[data-theme-toggle]").click();
await expect.poll(async () => page.locator("#m-app").getAttribute("data-theme")).not.toBe(before);
await navigateToFeature(page, "system/members");
await page.locator("[data-system-open-member]").click();
await expect(page.locator(".m-sheet-root.is-open")).toBeVisible();
await expect(page.locator(".m-sheet-head h2")).toContainText("管理会员");
await expect(page.locator("[data-system-datahub]").first()).toBeVisible();
});
test("password mismatch shows inline error instead of a silent submit", async ({ page }) => {
@@ -439,7 +434,7 @@ test("non-admin cannot open system admin pages as placeholders", async ({ page }
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-system-page='home']")).toBeVisible();
await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0);
await expect(page.locator('[data-route="#/feature/system/members"]')).toHaveCount(0);
await expect(page.locator("[data-system-datahub]")).toHaveCount(0);
await expect(page.locator("[data-system-switch]")).toBeVisible();
await navigateToFeature(page, "system/admin");
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
+6 -1
View File
@@ -35,7 +35,12 @@ class AccountSwitchGrantTests(unittest.TestCase):
self.bound_user_id = int(user_id)
def _register(self, username: str, device_hash: str = "") -> dict:
return self.service.register(username, "Password123", device_hash or self.device_a)
invite = ""
if self.database.count_users():
invite = self.service.generate_invite_codes(1)[0]["code"]
return self.service.register(
username, "Password123", device_hash or self.device_a, invite
)
def test_login_records_accounts_for_the_current_device_only(self) -> None:
first = self._register("alpha_user")
+4 -3
View File
@@ -26,6 +26,7 @@ class DatabaseMigrationTests(unittest.TestCase):
("0003", "extend_llm_audit"),
("0004", "add_mentor_note"),
("0005", "create_account_switch_grants"),
("0006", "create_invite_codes"),
],
)
columns = {
@@ -40,7 +41,7 @@ class DatabaseMigrationTests(unittest.TestCase):
count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"]
self.assertEqual(count, 5)
self.assertEqual(count, 6)
def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
self,
@@ -61,7 +62,7 @@ class DatabaseMigrationTests(unittest.TestCase):
count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"]
self.assertEqual(count, 5)
self.assertEqual(count, 6)
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
with tempfile.TemporaryDirectory() as root:
@@ -88,7 +89,7 @@ class DatabaseMigrationTests(unittest.TestCase):
"PRAGMA table_info(mentor_preferences)"
)
]
self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005"})
self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005", "0006"})
self.assertIn("note", note_rows)
def test_database_with_unknown_migration_is_rejected(self) -> None:
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
import json
import unittest
from http import HTTPStatus
from pathlib import Path
from unittest import mock
from backend.application import HUB_SERVICE_HANDLERS, RequestHandler
ROOT = Path(__file__).resolve().parents[1]
SERVICE_TOKEN = "hub-admin-token-for-tests"
class FakeHeaders(dict):
def get(self, name, default=""): # type: ignore[override]
return super().get(name, default)
class HubAdminBridgeTests(unittest.TestCase):
def handler(self, path: str, token: str, calls: list[str]) -> RequestHandler:
handler = RequestHandler.__new__(RequestHandler)
handler.path = path
handler.headers = FakeHeaders({"X-Hub-Admin-Token": token} if token else {})
handler.responses = []
handler.send_json = lambda payload, status=HTTPStatus.OK, headers=None: (
handler.responses.append((status, payload))
)
handler.require_auth = lambda: calls.append("auth") or True
handler.require_csrf = lambda: calls.append("csrf") or True
handler.require_access = lambda method, route: calls.append("access") or True
return handler
def test_every_bridge_path_has_a_real_handler(self) -> None:
for path, handler_name in HUB_SERVICE_HANDLERS.items():
with self.subTest(path=path):
self.assertTrue(path.startswith("/api/hub-admin/"))
self.assertTrue(callable(getattr(RequestHandler, handler_name)))
def test_bridge_paths_stay_out_of_the_browser_route_registry(self) -> None:
registry = json.loads(
(ROOT / "config" / "api.config.json").read_text(encoding="utf-8")
)
registered = {route["path"] for route in registry["routes"]}
for path in HUB_SERVICE_HANDLERS:
with self.subTest(path=path):
self.assertNotIn(path, registered)
self.assertIsNone(RequestHandler.route_registry.resolve("POST", path))
def test_missing_or_wrong_service_token_is_rejected(self) -> None:
for token in ("", "wrong-token"):
with self.subTest(token=token), mock.patch.dict(
"os.environ", {"HUB_ADMIN_TOKEN": SERVICE_TOKEN}
):
calls: list[str] = []
handler = self.handler("/api/hub-admin/status", token, calls)
handler.hub_system_status = lambda: calls.append("dispatched")
RequestHandler.do_POST(handler)
self.assertEqual(calls, [])
status, payload = handler.responses[-1]
self.assertEqual(status, HTTPStatus.UNAUTHORIZED)
self.assertIn("服务令牌", payload["error"])
def test_unset_server_token_refuses_every_bridge_call(self) -> None:
with mock.patch.dict("os.environ", {"HUB_ADMIN_TOKEN": ""}):
calls: list[str] = []
handler = self.handler("/api/hub-admin/status", SERVICE_TOKEN, calls)
handler.hub_system_status = lambda: calls.append("dispatched")
RequestHandler.do_POST(handler)
self.assertEqual(calls, [])
self.assertEqual(handler.responses[-1][0], HTTPStatus.UNAUTHORIZED)
def test_valid_service_token_dispatches_without_session_guards(self) -> None:
for path, handler_name in HUB_SERVICE_HANDLERS.items():
with self.subTest(path=path), mock.patch.dict(
"os.environ", {"HUB_ADMIN_TOKEN": SERVICE_TOKEN}
):
calls: list[str] = []
handler = self.handler(path, SERVICE_TOKEN, calls)
setattr(handler, handler_name, lambda: calls.append(handler_name))
RequestHandler.do_POST(handler)
self.assertEqual(calls, [handler_name])
if __name__ == "__main__":
unittest.main()
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from tempfile import TemporaryDirectory
from backend.features.accounts.security import SecretVault
from backend.features.accounts.service import AccountService
from database import ReviewDatabase
class InviteRegistrationTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
self.bound_user_id = 0
self.service = AccountService(
database=self.database,
vault=SecretVault(SecretVault.generate_key()),
current_user_supplier=lambda: self.bound_user_id,
access_supplier=lambda: self.database.user_access(self.bound_user_id) or {},
bind_user=self._bind,
personal_field_builder=lambda *args, **kwargs: {},
auth_lock=threading.Lock(),
)
def _bind(self, user_id: int) -> None:
self.bound_user_id = int(user_id)
def _bootstrap_admin(self) -> None:
self.service.register("root_admin", "Password123")
def _one_code(self) -> str:
return self.service.generate_invite_codes(1)[0]["code"]
def test_first_account_is_created_without_an_invite_code(self) -> None:
result = self.service.register("root_admin", "Password123")
self.assertEqual(result["user"]["role"], "admin")
def test_registration_requires_an_invite_code_once_an_account_exists(self) -> None:
self._bootstrap_admin()
with self.assertRaises(ValueError) as error:
self.service.register("second_user", "Password123")
self.assertIn("邀请码", str(error.exception))
self.assertEqual(self.database.count_users(), 1)
def test_unknown_used_and_revoked_codes_are_all_rejected(self) -> None:
self._bootstrap_admin()
with self.assertRaises(ValueError):
self.service.register("second_user", "Password123", "", "XB-AAAA-AAAA-AAAA")
code = self._one_code()
self.service.register("second_user", "Password123", "", code)
with self.assertRaises(ValueError) as used:
self.service.register("third_user", "Password123", "", code)
self.assertIn("已被使用", str(used.exception))
revoked = self._one_code()
self.service.revoke_invite_code(revoked)
with self.assertRaises(ValueError) as gone:
self.service.register("fourth_user", "Password123", "", revoked)
self.assertIn("作废", str(gone.exception))
self.assertEqual(self.database.count_users(), 2)
def test_invite_code_is_accepted_with_or_without_separators(self) -> None:
self._bootstrap_admin()
code = self._one_code()
self.service.register("second_user", "Password123", "", code.replace("-", "").lower())
record = self.database.invite_code(code)
self.assertEqual(record["status"], "used")
self.assertTrue(record["used_at"])
def test_concurrent_registrations_consume_one_code_once(self) -> None:
self._bootstrap_admin()
code = self._one_code()
def attempt(index: int) -> str:
try:
self.service.register(f"racer_{index}", "Password123", "", code)
return "ok"
except ValueError as exc:
return str(exc)
with ThreadPoolExecutor(max_workers=6) as pool:
outcomes = list(pool.map(attempt, range(6)))
self.assertEqual(outcomes.count("ok"), 1)
self.assertEqual(self.database.count_users(), 2)
self.assertEqual(self.database.count_invite_codes()["used"], 1)
def test_failed_account_creation_keeps_the_code_available(self) -> None:
self._bootstrap_admin()
code = self._one_code()
with self.assertRaises(ValueError):
self.service.register("root_admin", "Password123", "", code)
self.assertEqual(self.database.invite_code(code)["status"], "unused")
self.service.register("second_user", "Password123", "", code)
self.assertEqual(self.database.invite_code(code)["status"], "used")
def test_used_code_cannot_be_revoked_and_stays_reported(self) -> None:
self._bootstrap_admin()
code = self._one_code()
self.service.register("second_user", "Password123", "", code)
with self.assertRaises(ValueError):
self.service.revoke_invite_code(code)
overview = self.service.invite_overview()
# total 让页面能直接显示"共 N 个",不必自己加总
self.assertEqual(overview["summary"], {"unused": 0, "used": 1, "revoked": 0})
row = overview["codes"][0]
self.assertEqual(row["used_by_username"], "second_user")
self.assertNotIn(code, row["code_masked"])
self.assertTrue(row["code_masked"].endswith("••••"))
self.assertEqual(row["code_id"], AccountService.invite_handle(code))
def test_codes_can_be_revoked_through_their_public_handle(self) -> None:
self._bootstrap_admin()
code = self._one_code()
self.service.revoke_invite_code(AccountService.invite_handle(code))
self.assertEqual(self.database.invite_code(code)["status"], "revoked")
def test_batch_generation_is_bounded(self) -> None:
self._bootstrap_admin()
with self.assertRaises(ValueError):
self.service.generate_invite_codes(AccountService.INVITE_MAX_BATCH + 1)
created = self.service.generate_invite_codes(3, "内部测试")
self.assertEqual(len({item["code"] for item in created}), 3)
self.assertEqual(self.database.count_invite_codes()["unused"], 3)
self.assertEqual(self.service.invite_overview()["codes"][0]["note"], "内部测试")
if __name__ == "__main__":
unittest.main()
+4 -9
View File
@@ -25,7 +25,6 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
"system/password",
"system/membership",
"system/admin",
"system/members",
],
)
for key in keys:
@@ -47,19 +46,16 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
'data-system-page="profile"',
'data-system-page="password"',
'data-system-page="membership"',
'data-system-page="members"',
'data-system-page="forbidden"',
'data-system-admin-panel="market"',
"m-sys-birth-date",
"m-sys-password-current",
"m-sys-token",
"m-sys-member-limit",
"data-system-switch",
"data-system-edit-model",
"data-system-open-member",
"data-system-datahub",
"管理员专区",
"刷新状态",
"保存分工",
"打开数据中枢",
'location.assign("/login/")',
):
self.assertIn(marker, pages)
@@ -75,12 +71,11 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
for name in (
"data-system-save-birth",
"data-system-save-password",
"data-system-add-model",
"data-system-save-models",
"data-system-save-market",
"data-system-refresh",
"data-system-toggle-refresh",
"data-system-save-model",
"data-system-backfill",
"data-system-datahub",
):
self.assertIn(name, pages)
self.assertNotIn(name + '">', pages)