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