diff --git a/.env.example b/.env.example index b869041..9492911 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,10 @@ TUSHARE_TOKEN=your_tushare_token_here DATAHUB_BASE_URL=http://127.0.0.1:8766 DATAHUB_TOKEN= +# 数据中枢控制台(8766)用它调用 /api/hub-admin/* 校验主站会话、读写模型池与 +# 会员/邀请码。两个服务必须填同一个值;32+ 随机字节,未设置则桥接直接拒绝。 +HUB_ADMIN_TOKEN= + # iFinD credentials live on xiaobai-datahub, not the website process. # IFIND_REFRESH_TOKEN=your_ifind_refresh_token_here # IFIND_ACCESS_TOKEN= diff --git a/DOCKER_DEPLOY.md b/DOCKER_DEPLOY.md index ffbd071..3c1a6e3 100644 --- a/DOCKER_DEPLOY.md +++ b/DOCKER_DEPLOY.md @@ -274,3 +274,18 @@ docker compose restart xiaobai-review 当前部署使用局域网 HTTP,账号密码和会话只适合可信内网使用。不要直接将 `8765` 暴露到互联网。以后需要公网访问时,应在容器前增加 Caddy 或 Nginx, 启用 HTTPS,并限制可信来源。 + +### 数据中枢控制台(8766) + +数据中枢是管理员的统一配置入口(数据源凭证、模型池、会员与邀请码),叠加 +`compose.datahub.yaml` 部署,容器名 `xiaobai-datahub`: + +- 它没有独立账号,用主站管理员账号进入;未登录会跳主站登录页,非管理员一律 403。 + 每个页面与接口都在服务端校验,前端隐藏不作为权限依据。 +- 两侧 `.env` 的 `HUB_ADMIN_TOKEN` 必须填成同一个随机值(服务间桥接令牌)。缺失或 + 不一致时控制台无法校验会话,页面会停在门禁面板。 +- `REVIEW_BASE_URL` 是中枢访问主站的地址(同一 compose 网络内用服务名 + `http://xiaobai-review:8765`);`REVIEW_PUBLIC_URL` 是浏览器可达的主站地址, + 留空则按当前主机名推导。 +- 会话 cookie 靠"同主机不同端口"共享,因此主站与中枢必须对浏览器暴露在同一主机名下; + 8766 与 8765 同样只在可信内网开放。 diff --git a/README.md b/README.md index 4a192a8..13f0bf9 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ python server.py 默认监听 `127.0.0.1:8765`(仅本机可访问)。浏览器打开该地址,首次使用先注册账号;第一个账号自动成为管理员,之后注册的默认为普通用户。 +第一个账号之外的注册都必须填写一次性邀请码:管理员在数据中枢「会员管理」里生成,一个码只能成功注册一次,已使用或已作废的码不再可用。 + 主行情不再回退演示数据:盘前、非交易日或临时取数失败时沿用最近真实收盘快照;没有任何真实快照时,页面会提示等待管理员完成首次同步。 可选参数: @@ -115,9 +117,11 @@ compose.yaml .env.example 环境变量模板(复制为 .env 后填写) ``` -管理员通过页面右上角「系统管理」保存公共 Tushare Token、平台主/辅助模型、会员每日额度和后台刷新开关。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。 +管理员的配置入口在数据中枢(页面右上角「数据中枢」按钮,指向 8766):数据源凭证、模型池、会员与邀请码都在那里维护,数据仍存在主站同一份 SQLite 里。主站自身只保留「行情管理」面板(Tushare Token、后台刷新开关、手动刷新与补数)。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。 -普通用户在「账号设置」中维护个人资料、查看会员状态和修改密码,不配置个人 LLM。有效会员使用平台模型;管理员可开通、续期、停用会员。平台模型受每日调用次数限制,管理员账号始终可用。 +数据中枢用主站的管理员账号进入,没有独立账号;桥接令牌 `HUB_ADMIN_TOKEN` 需在主站与中枢两侧 `.env` 填成同一个值。详见 [xiaobai-datahub/README.md](xiaobai-datahub/README.md)。 + +普通用户在「账号设置」中维护个人资料、查看会员状态和修改密码,不配置个人 LLM。有效会员使用平台模型;管理员在数据中枢开通、续期、停用会员。平台模型受每日调用次数限制,管理员账号始终可用。 相关文档: diff --git a/backend/application.py b/backend/application.py index 98ab9eb..2d4a772 100644 --- a/backend/application.py +++ b/backend/application.py @@ -50,9 +50,11 @@ from backend.features.themes.routes import ThemeRoutesMixin from backend.http import HttpTransportMixin from backend.http.dispatch import ( AUTHENTICATED_POST_HANDLERS, + HUB_SERVICE_HANDLERS, PUBLIC_POST_HANDLERS, ApplicationHttpDispatchMixin, ) +from backend.http.hubadmin import HubAdminHttpMixin from backend.jobs.service import JobServiceMixin from backend.llm import LLMGateway from backend.llm.http import LLMHttpMixin @@ -152,6 +154,7 @@ class RequestHandler( AlertHttpMixin, ReviewHttpMixin, LLMHttpMixin, + HubAdminHttpMixin, ApplicationHttpDispatchMixin, HttpTransportMixin, BaseHTTPRequestHandler, diff --git a/backend/database/migrations/__init__.py b/backend/database/migrations/__init__.py index 71755cf..e8b7d67 100644 --- a/backend/database/migrations/__init__.py +++ b/backend/database/migrations/__init__.py @@ -3,6 +3,7 @@ from .m0002_job_runs import MIGRATION as M0002_JOB_RUNS from .m0003_llm_audit import MIGRATION as M0003_LLM_AUDIT from .m0004_mentor_notes import MIGRATION as M0004_MENTOR_NOTES from .m0005_account_switch_grants import MIGRATION as M0005_ACCOUNT_SWITCH_GRANTS +from .m0006_invite_codes import MIGRATION as M0006_INVITE_CODES from .runner import Migration, MigrationError, MigrationRunner MIGRATIONS = ( @@ -11,6 +12,7 @@ MIGRATIONS = ( M0003_LLM_AUDIT, M0004_MENTOR_NOTES, M0005_ACCOUNT_SWITCH_GRANTS, + M0006_INVITE_CODES, ) __all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"] diff --git a/backend/database/migrations/m0006_invite_codes.py b/backend/database/migrations/m0006_invite_codes.py new file mode 100644 index 0000000..7fb3db7 --- /dev/null +++ b/backend/database/migrations/m0006_invite_codes.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import sqlite3 + +from backend.database.migrations.runner import Migration + + +def create_invite_codes(connection: sqlite3.Connection) -> None: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS invite_codes ( + code TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'unused', + note TEXT NOT NULL DEFAULT '', + created_by INTEGER, + created_at TEXT NOT NULL, + used_by INTEGER, + used_at TEXT NOT NULL DEFAULT '', + revoked_at TEXT NOT NULL DEFAULT '', + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL + ) + """ + ) + connection.execute( + """ + CREATE INDEX IF NOT EXISTS idx_invite_codes_status + ON invite_codes(status, created_at DESC) + """ + ) + + +MIGRATION = Migration( + version="0006", + name="create_invite_codes", + action=create_invite_codes, + signature="invite-codes:v1:code,status,note,created,used,revoked", +) diff --git a/backend/features/accounts/application.py b/backend/features/accounts/application.py index 1d227e9..d1c5d40 100644 --- a/backend/features/accounts/application.py +++ b/backend/features/accounts/application.py @@ -28,8 +28,14 @@ class AccountApplicationMixin: def update_membership(self, payload: dict[str, Any]) -> None: self.accounts.update_membership(payload) - def register_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: - return self.accounts.register(username, password, device_hash) + def register_account( + self, + username: str, + password: str, + device_hash: str = "", + invite_code: str = "", + ) -> dict[str, Any]: + return self.accounts.register(username, password, device_hash, invite_code) def login_account(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: return self.accounts.login(username, password, device_hash) diff --git a/backend/features/accounts/http.py b/backend/features/accounts/http.py index 22aafeb..f960444 100644 --- a/backend/features/accounts/http.py +++ b/backend/features/accounts/http.py @@ -41,6 +41,7 @@ class AccountHttpMixin: str(body.get("username") or ""), str(body.get("password") or ""), token_hash(device_raw), + str(body.get("invite_code") or ""), ) self._send_authenticated_session(result, HTTPStatus.CREATED, device_raw) except (ValueError, json.JSONDecodeError) as exc: diff --git a/backend/features/accounts/repository.py b/backend/features/accounts/repository.py index 04bb60c..f066f4a 100644 --- a/backend/features/accounts/repository.py +++ b/backend/features/accounts/repository.py @@ -5,6 +5,9 @@ from datetime import datetime, timezone from typing import Any +INVITE_CONSUMED_MESSAGE = "邀请码无效或已被使用,请联系管理员重新获取。" + + class AccountRepositoryMixin: """Original SQLite account persistence methods, moved without query changes.""" @@ -23,11 +26,14 @@ class AccountRepositoryMixin: username: str, password_salt: str, password_hash: str, + invite_code: str = "", ) -> dict[str, Any]: now = datetime.now(timezone.utc).isoformat(timespec="seconds") try: with self.connect() as connection: role = "admin" if int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0]) == 0 else "user" + if invite_code and not self._consume_invite_code(connection, invite_code, now): + raise ValueError(INVITE_CONSUMED_MESSAGE) cursor = connection.execute( """ INSERT INTO users @@ -37,10 +43,91 @@ class AccountRepositoryMixin: (username, password_salt, password_hash, role, now, now), ) user_id = int(cursor.lastrowid) + if invite_code: + connection.execute( + "UPDATE invite_codes SET used_by = ? WHERE code = ?", + (user_id, invite_code), + ) except sqlite3.IntegrityError as exc: raise ValueError("该账号名已被使用。") from exc return {"id": user_id, "username": username, "role": role, "created_at": now} + @staticmethod + def _consume_invite_code( + connection: sqlite3.Connection, code: str, used_at: str + ) -> bool: + cursor = connection.execute( + """ + UPDATE invite_codes SET status = 'used', used_at = ? + WHERE code = ? AND status = 'unused' + """, + (used_at, code), + ) + return cursor.rowcount > 0 + + def invite_code(self, code: str) -> dict[str, Any] | None: + with self.connect() as connection: + row = connection.execute( + """ + SELECT code, status, note, created_at, used_at, revoked_at, used_by + FROM invite_codes WHERE code = ? + """, + (code,), + ).fetchone() + return dict(row) if row else None + + def create_invite_codes( + self, codes: list[str], note: str, created_by: int + ) -> list[str]: + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with self.connect() as connection: + for code in codes: + connection.execute( + """ + INSERT INTO invite_codes (code, status, note, created_by, created_at) + VALUES (?, 'unused', ?, ?, ?) + """, + (code, note, created_by or None, now), + ) + return list(codes) + + def revoke_invite_code(self, code: str) -> bool: + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + with self.connect() as connection: + cursor = connection.execute( + """ + UPDATE invite_codes SET status = 'revoked', revoked_at = ? + WHERE code = ? AND status = 'unused' + """, + (now, code), + ) + return cursor.rowcount > 0 + + def list_invite_codes(self, limit: int = 100) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT c.code, c.status, c.note, c.created_at, c.used_at, c.revoked_at, + u.username AS used_by_username + FROM invite_codes AS c + LEFT JOIN users AS u ON u.id = c.used_by + ORDER BY c.created_at DESC, c.code + LIMIT ? + """, + (max(1, min(500, int(limit))),), + ).fetchall() + return [dict(row) for row in rows] + + def count_invite_codes(self) -> dict[str, int]: + with self.connect() as connection: + rows = connection.execute( + "SELECT status, COUNT(*) AS total FROM invite_codes GROUP BY status" + ).fetchall() + counts = {"unused": 0, "used": 0, "revoked": 0} + for row in rows: + counts[str(row["status"])] = int(row["total"]) + return counts + def user_by_username(self, username: str) -> dict[str, Any] | None: with self.connect() as connection: row = connection.execute( diff --git a/backend/features/accounts/service.py b/backend/features/accounts/service.py index 47d5a75..f5c9a25 100644 --- a/backend/features/accounts/service.py +++ b/backend/features/accounts/service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import secrets import threading from collections.abc import Callable @@ -82,16 +83,123 @@ class AccountService: MAX_GRANTS_PER_DEVICE = 5 SWITCH_REAUTH_MESSAGE = "该账号需重新验证" - def register(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: + INVITE_ALPHABET = "ACDEFGHJKLMNPQRTUVWXY34679" + INVITE_MAX_BATCH = 20 + INVITE_LIST_LIMIT = 500 + + def register( + self, + username: str, + password: str, + device_hash: str = "", + invite_code: str = "", + ) -> dict[str, Any]: username = username.strip() self.validate_input(username, password) with self.auth_lock: + code = self.checked_invite_code(invite_code) salt, password_digest = hash_password(password) - user = self.database.create_user(username, salt, password_digest) + user = self.database.create_user(username, salt, password_digest, code) result = self.create_session(user) self.remember_account(device_hash, int(user["id"]), fresh=True) return result + @classmethod + def normalize_invite_code(cls, value: str) -> str: + raw = "".join( + character + for character in str(value or "").upper() + if character.isalnum() + ) + if raw.startswith("XB") and len(raw) == 14: + body = raw[2:] + return f"XB-{body[0:4]}-{body[4:8]}-{body[8:12]}" + return raw[:64] + + @staticmethod + def mask_invite_code(code: str) -> str: + groups = str(code or "").split("-") + if len(groups) < 3: + return str(code or "") + return f"{groups[0]}-{groups[1]}-••••" + + @staticmethod + def invite_handle(code: str) -> str: + return hashlib.sha256(str(code or "").encode("utf-8")).hexdigest()[:16] + + def checked_invite_code(self, invite_code: str) -> str: + if self.database.count_users() == 0: + return "" + code = self.normalize_invite_code(invite_code) + if not code: + raise ValueError("请填写邀请码,注册需要管理员发放的一次性邀请码。") + record = self.database.invite_code(code) + status = str((record or {}).get("status") or "") + if not record: + raise ValueError("邀请码不存在,请向管理员确认。") + if status == "used": + raise ValueError("该邀请码已被使用。") + if status != "unused": + raise ValueError("该邀请码已作废。") + return code + + def generate_invite_codes( + self, count: int, note: str = "", created_by: int = 0 + ) -> list[dict[str, str]]: + try: + total = int(count or 1) + except (TypeError, ValueError) as exc: + raise ValueError("生成数量不正确。") from exc + if total < 1 or total > self.INVITE_MAX_BATCH: + raise ValueError(f"每次最多生成 {self.INVITE_MAX_BATCH} 个邀请码。") + codes: list[str] = [] + while len(codes) < total: + body = "".join(secrets.choice(self.INVITE_ALPHABET) for _ in range(12)) + code = f"XB-{body[0:4]}-{body[4:8]}-{body[8:12]}" + if code in codes or self.database.invite_code(code): + continue + codes.append(code) + self.database.create_invite_codes(codes, str(note or "").strip()[:60], created_by) + return [{"code": code, "code_id": self.invite_handle(code)} for code in codes] + + def _stored_invite_code(self, reference: str) -> str: + normalized = self.normalize_invite_code(reference) + if normalized and self.database.invite_code(normalized): + return normalized + handle = str(reference or "").strip().lower() + for row in self.database.list_invite_codes(self.INVITE_LIST_LIMIT): + if self.invite_handle(str(row["code"])) == handle: + return str(row["code"]) + return "" + + def revoke_invite_code(self, reference: str) -> None: + code = self._stored_invite_code(reference) + record = self.database.invite_code(code) if code else None + if not record: + raise ValueError("邀请码不存在。") + if str(record.get("status")) == "used": + raise ValueError("该邀请码已被使用,无法作废。") + if not self.database.revoke_invite_code(code): + raise ValueError("该邀请码已作废。") + + def invite_overview(self, limit: int = 100) -> dict[str, Any]: + codes = [] + for row in self.database.list_invite_codes(limit): + code = str(row["code"]) + codes.append( + { + "code_id": self.invite_handle(code), + "code_masked": self.mask_invite_code(code), + "status": str(row["status"]), + "note": str(row.get("note") or ""), + "created_at": str(row.get("created_at") or ""), + "used_at": str(row.get("used_at") or ""), + "revoked_at": str(row.get("revoked_at") or ""), + "used_by_username": str(row.get("used_by_username") or ""), + } + ) + return {"summary": self.database.count_invite_codes(), "codes": codes} + def login(self, username: str, password: str, device_hash: str = "") -> dict[str, Any]: username = username.strip() if not username or not password: diff --git a/backend/features/system/service.py b/backend/features/system/service.py index 766bdeb..a0c348f 100644 --- a/backend/features/system/service.py +++ b/backend/features/system/service.py @@ -128,6 +128,7 @@ class SystemServiceMixin: "base_url": profile["base_url"], "model": profile["model"], "configured": self._profile_configured(profile), + "api_key_last4": profile["api_key"][-4:], } ) return { diff --git a/backend/http/dispatch.py b/backend/http/dispatch.py index 3c31e5c..3da4fd7 100644 --- a/backend/http/dispatch.py +++ b/backend/http/dispatch.py @@ -11,6 +11,24 @@ PUBLIC_POST_HANDLERS = { "/api/auth/forget": "auth_forget", } +# Service-to-service bridge for the data hub console. These paths are guarded by +# the shared HUB_ADMIN_TOKEN header instead of a browser session, so they stay +# out of the user-facing route registry on purpose. +HUB_SERVICE_HANDLERS = { + "/api/hub-admin/session": "hub_session_check", + "/api/hub-admin/session/logout": "hub_session_logout", + "/api/hub-admin/password/check": "hub_password_check", + "/api/hub-admin/status": "hub_system_status", + "/api/hub-admin/settings/save": "hub_save_settings", + "/api/hub-admin/settings/test": "hub_test_model", + "/api/hub-admin/models/fetch": "hub_fetch_models", + "/api/hub-admin/members": "hub_members", + "/api/hub-admin/membership/save": "hub_save_membership", + "/api/hub-admin/invites": "hub_invites", + "/api/hub-admin/invites/create": "hub_create_invites", + "/api/hub-admin/invites/revoke": "hub_revoke_invite", +} + AUTHENTICATED_POST_HANDLERS = { "/api/auth/logout": "auth_logout", "/api/account/birth-profile": "save_birth_profile", @@ -81,6 +99,10 @@ class ApplicationHttpDispatchMixin: def do_POST(self) -> None: parsed = urlparse(self.path) + if parsed.path in HUB_SERVICE_HANDLERS: + if self.require_service_token(): + self._dispatch_named_handler(parsed.path, HUB_SERVICE_HANDLERS) + return if self._dispatch_named_handler(parsed.path, PUBLIC_POST_HANDLERS): return if not self.require_auth() or not self.require_csrf(): diff --git a/backend/http/handler.py b/backend/http/handler.py index ed567c2..0f044a8 100644 --- a/backend/http/handler.py +++ b/backend/http/handler.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import mimetypes +import os import secrets from collections.abc import Iterable from http import HTTPStatus @@ -62,6 +63,14 @@ class HttpTransportMixin: return False return True + def require_service_token(self) -> bool: + expected = str(os.environ.get("HUB_ADMIN_TOKEN") or "").strip() + supplied = self.headers.get("X-Hub-Admin-Token", "") + if not expected or not supplied or not secrets.compare_digest(supplied, expected): + self.send_json({"error": "服务令牌校验失败。"}, HTTPStatus.UNAUTHORIZED) + return False + return True + def require_admin(self) -> bool: if str(getattr(self, "auth_user", {}).get("role") or "user") != "admin": self.send_json({"error": "需要管理员权限。"}, HTTPStatus.FORBIDDEN) diff --git a/backend/http/hubadmin.py b/backend/http/hubadmin.py new file mode 100644 index 0000000..7b3d64d --- /dev/null +++ b/backend/http/hubadmin.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +from http import HTTPStatus + +from backend.features.accounts.security import token_hash, verify_password + + +class HubAdminHttpMixin: + """Service-to-service bridge used by the data hub console (port 8766). + + Every handler here is reached only after `require_service_token`, so the + shared `HUB_ADMIN_TOKEN` is the single trust boundary and no browser + session or CSRF token is involved. The data hub still verifies the site + session of the operator through `hub_session_check` before it exposes any + of these results to a page. + """ + + def _hub_body(self) -> dict: + return self.read_json_body(allow_empty=True) + + def _hub_failure(self, exc: Exception) -> None: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + + def hub_session_check(self) -> None: + try: + body = self._hub_body() + except (ValueError, json.JSONDecodeError) as exc: + self._hub_failure(exc) + return + raw_token = str(body.get("session_token") or "") + user = ( + self.application_service.database.session_user(token_hash(raw_token)) + if raw_token + else None + ) + if not user: + self.send_json({"ok": True, "authenticated": False}) + return + self.send_json( + { + "ok": True, + "authenticated": True, + "user": { + "id": int(user["id"]), + "username": str(user["username"]), + "role": str(user.get("role") or "user"), + "is_admin": str(user.get("role") or "user") == "admin", + }, + } + ) + + def hub_session_logout(self) -> None: + try: + body = self._hub_body() + except (ValueError, json.JSONDecodeError) as exc: + self._hub_failure(exc) + return + raw_token = str(body.get("session_token") or "") + if raw_token: + self.application_service.database.delete_session(token_hash(raw_token)) + self.send_json({"ok": True}) + + def hub_password_check(self) -> None: + try: + body = self._hub_body() + user_id = int(body.get("user_id") or 0) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + self._hub_failure(exc) + return + stored = self.application_service.database.user_password(user_id) + verified = bool( + stored + and verify_password( + str(body.get("password") or ""), + str(stored.get("password_salt") or ""), + str(stored.get("password_hash") or ""), + ) + ) + self.send_json({"ok": True, "verified": verified}) + + def hub_system_status(self) -> None: + service = self.application_service + self.send_json({"ok": True, **service.system_status(), "users": service.admin_users()}) + + def hub_save_settings(self) -> None: + try: + result = self.application_service.save_system_settings(self._hub_body()) + self.send_json({"ok": True, **result}) + except (ValueError, json.JSONDecodeError) as exc: + self._hub_failure(exc) + + def hub_test_model(self) -> None: + try: + body = self._hub_body() + result = self.application_service.test_system_llm_profile( + str(body.get("model_id") or ""), body.get("profile") or {} + ) + self.send_json({"ok": True, "result": result}) + except (ValueError, json.JSONDecodeError) as exc: + self._hub_failure(exc) + + def hub_fetch_models(self) -> None: + try: + body = self._hub_body() + models = self.application_service.fetch_llm_models( + str(body.get("base_url") or ""), + str(body.get("api_key") or ""), + ) + self.send_json({"ok": True, "models": models}) + except (ValueError, json.JSONDecodeError) as exc: + self._hub_failure(exc) + + def hub_members(self) -> None: + service = self.application_service + self.send_json( + { + "ok": True, + "users": service.admin_users(), + "membership": service.system_status()["membership"], + } + ) + + def hub_save_membership(self) -> None: + try: + service = self.application_service + service.update_membership(self._hub_body()) + self.send_json({"ok": True, "users": service.admin_users()}) + except (ValueError, json.JSONDecodeError) as exc: + self._hub_failure(exc) + + def hub_invites(self) -> None: + self.send_json({"ok": True, **self.application_service.accounts.invite_overview()}) + + def hub_create_invites(self) -> None: + try: + body = self._hub_body() + accounts = self.application_service.accounts + codes = accounts.generate_invite_codes( + body.get("count") or 1, + str(body.get("note") or ""), + int(body.get("created_by") or 0), + ) + self.send_json({"ok": True, "created": codes, **accounts.invite_overview()}) + except (ValueError, json.JSONDecodeError) as exc: + self._hub_failure(exc) + + def hub_revoke_invite(self) -> None: + try: + body = self._hub_body() + accounts = self.application_service.accounts + accounts.revoke_invite_code(str(body.get("code_id") or body.get("code") or "")) + self.send_json({"ok": True, **accounts.invite_overview()}) + except (ValueError, json.JSONDecodeError) as exc: + self._hub_failure(exc) diff --git a/backend/llm/service.py b/backend/llm/service.py index 13e5c35..9369f56 100644 --- a/backend/llm/service.py +++ b/backend/llm/service.py @@ -6,6 +6,7 @@ from urllib.parse import urlparse from backend.bootstrap.config import validate_text from backend.features.screener.compiler import LLMCompilerError, test_llm_connection +from backend.llm import transport as llm_transport class LLMServiceMixin: @@ -208,6 +209,37 @@ class LLMServiceMixin: start.isoformat(timespec="seconds"), ) + def fetch_llm_models(self, base_url: str, api_key: str) -> list[str]: + base_url = str(base_url or "").strip().rstrip("/") + parsed = urlparse(base_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("Base URL 格式不正确。") + key = str(api_key or "").strip() + if not key: + key = self._stored_api_key(base_url) + if not key: + raise ValueError("该供应商尚未保存 API Key,请先填写后再拉取模型列表。") + try: + return llm_transport.list_models( + api_key=key, + base_url=base_url, + timeout=15, + user_agent="XiaobaiReviewWeb/0.5", + ) + except llm_transport.OpenAIHTTPError as exc: + raise ValueError(exc.describe("模型列表拉取失败")) from exc + except llm_transport.OpenAITransportError as exc: + raise ValueError(f"模型列表拉取失败:{exc}") from exc + + def _stored_api_key(self, base_url: str) -> str: + for item in self._system_credentials.get("llm_models") or []: + if not isinstance(item, dict): + continue + stored = str(item.get("base_url") or "").strip().rstrip("/") + if stored == base_url and item.get("api_key"): + return str(item["api_key"]) + return "" + def test_system_llm_profile(self, model_id: str, payload: dict[str, Any]) -> dict[str, Any]: current = next( ( diff --git a/backend/llm/transport.py b/backend/llm/transport.py index b81bb11..cd0fb12 100644 --- a/backend/llm/transport.py +++ b/backend/llm/transport.py @@ -67,6 +67,37 @@ def chat_completion( ) +def list_models( + *, + api_key: str, + base_url: str, + timeout: int, + user_agent: str, +) -> list[str]: + request = urllib.request.Request( + f"{base_url.rstrip('/')}/models", + headers={ + "Authorization": f"Bearer {api_key}", + "User-Agent": user_agent, + }, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raise OpenAIHTTPError(exc.code, _http_error_detail(exc)) from exc + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as exc: + raise OpenAITransportError(str(exc)) from exc + items = payload.get("data") if isinstance(payload, dict) else payload + models = [] + for item in items or []: + name = str((item or {}).get("id") or "") if isinstance(item, dict) else str(item or "") + if name and name not in models: + models.append(name) + return models + + def stream_chat_completion( *, api_key: str, diff --git a/compose.datahub.yaml b/compose.datahub.yaml index 70d3a90..b4f852d 100644 --- a/compose.datahub.yaml +++ b/compose.datahub.yaml @@ -2,7 +2,8 @@ # Start later (总工部署时) with: # docker compose -f compose.yaml -f compose.datahub.yaml up -d # -# Required .env keys: DATAHUB_ENCRYPTION_KEY, DATAHUB_TOKEN, DATAHUB_ADMIN_PASSWORD, TUSHARE_TOKEN +# Required .env keys: DATAHUB_ENCRYPTION_KEY, DATAHUB_TOKEN, HUB_ADMIN_TOKEN, TUSHARE_TOKEN +# HUB_ADMIN_TOKEN 必须与主站 .env 里的同名变量一致:控制台靠它验证主站管理员会话。 services: xiaobai-datahub: @@ -18,7 +19,9 @@ services: environment: DATAHUB_ENCRYPTION_KEY: "${DATAHUB_ENCRYPTION_KEY:?DATAHUB_ENCRYPTION_KEY must be set}" DATAHUB_TOKEN: "${DATAHUB_TOKEN:?DATAHUB_TOKEN must be set}" - DATAHUB_ADMIN_PASSWORD: "${DATAHUB_ADMIN_PASSWORD:?DATAHUB_ADMIN_PASSWORD must be set}" + HUB_ADMIN_TOKEN: "${HUB_ADMIN_TOKEN:?HUB_ADMIN_TOKEN must be set}" + REVIEW_BASE_URL: "${REVIEW_BASE_URL:-http://xiaobai-review:8765}" + REVIEW_PUBLIC_URL: "${REVIEW_PUBLIC_URL:-}" TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}" IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}" IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}" diff --git a/compose.yaml b/compose.yaml index 71a11a0..3c5a3db 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,6 +13,10 @@ services: - ./.env environment: APP_ENCRYPTION_KEY: "${APP_ENCRYPTION_KEY:?APP_ENCRYPTION_KEY must be set in .env}" + # Shared secret for /api/hub-admin/*: the data hub console (8766) uses it to + # verify this site's admin sessions and to read/write the model pool, + # members and invite codes. Unset means the bridge refuses every call. + HUB_ADMIN_TOKEN: "${HUB_ADMIN_TOKEN:-}" # Provider credentials are consumed only by xiaobai-datahub. TUSHARE_TOKEN: "" IFIND_REFRESH_TOKEN: "" diff --git a/config/architecture-inventory.json b/config/architecture-inventory.json index 1ae0149..706e104 100644 --- a/config/architecture-inventory.json +++ b/config/architecture-inventory.json @@ -13,7 +13,7 @@ "api_exact_paths": 56, "api_prefixes": 0, "api_patterns": 11, - "database_tables": 37, + "database_tables": 38, "frontend_page_fragments": 12 }, "pages": [ @@ -193,6 +193,7 @@ "heaven_readings", "job_runs", "account_switch_grants", + "invite_codes", "schema_migrations" ], "background_job_methods": [ @@ -453,8 +454,8 @@ "code_hotspots": [ { "path": "frontend/pages/heaven/foundation.css", - "bytes": 182616, - "lines": 11494 + "bytes": 182527, + "lines": 11488 }, { "path": "frontend/pages/screener/foundation.css", @@ -478,8 +479,8 @@ }, { "path": "frontend/index.html", - "bytes": 48403, - "lines": 665 + "bytes": 46900, + "lines": 638 }, { "path": "backend/data/providers/tushare_industries.py", @@ -556,11 +557,6 @@ "bytes": 14942, "lines": 235 }, - { - "path": "frontend/shared/admin.js", - "bytes": 14836, - "lines": 283 - }, { "path": "backend/features/screener/data_sync.py", "bytes": 14743, @@ -573,8 +569,8 @@ }, { "path": "frontend/shared/session.js", - "bytes": 13219, - "lines": 289 + "bytes": 13633, + "lines": 296 }, { "path": "backend/features/market/insights_auction_data.py", @@ -583,8 +579,8 @@ }, { "path": "backend/features/system/service.py", - "bytes": 12180, - "lines": 265 + "bytes": 12242, + "lines": 266 }, { "path": "backend/features/market/insights_auction.py", @@ -651,16 +647,16 @@ "bytes": 6547, "lines": 220 }, + { + "path": "backend/application.py", + "bytes": 6500, + "lines": 164 + }, { "path": "frontend/pages/sentiment/page.html", "bytes": 6488, "lines": 81 }, - { - "path": "backend/application.py", - "bytes": 6399, - "lines": 161 - }, { "path": "frontend/pages/market/stock-detail.js", "bytes": 6325, @@ -701,6 +697,11 @@ "bytes": 5350, "lines": 74 }, + { + "path": "backend/http/dispatch.py", + "bytes": 5281, + "lines": 139 + }, { "path": "frontend/shared/feedback.js", "bytes": 5157, @@ -721,6 +722,11 @@ "bytes": 4406, "lines": 124 }, + { + "path": "frontend/shared/admin.js", + "bytes": 4376, + "lines": 107 + }, { "path": "backend/features/market/routes.py", "bytes": 4276, @@ -736,11 +742,6 @@ "bytes": 4242, "lines": 129 }, - { - "path": "backend/http/dispatch.py", - "bytes": 4196, - "lines": 117 - }, { "path": "frontend/shared/table.js", "bytes": 3790, @@ -783,8 +784,8 @@ }, { "path": "backend/features/accounts/application.py", - "bytes": 2514, - "lines": 63 + "bytes": 2597, + "lines": 69 }, { "path": "backend/jobs/service.py", diff --git a/frontend/index.html b/frontend/index.html index f7059a5..e46599a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -73,6 +73,7 @@ + @@ -156,7 +157,8 @@ - + +
@@ -592,18 +594,11 @@
-
管理员

系统配置

+
管理员

行情管理

正在读取系统状态
-
- - -
+

模型池、会员与邀请码已统一在数据中枢管理,点击顶栏「数据中枢」进入。

公共行情

待检查
@@ -624,28 +619,6 @@
- -
" + - '

' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "系统设置与会员管理仅管理员可见") + "

" + + '

' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "行情管理与数据中枢仅管理员可见") + "

" + "
"; document.getElementById("m-view").innerHTML = html; } @@ -5207,14 +5196,6 @@ systemFill(html); } - function adminTabHtml() { - const tab = state.system.adminTab === "models" ? "models" : "market"; - return '
' + - '' + - '' + - "
"; - } - function statusDot(ok) { return ''; } @@ -5228,16 +5209,10 @@ return " 未配置"; } - function renderSystemAdmin(key) { - if (key === "system/members") { - renderSystemMembers(); - return; - } + function renderSystemAdmin() { const payload = state.system.admin || {}; const data = payload.data || {}; const ifind = data.ifind || {}; - const llm = payload.llm || {}; - const tab = state.system.adminTab === "models" ? "models" : "market"; const marketHtml = '
' + '
数据源状态' + @@ -5263,178 +5238,28 @@ formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) + '' + '

回补用于补齐缺失的历史行情,开始前会再次确认;回补期间页面可正常使用。

' + + '
模型池 · 会员 · 邀请码' + + '

这些配置已统一在数据中枢管理。

' + + '
' + "
"; - const models = state.system.models || []; - const modelsHtml = - '
' + - '
模型分工' + - formFieldHtml("主模型", '', false) + - formFieldHtml("辅助模型", '', false) + - '

主模型不可用时自动改用辅助模型

' + - '
' + - '
模型池 · ' + models.length + " 个" + - '
' + renderModelPoolHtml(models) + "
" + - '

点任意模型卡片进入编辑:改名称、地址、密钥、测试连接或删除

' + - '
' + - "
"; - const page = document.querySelector(".m-page"); - if (!page) return; - page.innerHTML = adminTabHtml() + '
' + (tab === "models" ? modelsHtml : marketHtml) + "
"; - if (tab === "models") updateSystemModelRoleOptions(llm.primary_model_id || "", llm.fallback_model_id || ""); + systemFill(marketHtml); } - function hostOfUrl(url) { - try { - return new URL(url).host; - } catch (error) { - return String(url || "").replace(/^https?:\/\//, "").split("/")[0] || "--"; - } + function dataHubConsoleUrl() { + if (global.XIAOBAI_DATAHUB_URL) return String(global.XIAOBAI_DATAHUB_URL); + return global.location.protocol + "//" + global.location.hostname + ":8766/admin/"; } - function renderModelPoolHtml(models) { - if (!models.length) { - return '

模型池为空,请先添加模型

'; - } - const llm = (state.system.admin && state.system.admin.llm) || {}; - return models.map(function (item) { - const badges = []; - if (item.id === llm.primary_model_id) badges.push('主模型'); - if (item.id === llm.fallback_model_id) badges.push('辅助'); - badges.push('' + (item.configured ? "已配置" : "待配置") + ""); - return '"; - }).join(""); + function openDataHubConsole() { + if (!global.MobileSession.isAdmin()) return; + global.open(dataHubConsoleUrl(), "_blank", "noopener"); } - function collectSystemModelPool() { - return (state.system.models || []).map(function (item) { - return { - id: item.id, - name: item.name || "", - base_url: item.base_url || "", - model: item.model || "", - api_key: item.api_key || "", - configured: Boolean(item.configured) - }; - }); - } - - function updateSystemModelRoleOptions(primaryId, fallbackId) { - const models = collectSystemModelPool(); - const options = models.map(function (item) { - return '"; - }).join(""); - const primary = document.getElementById("m-sys-primary-model"); - const fallback = document.getElementById("m-sys-fallback-model"); - if (!primary || !fallback) return; - primary.innerHTML = models.length ? options : ''; - fallback.innerHTML = '' + options; - const keepPrimary = models.some(function (item) { return item.id === primaryId; }) ? primaryId : (models[0] && models[0].id) || ""; - primary.value = keepPrimary; - fallback.value = models.some(function (item) { return item.id === fallbackId; }) && fallbackId !== keepPrimary ? fallbackId : ""; - } - - function openModelEditSheet(modelId) { - const models = state.system.models || []; - const item = models.find(function (row) { return row.id === modelId; }) || {}; - state.system.editingModelId = modelId; - openSheet( - '

编辑模型

' + - '
" + - '
' + - formFieldHtml("显示名称", '', true) + - formFieldHtml("API Base URL", '', true) + - formFieldHtml("模型标识", '', true) + - formFieldHtml("API Key", '', !item.configured) + - '
' + - '未测试
' + - '
' + - '' + - '' + - "
" - ); - } - - function readModelSheetFields(root) { - function fieldValue(name) { - const input = root.querySelector("[data-model-field='" + name + "']"); - return String(input && input.value != null ? input.value : "").trim(); - } - return { - name: fieldValue("name"), - base_url: fieldValue("base_url"), - model: fieldValue("model"), - api_key: fieldValue("api_key") - }; - } - - function renderSystemMembers() { - const payload = state.system.admin || {}; - const membership = payload.membership || {}; - const users = payload.users || []; - const userHtml = users.map(function (user) { - const admin = user.role === "admin"; - const member = Boolean(user.membership_subscribed); - const badges = []; - if (admin) badges.push('管理员'); - if (member) badges.push('会员'); - else badges.push('普通用户'); - const expiry = member - ? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效") - : user.membership_status === "suspended" - ? "会员已停用" - : "尚未开通"; - return '
' + - '
' + escapeHtml(user.username) + "" + - '
' + badges.join("") + "
" + - '

' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次

" + - '
'; - }).join("") || '

暂无注册用户

'; - const html = - '
' + - '
全局额度' + - formFieldHtml("会员每日智能分析上限", '', false) + - '

对所有会员生效,每日 0 点自动重置。

' + - '
' + - '
会员账号 · ' + users.length + " 个" + - userHtml + - '

点「管理」为对应账号开通、续期或停用会员。

' + - "
"; - systemFill(html); - } - - function openMemberManageSheet(userId) { - const users = (state.system.admin && state.system.admin.users) || []; - const user = users.find(function (item) { return String(item.id) === String(userId); }); - if (!user) return; - state.system.editingUserId = String(userId); - const member = Boolean(user.membership_subscribed); - const expiry = member - ? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效") - : user.membership_status === "suspended" ? "会员已停用" : "尚未开通"; - openSheet( - '

管理会员 · ' + escapeHtml(user.username) + "

" + - '
" + - '
' + - '

当前状态:' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次

" + - formFieldHtml("会员状态", '", false) + - formFieldHtml("开通 / 续期时长", '', false) + - '

从当前时间开始顺延;已有会员则叠加续期。

' + - '
' + - '' + - '' + - "
" - ); + function dataHubRowHtml() { + return '"; } function setFieldError(inputId, message) { @@ -5571,97 +5396,6 @@ }); } - function saveSystemModels() { - const button = document.querySelector("[data-system-save-models]"); - if (button) button.disabled = true; - global.MobileAPI.request("/api/admin/settings", "POST", { - primary_model_id: (document.getElementById("m-sys-primary-model") || {}).value || "", - fallback_model_id: (document.getElementById("m-sys-fallback-model") || {}).value || "", - }).then(function () { - showToast("模型分工已保存"); - loadSystem(); - }).catch(function (error) { - showToast(error && error.message ? error.message : "模型分工保存失败"); - }).then(function () { - if (button) button.disabled = false; - }); - } - - function persistSystemModels(models, message) { - return global.MobileAPI.request("/api/admin/settings", "POST", { models: models }).then(function () { - showToast(message || "模型池已保存"); - closeSheet(); - loadSystem(); - }); - } - - function saveEditedSystemModel() { - const sheet = document.querySelector(".m-sheet-body[data-model-id]"); - if (!sheet) return; - const id = sheet.dataset.modelId; - const fields = readModelSheetFields(sheet); - const models = collectSystemModelPool().map(function (item) { - if (item.id !== id) return item; - return Object.assign({}, item, fields); - }); - persistSystemModels(models, "模型已保存").catch(function (error) { - showToast(error && error.message ? error.message : "模型保存失败"); - }); - } - - function addSystemModel() { - const models = collectSystemModelPool(); - const id = "model-" + Date.now() + "-" + Math.floor(Math.random() * 10000); - const created = { id: id, name: "模型 " + (models.length + 1), base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false }; - state.system.models = models.concat([created]); - const list = document.getElementById("m-sys-model-list"); - if (list) list.innerHTML = renderModelPoolHtml(state.system.models); - updateSystemModelRoleOptions(id, (document.getElementById("m-sys-fallback-model") || {}).value || ""); - openModelEditSheet(id); - } - - function deleteSystemModel(row) { - if (!row) return; - const id = row.dataset.modelId; - const primary = (document.getElementById("m-sys-primary-model") || {}).value; - const fallback = (document.getElementById("m-sys-fallback-model") || {}).value; - const llm = (state.system.admin && state.system.admin.llm) || {}; - if (id === primary || id === fallback || id === llm.primary_model_id || id === llm.fallback_model_id) { - showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型"); - return; - } - openConfirmSheet("删除模型?", "删除后该模型将从模型池移除,此操作不可恢复。", { - danger: true, - centered: true, - confirmLabel: "删除", - onConfirm: function () { - const models = collectSystemModelPool().filter(function (item) { return item.id !== id; }); - persistSystemModels(models, "模型已删除").catch(function (error) { - showToast(error && error.message ? error.message : "删除失败"); - }); - } - }); - } - - function testSystemModel(row) { - if (!row) return; - const status = document.querySelector("[data-model-test-status]"); - const button = document.querySelector("[data-system-test-model]"); - const sheet = document.querySelector(".m-sheet-body[data-model-id]"); - const id = (sheet && sheet.dataset.modelId) || row.dataset.modelId; - const fields = sheet ? readModelSheetFields(sheet) : {}; - const profile = Object.assign({}, collectSystemModelPool().find(function (item) { return item.id === id; }) || {}, fields, { id: id }); - if (button) button.disabled = true; - if (status) status.textContent = "连接中"; - global.MobileAPI.request("/api/admin/settings/test", "POST", { model_id: id, profile: profile }).then(function (payload) { - if (status) status.textContent = "上次测试:成功 · " + number(payload.result && payload.result.latency_ms) + "ms"; - }).catch(function (error) { - if (status) status.textContent = error && error.message ? error.message : "测试失败"; - }).then(function () { - if (button) button.disabled = false; - }); - } - function startSystemRefresh() { const button = document.querySelector("[data-system-refresh]"); if (button) button.disabled = true; @@ -5699,56 +5433,6 @@ }); } - function saveSystemMemberLimit() { - const button = document.querySelector("[data-system-save-limit]"); - if (button) button.disabled = true; - global.MobileAPI.request("/api/admin/settings", "POST", { - member_daily_limit: number((document.getElementById("m-sys-member-limit") || {}).value), - }).then(function () { - showToast("会员调用额度已保存"); - loadSystem(); - }).catch(function (error) { - showToast(error && error.message ? error.message : "会员调用额度保存失败"); - }).then(function () { - if (button) button.disabled = false; - }); - } - - function saveSystemMember(card) { - if (!card) return; - const status = (card.querySelector("[data-member-status]") || {}).value; - const apply = function () { - const button = card.querySelector("[data-system-save-member]"); - if (button) button.disabled = true; - global.MobileAPI.request("/api/admin/membership", "POST", { - user_id: card.dataset.adminUser, - status: status, - duration: (card.querySelector("[data-member-duration]") || {}).value, - }).then(function (payload) { - if (state.system.admin) state.system.admin.users = payload.users || []; - closeSheet(); - showToast("会员状态已更新"); - renderSystemMembers(); - }).catch(function (error) { - showToast(error && error.message ? error.message : "会员状态保存失败"); - }).then(function () { - if (button) button.disabled = false; - }); - }; - if (status === "suspended") { - openConfirmSheet("停用该会员?", "停用后该账号将无法使用会员智能功能,可稍后重新开通。", { - danger: true, - centered: true, - confirmLabel: "停用", - onConfirm: apply - }); - return; - } - apply(); - } - - /* ---------------------------------------------------------------- events */ - function bindEvents() { window.addEventListener("hashchange", closeSheet); @@ -5876,12 +5560,6 @@ return; } - const systemAdminTab = event.target.closest("[data-system-admin-tab]"); - if (systemAdminTab) { - state.system.adminTab = systemAdminTab.dataset.systemAdminTab === "models" ? "models" : "market"; - renderSystemAdmin("system/admin"); - return; - } if (event.target.closest("[data-system-save-birth]")) { saveSystemBirth(); return; } if (event.target.closest("[data-system-delete-birth]")) { deleteSystemBirth(); return; } if (event.target.closest("[data-system-save-password]")) { saveSystemPassword(); return; } @@ -5889,22 +5567,9 @@ if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; } if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; } if (event.target.closest("[data-system-toggle-refresh]")) { toggleSystemRefresh(); return; } - if (event.target.closest("[data-system-save-models]")) { saveSystemModels(); return; } - if (event.target.closest("[data-system-save-model]")) { saveEditedSystemModel(); return; } - if (event.target.closest("[data-system-add-model]")) { addSystemModel(); return; } - const editModel = event.target.closest("[data-system-edit-model]"); - if (editModel) { openModelEditSheet(editModel.dataset.modelId); return; } - const deleteModel = event.target.closest("[data-system-delete-model]"); - if (deleteModel) { deleteSystemModel(deleteModel.closest("[data-model-id]")); return; } - const testModel = event.target.closest("[data-system-test-model]"); - if (testModel) { testSystemModel(testModel.closest("[data-model-id]")); return; } if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; } if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; } - if (event.target.closest("[data-system-save-limit]")) { saveSystemMemberLimit(); return; } - const openMember = event.target.closest("[data-system-open-member]"); - if (openMember) { openMemberManageSheet(openMember.closest("[data-admin-user]").dataset.adminUser); return; } - const saveMember = event.target.closest("[data-system-save-member]"); - if (saveMember) { saveSystemMember(saveMember.closest("[data-admin-user]")); return; } + if (event.target.closest("[data-system-datahub]")) { openDataHubConsole(); return; } // 我的复盘:新增/编辑/删除/筛选/提交等操作 const reviewAddTrade = event.target.closest("[data-review-add-trade]"); diff --git a/frontend/m/js/router.js b/frontend/m/js/router.js index 17405ec..0cfcece 100644 --- a/frontend/m/js/router.js +++ b/frontend/m/js/router.js @@ -263,6 +263,7 @@ '', '', '', + '', '', '', "", @@ -280,6 +281,8 @@ }); document.getElementById("m-auth-confirm-field").hidden = authMode !== "register"; document.getElementById("m-auth-confirm").required = authMode === "register"; + document.getElementById("m-auth-invite-field").hidden = authMode !== "register"; + document.getElementById("m-auth-invite").required = authMode === "register"; document.getElementById("m-auth-password").autocomplete = authMode === "register" ? "new-password" : "current-password"; const submitLabel = document.querySelector("#m-auth-submit .m-btn-label"); if (submitLabel) submitLabel.textContent = authMode === "register" ? "注册并进入" : "登录"; @@ -346,7 +349,7 @@ label.textContent = authMode === "register" ? "注册中…" : "登录中…"; try { if (authMode === "register") { - await global.MobileSession.register(username, password); + await global.MobileSession.register(username, password, document.getElementById("m-auth-invite").value.trim()); } else { await global.MobileSession.login(username, password); } diff --git a/frontend/m/js/session.js b/frontend/m/js/session.js index dceefc7..1f7cb19 100644 --- a/frontend/m/js/session.js +++ b/frontend/m/js/session.js @@ -24,10 +24,11 @@ return applySession(payload); } - async function register(username, password) { + async function register(username, password, inviteCode) { const payload = await global.MobileAPI.request("/api/auth/register", "POST", { username: username, - password: password + password: password, + invite_code: inviteCode || "" }); return applySession(payload); } diff --git a/frontend/pages/heaven/foundation.css b/frontend/pages/heaven/foundation.css index f1f5002..e470c55 100644 --- a/frontend/pages/heaven/foundation.css +++ b/frontend/pages/heaven/foundation.css @@ -6250,12 +6250,6 @@ body[data-active-view="heavenView"] .workspace-view { color: var(--up); } -.model.on { - background: var(--blue-soft); - - box-shadow: inset 2px 0 0 var(--blue); -} - .star { color: rgb(245, 158, 11); diff --git a/frontend/pages/review/foundation.css b/frontend/pages/review/foundation.css index 9924d7f..1fa980c 100644 --- a/frontend/pages/review/foundation.css +++ b/frontend/pages/review/foundation.css @@ -2065,7 +2065,7 @@ body[data-active-view="reviewWorkspaceView"] .workspace-view { } } -:root[data-theme="dark"] :is(.global-search-results, .assistant-messages, .trade-log-form, .alerts-toolbar, .admin-section-picker) { +:root[data-theme="dark"] :is(.global-search-results, .assistant-messages, .trade-log-form, .alerts-toolbar) { border-color: var(--line-soft); background: var(--surface-muted); diff --git a/frontend/shared/admin.js b/frontend/shared/admin.js index 012178e..840ec5a 100644 --- a/frontend/shared/admin.js +++ b/frontend/shared/admin.js @@ -39,21 +39,26 @@ async function openAdminSettings(refreshOnly = false) { const payload = await apiRequest("/api/admin/settings"); const data = payload.data || {}; const ifind = data.ifind || {}; - const llm = payload.llm || {}; - const membership = payload.membership || {}; status.textContent = `数据中枢 ${data.configured ? "已连接" : "未连接"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`; status.classList.toggle("connected", Boolean(data.configured)); setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停"); renderDatahubRouteStatus(data.datahub || {}); document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled); - document.querySelector("#memberDailyLimit").value = number(membership.member_daily_limit) || 50; - renderModelPool(llm.models || [], llm.primary_model_id || "", llm.fallback_model_id || ""); - renderAdminUsers(payload.users || []); } catch (error) { status.textContent = error.message || "系统配置读取失败"; } } +function dataHubConsoleUrl() { + if (window.XIAOBAI_DATAHUB_URL) return String(window.XIAOBAI_DATAHUB_URL); + return `${window.location.protocol}//${window.location.hostname}:8766/admin/`; +} + +function openDataHubConsole() { + if (state.user?.role !== "admin") return; + window.open(dataHubConsoleUrl(), "_blank", "noopener"); +} + function renderDatahubRouteStatus(hub) { const box = document.querySelector("#datahubRouteStatus"); if (!box) return; @@ -74,129 +79,6 @@ function renderDatahubRouteStatus(hub) { } } -function selectAdminPanel(panel) { - const selected = ["market", "models", "members"].includes(panel) ? panel : "market"; - document.querySelector("#adminSectionSelect").value = selected; - document.querySelectorAll("[data-admin-panel]").forEach((item) => { - item.hidden = item.dataset.adminPanel !== selected; - }); -} - -function renderModelPool(models, primaryId = "", fallbackId = "") { - state.adminModels = models.map((item) => ({ ...item, api_key: item.api_key || "" })); - const container = document.querySelector("#modelPoolList"); - container.innerHTML = state.adminModels.map((item, index) => ` -
-
${escapeHtml(item.name || `模型 ${index + 1}`)}${item.configured ? "已保存密钥" : "待配置"}
-
- - - - -
-
未测试
-
- `).join("") || emptyStateHtml("模型池为空,请先添加模型"); - updateModelRoleOptions(primaryId, fallbackId); - container.querySelectorAll("[data-test-model]").forEach((button) => button.addEventListener("click", () => testPlatformModel(button.closest("[data-model-id]")))); - container.querySelectorAll("[data-delete-model]").forEach((button) => button.addEventListener("click", () => deletePlatformModel(button.closest("[data-model-id]")))); - container.querySelectorAll("[data-model-field='name']").forEach((input) => input.addEventListener("input", updateModelRoleLabels)); - refreshIcons(); -} - -function collectModelPool() { - const saved = new Map(state.adminModels.map((item) => [item.id, item])); - return [...document.querySelectorAll("#modelPoolList [data-model-id]")].map((row) => ({ - id: row.dataset.modelId, - name: row.querySelector("[data-model-field='name']").value.trim(), - base_url: row.querySelector("[data-model-field='base_url']").value.trim(), - model: row.querySelector("[data-model-field='model']").value.trim(), - api_key: row.querySelector("[data-model-field='api_key']").value.trim(), - configured: Boolean(saved.get(row.dataset.modelId)?.configured), - })); -} - -function updateModelRoleOptions(primaryId = document.querySelector("#platformPrimaryModelSelect").value, fallbackId = document.querySelector("#platformFallbackModelSelect").value) { - const models = collectModelPool(); - const options = models.map((item) => ``).join(""); - const primary = document.querySelector("#platformPrimaryModelSelect"); - const fallback = document.querySelector("#platformFallbackModelSelect"); - primary.innerHTML = models.length ? options : ''; - fallback.innerHTML = `${options}`; - primary.value = models.some((item) => item.id === primaryId) ? primaryId : models[0]?.id || ""; - fallback.value = models.some((item) => item.id === fallbackId) && fallbackId !== primary.value ? fallbackId : ""; -} - -function updateModelRoleLabels() { - updateModelRoleOptions(); -} - -function addPlatformModel() { - const models = collectModelPool(); - const id = `model-${Date.now()}-${Math.floor(Math.random() * 10000)}`; - models.push({ id, name: `模型 ${models.length + 1}`, base_url: "https://api.openai.com/v1", model: "", api_key: "", configured: false }); - renderModelPool(models, document.querySelector("#platformPrimaryModelSelect").value || id, document.querySelector("#platformFallbackModelSelect").value); - document.querySelector(`[data-model-id="${CSS.escape(id)}"] [data-model-field="name"]`)?.focus(); -} - -function deletePlatformModel(row) { - if (!row) return; - const id = row.dataset.modelId; - const primary = document.querySelector("#platformPrimaryModelSelect").value; - const fallback = document.querySelector("#platformFallbackModelSelect").value; - if (id === primary || id === fallback) { - showToast("请先为主模型或辅助模型选择其他模型,再删除当前模型"); - return; - } - const models = collectModelPool().filter((item) => item.id !== id); - renderModelPool(models, primary, fallback); -} - -function renderAdminUsers(users) { - const container = document.querySelector("#adminUsersList"); - container.innerHTML = users.map((user) => { - const admin = user.role === "admin"; - const member = Boolean(user.membership_subscribed); - const identityLabels = [admin ? "管理员" : "", member ? "会员有效" : "普通用户"].filter(Boolean).join(" · "); - const expiry = member - ? (user.membership_expires_at ? `有效至 ${membershipDateDisplay(user.membership_expires_at)}` : "永久有效") - : user.membership_status === "suspended" - ? "会员已停用" - : user.membership_status === "active" && user.membership_expires_at - ? `已于 ${membershipDateDisplay(user.membership_expires_at)} 到期` - : "尚未开通"; - return `
-
${escapeHtml(user.username)}${escapeHtml(identityLabels)}${escapeHtml(expiry)}
-
今日调用 ${number(user.used_today)}
-
- - - -
当前到期${escapeHtml(expiry)}
- -
-
`; - }).join("") || emptyStateHtml("暂无注册用户"); - container.querySelectorAll(".membership-form").forEach((form) => form.addEventListener("submit", saveMembership)); -} - -async function saveMembership(event) { - event.preventDefault(); - const form = event.currentTarget; - const data = Object.fromEntries(new FormData(form).entries()); - const button = form.querySelector("button[type='submit']"); - button.disabled = true; - try { - const payload = await apiRequest("/api/admin/membership", "POST", data); - renderAdminUsers(payload.users || []); - showToast("会员状态已更新"); - } catch (error) { - showToast(error.message || "会员状态保存失败"); - } finally { - button.disabled = false; - } -} - async function saveMarketSettings(event) { event.preventDefault(); const button = event.currentTarget.querySelector("button[type='submit']"); @@ -214,70 +96,12 @@ async function saveMarketSettings(event) { } } -async function saveModelPool(event) { - event.preventDefault(); - const button = event.currentTarget.querySelector("button[type='submit']"); - button.disabled = true; - try { - await apiRequest("/api/admin/settings", "POST", { - models: collectModelPool(), - primary_model_id: document.querySelector("#platformPrimaryModelSelect").value, - fallback_model_id: document.querySelector("#platformFallbackModelSelect").value, - }); - showToast("模型池已保存"); - await openAdminSettings(true); - } catch (error) { - showToast(error.message || "模型池保存失败"); - } finally { - button.disabled = false; - } -} - -async function saveMembershipSettings(event) { - event.preventDefault(); - const button = event.currentTarget.querySelector("button[type='submit']"); - button.disabled = true; - try { - await apiRequest("/api/admin/settings", "POST", { - member_daily_limit: number(document.querySelector("#memberDailyLimit").value), - }); - showToast("会员调用额度已保存"); - await openAdminSettings(true); - } catch (error) { - showToast(error.message || "会员调用额度保存失败"); - } finally { - button.disabled = false; - } -} - -async function testPlatformModel(row) { - if (!row) return; - const button = row.querySelector("[data-test-model]"); - const status = row.querySelector(".model-test-status"); - const profile = collectModelPool().find((item) => item.id === row.dataset.modelId) || {}; - button.disabled = true; - status.textContent = "连接中"; - try { - const payload = await apiRequest("/api/admin/settings/test", "POST", { model_id: row.dataset.modelId, profile }); - status.textContent = `已连通 · ${number(payload.result.latency_ms)} ms`; - status.className = "model-test-status success"; - } catch (error) { - status.textContent = error.message; - status.className = "model-test-status failure"; - } finally { - button.disabled = false; - } -} - function bindAdminEvents() { - document.querySelector("#settingsButton").addEventListener("click", () => openAdminSettings()); + document.querySelector("#settingsButton").addEventListener("click", openDataHubConsole); + document.querySelector("#marketAdminButton").addEventListener("click", () => openAdminSettings()); document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close()); document.querySelector("#backfillButton").addEventListener("click", backfillData); - document.querySelector("#adminSectionSelect").addEventListener("change", (event) => selectAdminPanel(event.target.value)); document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings); - document.querySelector("#systemModelsForm").addEventListener("submit", saveModelPool); - document.querySelector("#membershipSettingsForm").addEventListener("submit", saveMembershipSettings); - document.querySelector("#addPlatformModel").addEventListener("click", addPlatformModel); document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh); } diff --git a/frontend/shared/auth.css b/frontend/shared/auth.css index d172abc..1aaf540 100644 --- a/frontend/shared/auth.css +++ b/frontend/shared/auth.css @@ -125,50 +125,6 @@ width: min(1060px, -24px + 100vw); } -.admin-section-picker { - display: grid; - - grid-template-columns: 96px minmax(220px, 360px); - - align-items: center; - - gap: 12px; - - padding: 16px 18px; -} - -.admin-section-picker label { - color: var(--text-secondary); - - font-size: 13px; - - font-weight: 700; -} - -.admin-section-picker select { - width: 100%; - - min-height: 32px; - - padding: 0px 11px; - - border: 1px solid var(--border-strong); - - border-radius: 8px; - - background: var(--surface); - - color: var(--text-primary); - - font-size: var(--font-size-label); -} - -.admin-section-picker select:focus-visible { - outline: 2px solid var(--action); - - outline-offset: 2px; -} - .admin-panel[hidden] { display: none; } @@ -220,161 +176,6 @@ font-weight: 650; } -.compact-number-field { - width: min(260px, 100%); - - margin-top: 14px; -} - -.admin-users-list { - display: grid; - - border-top: 1px solid var(--line); -} - -.admin-user-row { - min-width: 0px; - - display: grid; - - grid-template-columns: 160px 100px minmax(0px, 1fr); - - align-items: center; - - gap: 14px; - - padding: 13px 0px; - - border-bottom: 1px solid var(--line); -} - -.admin-user-identity strong { - display: block; - - color: var(--text-primary); - - font-size: 13px; -} - -.admin-user-identity small, -.admin-user-identity span { - display: block; - - margin-top: 4px; - - color: var(--text-muted); - - font-size: 11px; -} - -.admin-user-usage { - margin-top: 4px; - - color: var(--text-muted); - - font-size: 11px; -} - -.membership-form { - padding: 0px; - - min-width: 0px; - - display: grid; - - grid-template-columns: 100px minmax(170px, 0.8fr) minmax(160px, 1fr) auto; - - align-items: end; - - gap: 8px; -} - -.membership-form label { - min-width: 0px; - - display: grid; - - gap: 5px; -} - -.membership-form label span { - color: var(--text-muted); - - font-size: 10px; -} - -.membership-form input, -.membership-form select { - width: 100%; - - min-width: 0px; - - height: 32px; - - padding: 0px 8px; - - border: 1px solid var(--border-strong); - - border-radius: 8px; - - background: var(--surface); -} - -.membership-expiry { - min-width: 0px; - - display: grid; - - gap: 5px; - - align-self: end; -} - -.membership-expiry span { - color: var(--text-muted); - - font-size: 10px; -} - -.membership-expiry strong { - min-height: 34px; - - display: flex; - - align-items: center; - - color: var(--text-secondary); - - font-size: 12px; - - font-weight: 650; -} - -@media (max-width: 820px) { - .admin-user-row { - grid-template-columns: 1fr auto; - } - - .admin-user-row .membership-form { - grid-column: 1 / -1; - } - - .membership-form { - grid-template-columns: repeat(2, minmax(0px, 1fr)); - } - - .membership-form .button { - align-self: end; - } -} - -@media (max-width: 600px) { - - .membership-form { - grid-template-columns: minmax(0px, 1fr); - } -} - .connection-status { margin: 18px 18px 0px; @@ -399,7 +200,6 @@ padding: 18px; } -.settings-dialog .membership-form, .settings-dialog .settings-section > form, .settings-dialog.admin-dialog > form { padding: 0px; @@ -559,7 +359,6 @@ padding-left: 12px; } - .settings-dialog .membership-form, .settings-dialog .settings-section > form, .settings-dialog.admin-dialog > form { padding: 0px; @@ -1173,12 +972,6 @@ button.account-role-badge:focus-visible { } } -@media (max-width: 600px) { - .admin-section-picker { - grid-template-columns: minmax(0px, 1fr); - } -} - @media (max-width: 520px) { .settings-dialog .form-field { width: 100%; @@ -1520,32 +1313,6 @@ button.account-role-badge:focus-visible { font-size: 11px; } -.admin-dialog .admin-section-picker { - grid-template-columns: auto minmax(220px, 300px); - - gap: 14px; - - padding: 12px 20px; - - border-bottom: 1px solid var(--dialog-line); - - background: var(--surface-subtle); -} - -.admin-dialog .admin-section-picker label { - font-size: 11px; -} - -.admin-dialog .admin-section-picker select { - min-height: 32px; - - border-color: var(--border-strong); - - border-radius: 8px; - - font-size: var(--font-size-label); -} - .admin-dialog .admin-panel { max-width: 860px; @@ -1556,24 +1323,6 @@ button.account-role-badge:focus-visible { border-top: 1px solid var(--dialog-line); } -.admin-dialog .admin-users-list { - border-color: var(--dialog-line); -} - -.admin-dialog .admin-user-row { - grid-template-columns: 150px 90px minmax(0px, 1fr); - - gap: 12px; - - padding: 12px 0px; - - border-color: var(--dialog-line); -} - -.admin-dialog .membership-form { - grid-template-columns: 92px minmax(135px, 0.8fr) minmax(140px, 1fr) auto; -} - @media (max-width: 760px) { .account-settings-dialog .membership-status-grid { grid-template-columns: repeat(2, minmax(0px, 1fr)); @@ -1582,22 +1331,6 @@ button.account-role-badge:focus-visible { .account-settings-dialog .account-birth-form { grid-template-columns: 1fr; } - - .admin-dialog .admin-section-picker { - grid-template-columns: 1fr; - - gap: 6px; - } - - .admin-dialog .admin-user-row { - grid-template-columns: 1fr auto; - } - - .admin-dialog .admin-user-row .membership-form { - grid-column: 1 / -1; - - grid-template-columns: repeat(2, minmax(0px, 1fr)); - } } @media (max-width: 460px) { @@ -1612,10 +1345,6 @@ button.account-role-badge:focus-visible { .account-settings-dialog .membership-comparison > div { min-width: 510px; } - - .admin-dialog .admin-user-row .membership-form { - grid-template-columns: 1fr; - } } :root[data-theme="dark"] :is(.header-command-group, .account-dropdown) { diff --git a/frontend/shared/components/cards.css b/frontend/shared/components/cards.css index 8f00854..b38c928 100644 --- a/frontend/shared/components/cards.css +++ b/frontend/shared/components/cards.css @@ -1028,64 +1028,6 @@ vertical-align: -1px; } -.model { - padding: 12px 14px; - - border-bottom: 1px solid var(--line-soft); - - cursor: pointer; -} - -.model:hover { - background: var(--table-hover); -} - -.model .r1 { - display: flex; - - align-items: center; - - gap: 8px; -} - -.model .r1 .nm { - font-weight: 700; - - font-size: 13px; -} - -.model .r1 .lv { - margin-left: auto; -} - -.model .r2 { - font-size: 11.5px; - - color: var(--sub); - - margin-top: 4px; - - line-height: 1.6; -} - -.model .r3 { - display: flex; - - gap: 10px; - - margin-top: 6px; - - font-size: 10.5px; - - color: var(--faint); -} - -.model .r3 .q { - border-bottom: 1px dashed var(--faint); - - cursor: help; -} - :root[data-theme="dark"] :is(.card-h, .performance-stage-card header, .rotation-card-head, .auction-card-head-v2, .theme-card-head-v2, .dragon-detail-header, .screener-card-head, .mentor-page-header) { border-color: var(--line-soft); diff --git a/frontend/shared/components/dialogs.css b/frontend/shared/components/dialogs.css index 233b36b..70aa2cd 100644 --- a/frontend/shared/components/dialogs.css +++ b/frontend/shared/components/dialogs.css @@ -29,22 +29,6 @@ margin-top: 22px; } -.admin-dialog .model-role-selectors { - gap: 10px; -} - -.admin-dialog .model-pool-list { - margin-top: 14px; -} - -.admin-dialog .model-row { - border-color: var(--border); - - border-radius: 8px; - - background: var(--surface-subtle); -} - @keyframes overlay-enter { 0% { opacity: 0; diff --git a/frontend/shared/session.js b/frontend/shared/session.js index 20ebc17..38a3f03 100644 --- a/frontend/shared/session.js +++ b/frontend/shared/session.js @@ -6,6 +6,8 @@ function selectAuthMode(mode) { const registering = state.authMode === "register"; document.querySelector("#authConfirmField").hidden = !registering; document.querySelector("#authPasswordConfirm").required = registering; + document.querySelector("#authInviteField").hidden = !registering; + document.querySelector("#authInviteCode").required = registering; document.querySelector("#authPassword").autocomplete = registering ? "new-password" : "current-password"; document.querySelector("#authSubmitButton").textContent = registering ? "注册并进入" : "登录"; document.querySelector("#authError").hidden = true; @@ -24,7 +26,11 @@ async function submitAuthForm(event) { const button = document.querySelector("#authSubmitButton"); button.disabled = true; try { - const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", { username, password }); + const registering = state.authMode === "register"; + const credentials = registering + ? { username, password, invite_code: document.querySelector("#authInviteCode").value.trim() } + : { username, password }; + const session = await apiRequest(`/api/auth/${state.authMode}`, "POST", credentials); document.querySelector("#authForm").reset(); await applyAuthenticatedSession(session); } catch (error) { @@ -42,6 +48,7 @@ async function applyAuthenticatedSession(session) { const isAdmin = session.user?.role === "admin"; updateAccountIdentityBadges(session.user?.membership || {}); document.querySelector("#settingsButton").hidden = !isAdmin; + document.querySelector("#marketAdminButton").hidden = !isAdmin; document.querySelector("#syncButton").hidden = !isAdmin; document.querySelector("#reasonForm").hidden = !isAdmin; document.querySelector("#sectorPhaseManager").hidden = !isAdmin; diff --git a/tests/e2e/app-shell.spec.js b/tests/e2e/app-shell.spec.js index 1cb5750..70130e1 100644 --- a/tests/e2e/app-shell.spec.js +++ b/tests/e2e/app-shell.spec.js @@ -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"); diff --git a/tests/e2e/mobile-app.spec.js b/tests/e2e/mobile-app.spec.js index 373c268..336fe81 100644 --- a/tests/e2e/mobile-app.spec.js +++ b/tests/e2e/mobile-app.spec.js @@ -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); diff --git a/tests/test_account_switch_grants.py b/tests/test_account_switch_grants.py index b282cdb..daed37c 100644 --- a/tests/test_account_switch_grants.py +++ b/tests/test_account_switch_grants.py @@ -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") diff --git a/tests/test_database_migrations.py b/tests/test_database_migrations.py index 6ee6379..6b4b186 100644 --- a/tests/test_database_migrations.py +++ b/tests/test_database_migrations.py @@ -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: diff --git a/tests/test_hub_admin_bridge.py b/tests/test_hub_admin_bridge.py new file mode 100644 index 0000000..92d85b0 --- /dev/null +++ b/tests/test_hub_admin_bridge.py @@ -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() diff --git a/tests/test_invite_registration.py b/tests/test_invite_registration.py new file mode 100644 index 0000000..017e702 --- /dev/null +++ b/tests/test_invite_registration.py @@ -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() diff --git a/tests/test_mobile_system_pages.py b/tests/test_mobile_system_pages.py index bca1694..182b6a3 100644 --- a/tests/test_mobile_system_pages.py +++ b/tests/test_mobile_system_pages.py @@ -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) diff --git a/tools/README.md b/tools/README.md index 5dcc15c..9e6dd66 100644 --- a/tools/README.md +++ b/tools/README.md @@ -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 ]`: 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 [--lookback 60] [--dry-run]`: auditable recent trading-day dashboard snapshot backfill. See `docs/maintenance/行情历史补档.md`. diff --git a/tools/verify_datahub_console.py b/tools/verify_datahub_console.py new file mode 100644 index 0000000..6602848 --- /dev/null +++ b/tools/verify_datahub_console.py @@ -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()) diff --git a/tools/verify_datahub_console_ui.py b/tools/verify_datahub_console_ui.py new file mode 100644 index 0000000..ba7d754 --- /dev/null +++ b/tools/verify_datahub_console_ui.py @@ -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()) diff --git a/xiaobai-datahub/.env.example b/xiaobai-datahub/.env.example index 33fb773..7bb211a 100644 --- a/xiaobai-datahub/.env.example +++ b/xiaobai-datahub/.env.example @@ -4,8 +4,13 @@ DATAHUB_ENCRYPTION_KEY= # Consumer API token for /v1 (32+ random bytes, shown once). Never log this value. DATAHUB_TOKEN= -# Initial admin password for /admin. Forced change on first login. -DATAHUB_ADMIN_PASSWORD= +# 控制台没有独立账号:/admin 用小白复盘主站的管理员账号登录(HEL-560)。 +# 主站的服务端地址(容器内互访)+ 双方共享的桥接令牌,两者缺一控制台无法校验登录。 +REVIEW_BASE_URL=http://xiaobai-review:8765 +HUB_ADMIN_TOKEN= + +# 浏览器可达的主站地址;留空时按请求 Host 推导 http://:8765。 +REVIEW_PUBLIC_URL= # Tushare Pro token. Stored encrypted after first launch; never returned by API or admin pages. TUSHARE_TOKEN= diff --git a/xiaobai-datahub/README.md b/xiaobai-datahub/README.md index 7809d05..532b07b 100644 --- a/xiaobai-datahub/README.md +++ b/xiaobai-datahub/README.md @@ -10,7 +10,7 @@ - 盘中观察(provisional):东财/腾讯指数报价、个股最新价、全市场快照、分时点(`/v1/quotes/latest` 不传 codes 即全市场,`/v1/indexes/quotes` `/v1/intraday/points`);永不写入 eod_* 正式表 - 暂存 → 校验 → 整批原子发布 → 可回滚 - `/v1` 稳定接口(`X-Datahub-Token`) -- `/admin/` 最小管理后台(总览 / 数据源 / 调度 / 发布 / 数据集 / 审计) +- `/admin/` 统一管理控制台(总览 / 数据源配置 / 模型池 / 会员管理 / 数据血缘),日间与夜间两套配色 - 同花顺/选股宝/AKShare/iFinD 适配器位仍预留;东财/腾讯已接入盘中观察 ## 单位口径(相对现站) @@ -33,16 +33,43 @@ cd xiaobai-datahub python -m venv .venv && .venv/bin/pip install -r requirements.txt cp .env.example .env -# 填入 DATAHUB_ENCRYPTION_KEY / DATAHUB_TOKEN / DATAHUB_ADMIN_PASSWORD / TUSHARE_TOKEN +# 填入 DATAHUB_ENCRYPTION_KEY / DATAHUB_TOKEN / HUB_ADMIN_TOKEN / TUSHARE_TOKEN +# HUB_ADMIN_TOKEN 与主站 .env 同名变量必须一致;控制台没有独立账号,用主站管理员账号登录 # 生成 Fernet 密钥: # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" .venv/bin/python server.py --host 127.0.0.1 --port 8766 ``` -- 管理后台:http://127.0.0.1:8766/admin/ +- 管理控制台:http://127.0.0.1:8766/admin/ - 存活检查:http://127.0.0.1:8766/livez (无需 token) - `/v1/*` 必须带请求头 `X-Datahub-Token` +## 管理控制台的账号与权限(HEL-560) + +控制台**没有自己的账号体系**,也不再有独立登录页和改密页: + +- 登录状态取自主站 `xiaobai_session` cookie。主站与中枢同主机不同端口,浏览器会自动带上该 cookie,因此在主站登录后直接打开 8766 即可进入;未登录会看到门禁面板并给出主站登录入口。 +- 仅管理员可进。每个页面与每个 `/admin/api/*` 接口都在服务端校验会话与 `role=admin`,非管理员一律 403,前端隐藏与否不作为权限依据。 +- 校验方式是服务间桥接:中枢把 cookie 交给主站 `/api/hub-admin/session` 换回用户身份,结果缓存数秒。桥接凭 `HUB_ADMIN_TOKEN`(与主站 `.env` 同名变量必须一致),主站在任何处理器之前先校验它。 +- CSRF 令牌由会话派生(HMAC),随 `GET /admin/api/session` 下发,写操作必须带 `X-CSRF-Token`。 +- 回滚、回补等危险操作仍需二次确认密码,校验走主站 `/api/hub-admin/password/check`,中枢不存密码。 +- 「退出」会请求主站注销该会话并跳回主站登录页。 + +需要的环境变量: + +| 变量 | 位置 | 说明 | +|---|---|---| +| `HUB_ADMIN_TOKEN` | 主站 + 中枢 | 服务间桥接令牌,两侧必须一致,缺失则控制台无法校验会话 | +| `REVIEW_BASE_URL` | 中枢 | 中枢访问主站的地址(容器内一般是服务名,如 `http://xiaobai-review:8765`) | +| `REVIEW_PUBLIC_URL` | 中枢 | 浏览器可达的主站地址,用于门禁的登录跳转;留空则按当前主机名推导 | + +## 从主站迁入的两块配置 + +- **模型池**:按供应商组织(同一 API 地址下可挂多个模型),填好地址与 Key 后可自动拉取 `/models` 勾选纳入;供应商不支持或拉取失败时用卡内「手动录入」兜底。主 / 辅模型分工在「调用编排」里指定。密钥加密存于主站,界面只回显后四位。 +- **会员与邀请码**:会员开通 / 续期 / 停用、每日调用额度,以及一次性邀请码的生成、复制、作废。注册必须提交有效邀请码,每个码只能成功注册一次(并发提交也只有一个成功)。列表只显示掩码,完整码仅在生成瞬间与「复制」动作中可得。 + +数据仍归主站所有(同一个 `review.db`),中枢只是唯一的管理入口;主站页面上原本的模型池与会员管理分区已移除,「数据中枢」按钮指向 8766。 + ## Docker(独立 compose,不改现网 review 服务) ```bash diff --git a/xiaobai-datahub/admin/app.js b/xiaobai-datahub/admin/app.js index 4baeab5..310ad18 100644 --- a/xiaobai-datahub/admin/app.js +++ b/xiaobai-datahub/admin/app.js @@ -14,7 +14,13 @@ function $(id) { return document.getElementById(id); } function esc(v) { return String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } -const state = { csrf: '', page: 'overview', calm: false, username: '' }; +const state = { + csrf: '', page: 'overview', calm: false, username: '', + reviewUrl: '', theme: 'night', + // 站点侧数据(模型池 / 会员 / 邀请码)由主站经桥接返回,只在本页缓存 + models: null, members: null, invites: null, + vendorDraft: {}, freshCodes: {}, modelTests: {}, +}; async function api(path, options = {}) { const headers = Object.assign({ 'Content-Type': 'application/json' }, options.headers || {}); @@ -23,90 +29,91 @@ async function api(path, options = {}) { const body = await res.json().catch(() => ({})); if (!res.ok) { const msg = (body.error && body.error.message) || body.error || res.statusText; - throw new Error(msg); + const error = new Error(msg); + error.status = res.status; + error.detail = body; + throw error; } return body; } -/* ================= 认证:登录 / 改密 / 登出 ================= */ +/* ================= 认证:主站会话门禁(数据中枢无独立账号) ================= + 浏览器在同一主机不同端口间共享主站的 xiaobai_session cookie,本页只把它 + 交给 /admin/api/session 由主站校验:未登录 401 → 引导去主站登录页, + 非管理员 403 → 明确拒绝,管理员才渲染控制台外壳。 */ function showView(id) { - ['login-view', 'change-view'].forEach((k) => { $(k).hidden = k !== id; }); + $('gate-view').hidden = id !== 'gate'; $('appRoot').hidden = id !== 'shell'; } +function showGate(title, desc, links) { + $('gate-title').textContent = title; + $('gate-desc').textContent = desc; + const login = $('gate-login'); + const site = $('gate-site'); + login.hidden = !links.loginUrl; + if (links.loginUrl) login.href = links.loginUrl; + site.hidden = !links.reviewUrl; + if (links.reviewUrl) site.href = links.reviewUrl; + showView('gate'); +} + async function boot() { + applyTheme(localStorage.getItem('datahub-theme') === 'day' ? 'day' : 'night'); try { const session = await api('/admin/api/session'); state.csrf = session.csrf; state.username = session.username; + state.reviewUrl = session.review_url || ''; $('who').textContent = session.username; - if (session.must_change) { showView('change-view'); return; } showView('shell'); await startApp(); - } catch { - showView('login-view'); + } catch (err) { + const detail = err.detail || {}; + if (err.status === 403) { + showGate('仅管理员可进入', '数据中枢只对管理员开放。当前登录账号「' + (detail.username || '未知') + '」没有管理员权限,请用管理员账号登录主站后再进入。', { reviewUrl: state.reviewUrl || '/' }); + return; + } + const loginUrl = detail.login_url || ''; + if (loginUrl) state.reviewUrl = loginUrl.replace(/\/login\/?$/, ''); + showGate('请先登录小白复盘', '数据中枢使用主站账号,未检测到登录状态。到主站用管理员账号登录后回到本页即可。', { loginUrl: loginUrl }); } } -$('login-form').addEventListener('submit', async (event) => { - event.preventDefault(); - const form = new FormData(event.target); - $('login-error').hidden = true; - try { - const result = await api('/admin/api/login', { - method: 'POST', - body: JSON.stringify({ username: form.get('username'), password: form.get('password') }), - }); - state.csrf = result.csrf; - if (result.must_change) { showView('change-view'); return; } - const session = await api('/admin/api/session'); - state.username = session.username; - $('who').textContent = session.username; - showView('shell'); - await startApp(); - } catch (err) { - $('login-error').hidden = false; - $('login-error').textContent = err.message; - } -}); - -$('change-form').addEventListener('submit', async (event) => { - event.preventDefault(); - const form = new FormData(event.target); - try { - await api('/admin/api/change-password', { - method: 'POST', - body: JSON.stringify({ current: form.get('current'), new_password: form.get('new_password') }), - }); - const session = await api('/admin/api/session'); - state.username = session.username; - $('who').textContent = session.username; - showView('shell'); - await startApp(); - } catch (err) { - $('change-error').hidden = false; - $('change-error').textContent = err.message; - } -}); +$('gate-retry').addEventListener('click', () => { boot(); }); $('logout-btn').addEventListener('click', async () => { - try { await api('/admin/api/logout', { method: 'POST', body: '{}' }); } catch {} + let loginUrl = ''; + try { + const result = await api('/admin/api/logout', { method: 'POST', body: '{}' }); + loginUrl = result.login_url || ''; + } catch {} Poller.stop(); - showView('login-view'); + showGate('已退出登录', '主站会话已结束。重新登录后可再次进入数据中枢。', { loginUrl: loginUrl }); }); +/* ---------- 日间 / 夜间主题(同一组 token 换值,不叠加覆盖样式) ---------- */ +function applyTheme(theme) { + state.theme = theme === 'day' ? 'day' : 'night'; + document.documentElement.dataset.theme = state.theme; + localStorage.setItem('datahub-theme', state.theme); + const btn = $('themeBtn'); + if (btn) btn.textContent = state.theme === 'day' ? '夜间' : '日间'; +} +$('themeBtn').addEventListener('click', () => applyTheme(state.theme === 'day' ? 'night' : 'day')); + /* ================= 状态归类(真实状态字符串 → 7 类视觉桶) ================= 桶: ok / slow / fail / plan / review / degrade / off 来源覆盖:observability.classify_error/classify_rows、CircuitBreaker、 eod/revision progress、batches/publications、job_runs 的全部真实取值。 */ const HEALTH = { - ok: { label: '正常', led: 'ok', color: '#34d399' }, - slow: { label: '慢', led: 'slow', color: '#fbbf24' }, - fail: { label: '失败', led: 'fail', color: '#f87171' }, - plan: { label: '计划', led: 'plan', color: '#8b9bb4' }, - review: { label: '复核中', led: 'rev', color: '#22d3ee' }, - degrade: { label: '降级', led: 'slow', color: '#fbbf24' }, - off: { label: '未配置', led: 'off', color: '#54637e' }, + ok: { label: '正常', led: 'ok', color: 'var(--mint)' }, + slow: { label: '慢', led: 'slow', color: 'var(--amb)' }, + fail: { label: '失败', led: 'fail', color: 'var(--rd)' }, + plan: { label: '计划', led: 'plan', color: 'var(--mut)' }, + review: { label: '复核中', led: 'rev', color: 'var(--cy)' }, + degrade: { label: '降级', led: 'slow', color: 'var(--amb)' }, + off: { label: '未配置', led: 'off', color: 'var(--dim)' }, }; const STATE_MAP = { ok: 'ok', closed: 'ok', published: 'ok', done: 'ok', aligned: 'ok', @@ -131,30 +138,44 @@ function ledHtml(st, pulse) { return `${ledHtml(h.led, bucket === 'fail' || bucket === 'review')}${label || h.label}`; } function tagHtml(tone, inner) { return `${inner}`; } function panelHtml(title, right, body, pad) { - return `
//${title}${right ? `${right}` : ''}
${body}
`; + return `
//${title}${right ? `${right}` : ''}
${body}
`; } function kpiHtml(label, value, sub, tone, live) { const tc = { txt: '', mint: 'glow-mint', amb: 'glow-amb', rd: 'glow-rd', cy: 'glow-cy' }[tone || 'txt']; - return `
${label}${value}${live ? '_' : ''}${sub ? `${sub}` : ''}
`; + return `
${label}${value}${live ? '_' : ''}${sub ? `${sub}` : ''}
`; } /* 延迟:真实值 + 变化时才闪(打样 jit 的真实数据版:不随机抖动数字, 仅当新一轮真实调用改写该值时给 .flash 青色闪烁)。 */ function latHtml(ms, warn) { - if (ms == null) return ''; - const color = ms > 1000 ? '#fbbf24' : (warn ? '#fbbf24' : '#d7e1f0'); + if (ms == null) return ''; + const color = ms > 1000 ? 'var(--amb)' : (warn ? 'var(--amb)' : 'var(--txt)'); return `${Number(ms).toLocaleString('en-US')}ms`; } /* 缓存年龄:记录真实时间戳,由 updateDynamics 每秒重算(真实增长)。 */ function ageHtml(iso) { const ms = Date.parse(String(iso || '').includes('T') ? iso : String(iso || '').replace(' ', 'T')); - if (!iso || Number.isNaN(ms)) return ''; - return ``; + if (!iso || Number.isNaN(ms)) return ''; + return ``; } function fmtAgeSec(s) { if (s == null) return '—'; // 打样口径:缓存年龄一律按秒展示、每秒增长(>30s 变琥珀) return `${Number(s).toLocaleString('en-US')}s`; } +function localDate(iso) { + const at = new Date(String(iso || '').replace(' ', 'T')); + return Number.isNaN(at.getTime()) ? null : at; +} +function ymd(iso) { + const at = localDate(iso); + if (!at) return String(iso || '').slice(0, 10); + return `${at.getFullYear()}-${p2(at.getMonth() + 1)}-${p2(at.getDate())}`; +} +function stamp(iso) { + const at = localDate(iso); + if (!at) return String(iso || ''); + return `${ymd(iso)} ${p2(at.getHours())}:${p2(at.getMinutes())}`; +} function hm(iso) { if (!iso) return '—'; const s = String(iso); @@ -189,9 +210,9 @@ function ellipHtml(text, color, fontSize, title) { const p2 = (x) => String(x).padStart(2, '0'); /* ================= sparkline(真实历史:来自 provider_call_log) ================= */ -const SPARK_COLORS = { cy: '#22d3ee', mint: '#34d399', amb: '#fbbf24', rd: '#f87171' }; +const SPARK_COLORS = { cy: 'var(--cy)', mint: 'var(--mint)', amb: 'var(--amb)', rd: 'var(--rd)' }; function sparkHtml(jid, tone, w, h) { - return ``; + return ``; } function sparkSeries(jid) { const hist = LatHist.get(jid) || []; @@ -384,24 +405,24 @@ function buildOverviewVM() { } function heroHtml(vm) { - const anomalyTxt = vm.anomalyCount ? `${vm.anomalyCount} 项异常` : `无异常`; + const anomalyTxt = vm.anomalyCount ? `${vm.anomalyCount} 项异常` : `无异常`; const tags = []; - if (vm.eod.state === 'waiting_upstream') tags.push(tagHtml('amb', `等待上游 · 已试 ${vm.eod.attempts || 0} 次`)); + if (vm.eod.state === 'waiting_upstream') tags.push(tagHtml('amb', `等待上游 · 已试 ${vm.eod.attempts || 0} 次`)); if (vm.rev.state === 'waiting_review') tags.push(tagHtml('cy', '复核 · 待发布')); const subParts = []; - if (vm.eod.attempts) subParts.push(`已试 ${vm.eod.attempts} 次`); - if (vm.eod.next_retry_at) subParts.push(`下次重试 --:--`); - if ((vm.eod.missing_datasets || []).length) subParts.push(`缺 ${esc(vm.eod.missing_datasets.join(','))}`); - if (vm.rev.window) subParts.push(`复核窗口 ${esc(vm.rev.window)}`); + if (vm.eod.attempts) subParts.push(`已试 ${vm.eod.attempts} 次`); + if (vm.eod.next_retry_at) subParts.push(`下次重试 --:--`); + if ((vm.eod.missing_datasets || []).length) subParts.push(`缺 ${esc(vm.eod.missing_datasets.join(','))}`); + if (vm.rev.window) subParts.push(`复核窗口 ${esc(vm.rev.window)}`); return `
` + `
` + - `
${vm.anomalyCount ? '部分异常' : '整体可用'}·${anomalyTxt}
` + + `
${vm.anomalyCount ? '部分异常' : '整体可用'}·${anomalyTxt}
` + `
${tags.join('')}
` + (subParts.length ? `
${subParts.join('|')}
` : '') + `
` + `
` + - kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, vm.unpublished.length ? `${vm.unpublished.map((d) => DATASET_NAME[d] || d).join(' · ')} 未发布` : '全部已发布') + - kpiHtml('今日调用成功率', vm.successRate == null ? '' : `${vm.successRate.toFixed(1)}%`, null, 'mint', vm.successRate != null) + + kpiHtml('已发布数据集', `${vm.publishedCount}/${vm.totalOfficial}`, vm.unpublished.length ? `${vm.unpublished.map((d) => DATASET_NAME[d] || d).join(' · ')} 未发布` : '全部已发布') + + kpiHtml('今日调用成功率', vm.successRate == null ? '' : `${vm.successRate.toFixed(1)}%`, null, 'mint', vm.successRate != null) + kpiHtml('主站回退页面', `${vm.fallbackPages.length} 页`, vm.fallbackPages.length ? esc(vm.fallbackPages[0]) : '实时层主源正常', vm.fallbackPages.length ? 'amb' : 'txt') + kpiHtml('EOD 尝试', `${vm.eod.attempts || 0} 次`, '上限 5 · 23:30 止', vm.eod.attempts ? 'rd' : 'txt') + `
`; @@ -411,10 +432,10 @@ function datasetStripHtml(vm) { const ring = d.bucket === 'fail' ? 'fail' : d.bucket === 'degrade' ? 'slow' : d.bucket === 'review' ? 'review' : ''; const sparkTone = d.bucket === 'fail' ? 'rd' : d.bucket === 'degrade' ? 'amb' : d.bucket === 'review' ? 'cy' : 'mint'; let note = ''; - if (d.time) note += `${esc(d.time)} · `; + if (d.time) note += `${esc(d.time)} · `; if (d.rows) note += `${Number(d.rows).toLocaleString('en-US')} 行`; - if (d.err) note += `${esc(plainError(d.err, 16))}`; - if (!d.time && !d.rows && !d.err) note = '今日尚未发布'; + if (d.err) note += `${esc(plainError(d.err, 16))}`; + if (!d.time && !d.rows && !d.err) note = '今日尚未发布'; return `
` + `
${esc(d.name)}${pillHtml(d.bucket)}
` + `
${note}
` + @@ -426,8 +447,8 @@ function observatoryHtml(vm) { const legendByProv = { eastmoney: 'EM 东财', tencent: 'TX 腾讯' }; const right = `${Object.keys(legendByProv).map((p) => `${ledHtml('ok')}${legendByProv[p]}`).join('')}`; const rows = vm.observers.length ? vm.observers.map((o) => `` + - `${esc(o.name)}` + - `${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'}` + + `${esc(o.name)}` + + `${o.provider === 'eastmoney' ? '东方财富' : '腾讯行情'}` + `${ageHtml(o.lastOk)}` + `${latHtml(o.lat, o.bucket !== 'ok')}` + `${sparkHtml(o.jid, o.bucket === 'slow' ? 'amb' : 'mint', 64, 16)}` + @@ -438,20 +459,20 @@ function observatoryHtml(vm) { } function siteImpactHtml(vm) { const bad = vm.siteImpact.filter((s) => s.bucket !== 'ok'); - const right = `${vm.okPages} 项正常 · ${vm.siteImpact.length - vm.okPages} 项待关注`; + const right = `${vm.okPages} 项正常 · ${vm.siteImpact.length - vm.okPages} 项待关注`; const rows = bad.length ? bad.slice(0, 5).map((s) => `` + - `${esc(s.page)}` + + `${esc(s.page)}` + `${pillHtml(s.bucket)}` + - `${s.note ? ellipHtml(plainError(s.note, 22), '#54637e', '11px', s.note) : '—'}` + + `${s.note ? ellipHtml(plainError(s.note, 22), 'var(--dim)', '11px', s.note) : '—'}` + `` + - ``).join('') : `全部页面正常${pillHtml('ok')}—`; + ``).join('') : `全部页面正常${pillHtml('ok')}—`; const footPages = vm.siteImpact.map((s) => ({ name: s.short, bucket: s.bucket })); const byPage = new Map(); footPages.forEach((p) => { const cur = byPage.get(p.name); if (!cur || SEV[p.bucket] > SEV[cur]) byPage.set(p.name, p.bucket); }); - const foot = `
正常 ${vm.okPages}` + + const foot = `
正常 ${vm.okPages}` + [...byPage.entries()].map(([name, bucket]) => bucket === 'ok' - ? `${esc(name)}·` - : `${esc(name)}(${HEALTH[bucket].label})·`).join('') + + ? `${esc(name)}·` + : `${esc(name)}(${HEALTH[bucket].label})·`).join('') + `
`; return panelHtml('主站影响', right, `${rows}
${foot}`, false); } @@ -472,25 +493,25 @@ function timelineHtml(vm) { if (nowMinutes > toMin(nodes[nodes.length - 1].t)) { nowX = X(nodes.length - 1); } } let s = `` + - `` + - `` + + `` + + `` + ``; nodes.forEach((n, i) => { const nx = X(i), up = i % 2 === 0; const ty = up ? y - 26 : y + 34, ly = up ? y - 12 : y + 20; - const color = n.state === 'done' ? '#34d399' : n.state === 'fail' ? '#f87171' : '#54637e'; + const color = n.state === 'done' ? 'var(--mint)' : n.state === 'fail' ? 'var(--rd)' : 'var(--dim)'; const anchor = i === 0 ? 'start' : i === nodes.length - 1 ? 'end' : 'middle'; - s += ``; - if (n.state === 'fail') s += `×`; - else s += ``; - s += `${esc(n.t)}` + - `${esc(n.label)}`; + s += ``; + if (n.state === 'fail') s += `×`; + else s += ``; + s += `${esc(n.t)}` + + `${esc(n.label)}`; }); - s += `` + - `` + - `NOW`; + s += `` + + `` + + `NOW`; const range = nodes.length ? `${nodes[0].t} → ${nodes[nodes.length - 1].t}` : ''; - const right = `现在 ${p2(now.getHours())}:${p2(now.getMinutes())} · ${range} · SCHEDULER.PY`; + const right = `现在 ${p2(now.getHours())}:${p2(now.getMinutes())} · ${range} · SCHEDULER.PY`; return panelHtml('今晚时间线', right, s, false); } function incidentsHtml(vm) { @@ -498,11 +519,11 @@ function incidentsHtml(vm) { const cards = vm.incidents.map((it) => { const tag = it.sev === 'fail' ? tagHtml('rd', esc(it.tag)) : it.sev === 'slow' ? tagHtml('amb', esc(it.tag)) : tagHtml('dashed', esc(it.tag)); const meta = it.cd - ? `${it.meta} · 下次重试 --m--s` + ? `${it.meta} · 下次重试 --m--s` : it.meta; return `
${tag}` + `
` + - `
${esc(it.title)}
` + + `
${esc(it.title)}
` + `
${meta}
` + `
` + `` + @@ -583,7 +604,7 @@ function sourceCardHtml(card) { const ringStyle = card.bucket === 'fail' ? 'border-color:rgba(248,113,113,.35)' : card.bucket === 'degrade' ? 'border-color:rgba(251,191,36,.25)' : card.bucket === 'slow' ? 'border-color:rgba(251,191,36,.3)' : - card.bucket === 'off' ? 'border-style:dashed;border-color:#243152' : ''; + card.bucket === 'off' ? 'border-style:dashed;border-color:var(--line2)' : ''; const ledSt = HEALTH[card.bucket].led; // source_catalog.py:credential_key 为空的来源(eastmoney/tencent 等公开 // 接口)真实语义是"无需凭证"而非"已配置",两者都体现为 configured:true, @@ -603,28 +624,29 @@ function sourceCardHtml(card) { `` + `` + (card.bucket === 'off' - ? `` + ? `下方填入凭证即可启用` : ``) + ``; const probeCell = ui.probeResult - ? `${ui.probeResult.t} ${ui.probeResult.ok ? `✓ ${ui.probeResult.ms}ms` : '× TIMEOUT'}` - : `${esc(card.lastProbe)}`; + ? `${ui.probeResult.t} ${ui.probeResult.ok ? `✓ ${ui.probeResult.ms}ms` : '× TIMEOUT'}` + : `${esc(card.lastProbe)}`; h += `
` + - `接口 ${card.usedCount}/${card.totalCount} 在用` + + `接口 ${card.usedCount}/${card.totalCount} 在用` + `|最后探测 ${probeCell}` + `|延迟 ${latHtml(card.latency)}` + - (card.error ? `|最近错误 ${ellipHtml(plainError(card.error, 26), '#f87171', '11px', card.error)}` : (card.bucket !== 'off' ? `|无最近错误` : '')) + + (card.error ? `|最近错误 ${ellipHtml(plainError(card.error, 26), 'var(--rd)', '11px', card.error)}` : (card.bucket !== 'off' ? `|无最近错误` : '')) + `
`; + h += credBoxHtml(card); if (ui.open && card.bucket !== 'off') { h += `
` + card.groups.map((g) => `
` + `
${esc(g.name)}
` + `` + g.rows.map((it) => `` + - `` + - `` + - `` + + `` + + `` + + `` + `` + - `` + + `` + ``).join('') + `
接口用途最近成功延迟状态
${esc(it.api)}${esc(it.use)}${esc(it.ok)}${esc(it.api)}${esc(it.use)}${esc(it.ok)}${latHtml(it.lat)}${pillHtml(it.st, it.st === 'plan' && it.obs === false ? '已配置 · 待观测' : '')}${it.err ? ellipHtml(plainError(it.err, 16), '#f87171', '10px', it.err) : ''}${pillHtml(it.st, it.st === 'plan' && it.obs === false ? '已配置 · 待观测' : '')}${it.err ? ellipHtml(plainError(it.err, 16), 'var(--rd)', '10px', it.err) : ''}
`).join('') + `
`; } @@ -635,19 +657,83 @@ function sourceCardHtml(card) { } return h + ``; } + +/* ---------- 凭证可编辑区(样图 v2「新增可编辑区」,原卡内容一项不删) ---------- + 公开源(东财/腾讯)无凭证可填,保持"无需凭证"徽标即可;Tushare 与 iFinD + 的 Token 存在数据中枢的加密库里,这里只回显掩码,留空表示保持不变。 */ +const CRED_FORMS = { + tushare: { + field: 'tushare_token', + label: 'API TOKEN', + placeholder: '粘贴 Tushare token', + save: '保存凭证', + test: true, + hint: '掩码显示 · 粘贴新值即覆盖 · 留空保持不变', + note: '', + }, + ifind: { + field: 'ifind_refresh_token', + label: 'REFRESH TOKEN', + placeholder: '粘贴 iFinD refresh_token', + save: '保存并启用', + test: false, + hint: '凭据服务端加密保存 · 任何界面不回显明文', + note: '保存并通过最近一次探测后自动纳入数据血缘', + }, +}; + +function credBoxHtml(card) { + const form = CRED_FORMS[card.id]; + if (!form) return ''; + const saved = card.credential.configured; + const probe = card.credential.updated_at ? `上次保存 ${esc(stamp(card.credential.updated_at))}` : ''; + return `
` + + `${esc(form.label)} · 新增可编辑区` + + `
` + + `` + + `` + + (form.test ? `` : '') + + `` + + `
` + + `
${esc(form.hint)}` + + `${esc(form.note) || probe}
` + + `
`; +} + +async function saveCredential(provider) { + const form = CRED_FORMS[provider]; + const box = document.querySelector(`[data-cred-form="${provider}"]`); + if (!form || !box) return; + const input = box.querySelector(`[data-cred-input="${form.field}"]`); + const value = input ? input.value.trim() : ''; + if (!value) { toast('请先粘贴新的凭证,留空表示保持不变', 'err'); return; } + const button = box.querySelector('[data-cred-save]'); + if (button) button.disabled = true; + try { + await api(`/admin/api/credentials/${provider}`, { method: 'POST', body: JSON.stringify({ [form.field]: value }) }); + toast('凭证已保存,已对下一次取数生效', 'ok'); + await refreshQuiet(); + renderSourceCards(); + } catch (err) { + toast(err.message, 'err'); + if (button) button.disabled = false; + } +} + function sourcesHtml() { const vm = buildSourcesVM(); const alerts = []; vm.cards.forEach((c) => { - if (c.bucket === 'fail') alerts.push(`${ledHtml('fail', true)}${esc(c.name)} 故障${c.error ? ' · ' + esc(c.error) : ''}`); - else if (c.bucket === 'degrade' || c.bucket === 'slow') alerts.push(`${ledHtml('slow')}${esc(c.name)} 降级/延迟`); + if (c.bucket === 'fail') alerts.push(`${ledHtml('fail', true)}${esc(c.name)} 故障${c.error ? ' · ' + esc(c.error) : ''}`); + else if (c.bucket === 'degrade' || c.bucket === 'slow') alerts.push(`${ledHtml('slow')}${esc(c.name)} 降级/延迟`); }); return `
` + `
` + - `数据源 ${vm.cards.length} 路 · 接口 ${vm.usedIfaces}/${vm.totalIfaces} 在用` + + `数据源 ${vm.cards.length} 路 · 接口 ${vm.usedIfaces}/${vm.totalIfaces} 在用` + alerts.join('') + `` + - `凭证安全存储 · 探测只读 · 不影响盘中链路` + + `凭证安全存储 · 探测只读 · 不影响盘中链路` + `
` + `
${vm.cards.map(sourceCardHtml).join('')}
` + `
${vm.reserved.map((r) => `
${ledHtml('off')}${esc(r.label)}${tagHtml('dashed', '预留位 · 未接入')}
`).join('')}
` + @@ -771,7 +857,7 @@ function lineageGraphHtml(vm) { vm.edgesSD.forEach(([s, d]) => { adjAdd(adj.sd, s, d); adjAdd(adj.ds, d, s); }); vm.edgesDP.forEach(([d, p]) => { adjAdd(adj.dp, d, p); adjAdd(adj.pd, p, d); }); let h = ``; - const colStyle = 'font-size="10" letter-spacing="2" fill="#54637e"'; + const colStyle = 'font-size="10" letter-spacing="2" style="fill:var(--dim)"'; if (vm.sources.length) h += `来源 · ${vm.sources.length}`; if (vm.datasets.length) h += `数据集 · ${vm.datasets.length}`; if (vm.pages.length) h += `主站页面 · ${vm.pages.length}`; @@ -779,30 +865,30 @@ function lineageGraphHtml(vm) { // 已连接线路全部有可辨识的定向流动(flowline),颜色按数据集真实状态 vm.edgesSD.forEach(([s, d]) => { if (!gpos['s:' + s] || !gpos['d:' + d]) return; - h += ``; + h += ``; }); vm.edgesDP.forEach(([d, p]) => { if (!gpos['d:' + d] || !gpos['p:' + p]) return; - h += ``; + h += ``; }); vm.sources.forEach((s) => { const p = gpos['s:' + s.id]; if (!p) return; h += `` + - `` + - `` + - `${esc(s.name)}`; + `` + + `` + + `${esc(s.name)}`; }); vm.datasets.forEach((d) => { const p = gpos['d:' + d.id]; if (!p) return; h += `` + - `` + - `${esc(d.id)}`; + `` + + `${esc(d.id)}`; }); vm.pages.forEach((pg) => { const p = gpos['p:' + pg.id]; if (!p) return; h += `` + - `` + - `${esc(pg.name)}`; + `` + + `${esc(pg.name)}`; }); h += `
悬停节点 · 追踪上下游链路
`; return h; @@ -832,7 +918,7 @@ function applyLinHover(vm) { const id = g.dataset.nid, kind = g.dataset.gkind; const dimmed = act && !act.has(id); g.setAttribute('opacity', dimmed ? (kind === 'src' ? 0.3 : 0.25) : 1); - if (kind !== 'src') { const t = g.querySelector('text'); if (t) t.setAttribute('fill', act && act.has(id) ? '#e8f1ff' : '#8b9bb4'); } + if (kind !== 'src') { const t = g.querySelector('text'); if (t) t.style.fill = act && act.has(id) ? 'var(--txt-strong)' : 'var(--mut)'; } }); const hint = $('linHint'); if (hint) hint.textContent = linHover ? `FOCUS: ${linHover} · 高亮上下游链路` : '悬停节点 · 追踪上下游链路'; @@ -854,22 +940,22 @@ function renderLinRows(vm) { const cls = (st) => st === 'fail' ? 'row-fail' : st === 'degrade' || st === 'slow' ? 'row-slow' : st === 'off' ? 'row-off' : ''; const stLabel = (r) => (r.st === 'plan' && r.note.includes('待观测')) ? '已配置 · 待观测' : ''; body.innerHTML = rows.map((r) => `` + - `${esc(r.page)}` + - `${esc(r.item)}` + - `${esc(r.dataset)}` + - `${esc(r.via)}` + - `${esc(r.role)}` + - `${esc(r.freq)}` + - `${r.ok ? esc(hm(r.ok)) : '—'}` + - `${r.lat != null ? Number(r.lat).toLocaleString('en-US') + 'ms' : '—'}` + - `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? ellipHtml(plainError(r.note, 20), '#54637e', '10px', r.note) : ''}` + + `${esc(r.page)}` + + `${esc(r.item)}` + + `${esc(r.dataset)}` + + `${esc(r.via)}` + + `${esc(r.role)}` + + `${esc(r.freq)}` + + `${r.ok ? esc(hm(r.ok)) : '—'}` + + `${r.lat != null ? Number(r.lat).toLocaleString('en-US') + 'ms' : '—'}` + + `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? ellipHtml(plainError(r.note, 20), 'var(--dim)', '10px', r.note) : ''}` + ``).join('') || `无匹配记录 · 调整筛选条件`; if (bodyN) bodyN.innerHTML = rows.map((r) => `` + - `${esc(r.page)}${esc(r.item)}` + - `${esc(r.dataset)}` + - `${esc(r.via)}` + - `${esc(r.freq)}${esc(r.role)}` + - `${r.ok ? esc(hm(r.ok)) : '—'}${r.lat != null ? r.lat + 'ms' : '—'}` + + `${esc(r.page)}${esc(r.item)}` + + `${esc(r.dataset)}` + + `${esc(r.via)}` + + `${esc(r.freq)}${esc(r.role)}` + + `${r.ok ? esc(hm(r.ok)) : '—'}${r.lat != null ? r.lat + 'ms' : '—'}` + `${pillHtml(r.st, stLabel(r))}${r.note && !stLabel(r) ? `${esc(plainError(r.note, 20))}` : ''}` + ``).join('') || `无匹配记录 · 调整筛选条件`; } @@ -886,9 +972,9 @@ function lineageHtml() { } const html = `
` + `
` + - `血缘地图` + - `${vm.sources.length} 源 → ${vm.datasets.length} 数据集 → ${vm.pages.length} 页面` + - `${ledHtml('ok')}健康${ledHtml('slow')}慢/降级${ledHtml('fail')}失败${ledHtml('plan')}计划${ledHtml('off')}未配置` + + `血缘地图` + + `${vm.sources.length} 源 → ${vm.datasets.length} 数据集 → ${vm.pages.length} 页面` + + `${ledHtml('ok')}健康${ledHtml('slow')}慢/降级${ledHtml('fail')}失败${ledHtml('plan')}计划${ledHtml('off')}未配置` + `` + impactTag + `
` + @@ -898,7 +984,7 @@ function lineageHtml() { `` + `` + `` + - `` + + `` + `` + `
` + `
主站页面数据项数据集源 : 接口主/备更新频率最近成功延迟状态
` + @@ -908,13 +994,273 @@ function lineageHtml() { return html; } + +/* ===================================================================== + 模型池(供应商制,样图 v2):一个供应商一张卡,卡内填 API 地址与 Key, + 自动拉取模型列表勾选纳入;拉取失败退回卡内「手动录入(兜底)」。 + 数据仍归主站所有,本页经桥接读写。 + ===================================================================== */ +function vendorDraft(key) { + if (!state.vendorDraft[key]) { + state.vendorDraft[key] = { fetching: false, fetched: null, fetchedAt: '', error: '', manual: false, url: '', key: '' }; + } + return state.vendorDraft[key]; +} + +function modelRoleTag(id) { + const data = state.models || {}; + if (id && id === data.primary_model_id) return tagHtml('cy', '主模型'); + if (id && id === data.fallback_model_id) return tagHtml('amb', '备用'); + return tagHtml('dashed', '备用'); +} + +function modelRowHtml(model, vendorLabel) { + const test = state.modelTests[model.id]; + const result = test + ? (test.pending + ? '测试中…' + : `${test.ok ? `已连通 · ${test.ms}ms` : `未连通 · ${esc(test.error || '失败')}`}`) + : '未测试'; + return `
` + + `
${esc(model.name || model.id)} ${modelRoleTag(model.id)}
` + + `
${esc(vendorLabel)} · ${esc(model.model || '')}
` + + `${result}` + + `` + + `
`; +} + +function vendorStateTag(group, draft) { + if (draft.error) return tagHtml('rd', '拉取失败'); + if (draft.fetching) return tagHtml('cy', '拉取中'); + if (group.configured) return tagHtml('mint', '密钥已保存'); + return tagHtml('amb', '待配置'); +} + +function vendorFetchHtml(group, draft) { + if (draft.fetching) { + return `
${ledHtml('rev', true)}正在请求 /models … 通常 1~3 秒,期间不可重复点击
`; + } + if (draft.error) { + return `
${ledHtml('fail')}拉取失败 · ${esc(draft.error)} · 也可改用手动录入模型标识
` + + manualEntryHtml(group); + } + if (!draft.fetched) return group.models.length ? '' : manualEntryHtml(group); + const existing = new Set(group.models.map((model) => String(model.model || ''))); + const pending = draft.fetched.filter((name) => !existing.has(name)); + const picks = draft.fetched.map((name) => { + const added = existing.has(name); + return ``; + }).join(''); + return `
${ledHtml('ok')}已拉取 ${draft.fetched.length} 个模型` + + `${draft.fetchedAt ? ' · ' + esc(draft.fetchedAt) : ''} · 来自 GET /models
` + + `
${picks}
` + + `
` + + `${pending.length ? '勾选后一键添加,免手敲模型标识' : '全部已添加'}
` + + manualEntryHtml(group); +} + +function manualEntryHtml(group) { + const draft = vendorDraft(group.base_url); + if (!draft.error && !draft.manual) { + return `
` + + `拉取失败或供应商不支持 /models 时使用
`; + } + return `
手动录入(兜底)` + + `
模型标识 *` + + `
` + + `
`; +} + +function vendorCardHtml(group) { + const draft = vendorDraft(group.base_url); + const required = '*'; + // 样图:地址与 Key 并排一行,两个操作按钮贴在同一行右端,提示挂在 Key 下面 + const body = `
` + + `
API BASE URL ${required}` + + `
` + + `
API KEY ${required}` + + `` + + `${group.configured ? '掩码显示 · 留空保持不变' : '密钥仅服务端加密保存,任何界面不回显明文'}
` + + `` + + `` + + `` + + `
` + + vendorFetchHtml(group, draft) + + group.models.map((model) => modelRowHtml(model, group.label)).join(''); + const head = `${ledHtml(group.configured ? 'ok' : 'off')}${esc(group.label)}` + + vendorStateTag(group, draft) + + tagHtml('cy', `${group.models.length} 个模型`); + return `
${head}
` + + `
${body}
`; +} + +function modelsHtml() { + const data = state.models; + if (!data) return panelHtml('模型池', '', '

正在从主站读取模型池…

'); + const named = (id) => (data.models.find((model) => model.id === id) || {}).name || ''; + const option = (model, want) => + ``; + const strip = `
` + + `供应商 ${data.groups.length}` + + ` · 模型 ${data.models.length} 个` + + ` · 主模型 ${esc(named(data.primary_model_id) || '未指定')}` + + ` · 辅助模型 ${esc(named(data.fallback_model_id) || '未启用')}` + + `` + + `密钥加密存储 · 保存后全站即时生效 · 自主站系统管理迁入` + + `
`; + const routing = panelHtml('调用编排 ROUTING', + `主模型故障时自动回落辅助模型`, + `
` + + `
主模型 PRIMARY
` + + `
辅助模型 FALLBACK
` + + `` + + `主 / 辅不可指向同一模型
`); + const vendors = data.groups.map(vendorCardHtml).join(''); + const adder = `
` + + `` + + `` + + `` + + `同一供应商(同一 API 地址)可挂多个模型 · 主 / 辅正在引用的模型不可删除,需先在上方切换` + + `
`; + return `
${strip}${routing}${vendors}${adder}
`; +} + +async function loadModels() { + state.models = await api('/admin/api/models'); +} + +async function saveModels(models, extra) { + const payload = Object.assign({ models: models }, extra || {}); + state.models = await api('/admin/api/models/save', { method: 'POST', body: JSON.stringify(payload) }); + renderPage('models'); +} + +/* ===================================================================== + 会员管理 + 邀请码:账号仍归主站,本页只调会员状态/时长/额度; + 注册必须凭一次性邀请码,这里负责生成 / 看状态 / 作废。 + ===================================================================== */ +const MEMBER_DURATIONS = [ + ['', '选择时长'], ['1_month', '1个月'], ['3_months', '3个月'], + ['12_months', '12个月'], ['3_years', '3年'], ['permanent', '永久'], +]; + +function memberExpiryText(u) { + if (u.membership_subscribed) return u.membership_expires_at ? '有效至 ' + ymd(u.membership_expires_at) : '永久有效'; + if (u.membership_status === 'suspended') return '已于 ' + (ymd(u.membership_expires_at) || '—') + ' 停用'; + if (u.membership_status === 'active' && u.membership_expires_at) return '已于 ' + ymd(u.membership_expires_at) + ' 到期'; + return '尚未开通'; +} + +function memberStatusValue(u) { + if (u.role === 'admin') return 'active'; + return u.membership_status || 'inactive'; +} + +function memberIdentityHtml(u) { + const parts = []; + if (u.role === 'admin') parts.push(tagHtml('cy', '管理员')); + if (u.membership_subscribed) parts.push(tagHtml('mint', '会员有效')); + else if (u.membership_status === 'suspended') parts.push(tagHtml('rd', '会员已停用')); + else parts.push(tagHtml('dashed', '普通用户')); + return parts.join(''); +} + +function membersHtml() { + const data = state.members; + const invites = state.invites; + if (!data || !invites) return panelHtml('会员管理', '', '

正在从主站读取会员与邀请码…

'); + const users = data.users || []; + const memberCount = users.filter((u) => u.membership_subscribed).length; + const usage = users.reduce((sum, u) => sum + (Number(u.used_today) || 0), 0); + const quota = (data.membership || {}).member_daily_limit || 50; + const strip = `
` + + `注册用户 ${users.length}` + + ` · 会员 ${memberCount}` + + ` · 今日调用 ${usage}` + + `` + + `会员每日调用额度` + + `` + + `
`; + + const rows = users.map((u) => { + const id = String(u.id); + const canEdit = u.role !== 'admin'; + return `` + + `${esc(u.username)}` + + `${memberIdentityHtml(u)}` + + `${esc(String(u.used_today ?? 0))}` + + `` + + `${canEdit + ? `` + : ''}` + + `${esc(memberExpiryText(u))}` + + `${canEdit ? `` : ''}` + + ``; + }).join(''); + const memberPanel = panelHtml('会员管理 MEMBERS', `自主站系统管理迁入`, + `
` + + `` + + `${rows || ''}
用户名身份今日调用会员状态开通 / 续期当前到期操作
暂无注册用户
`, false); + + const codeRows = (invites.codes || []).map((c) => { + // 明文只在"还能用"的时候有意义;用掉或作废后立刻退回掩码并忘掉它。 + if (c.status !== 'unused') delete state.freshCodes[c.code_id]; + const fresh = c.status === 'unused' ? state.freshCodes[c.code_id] : ''; + const tone = c.status === 'unused' ? tagHtml('mint', ledHtml('ok') + ' 未使用') + : (c.status === 'used' ? tagHtml('dashed', '已使用') : tagHtml('rd', '作废')); + const use = c.status === 'used' + ? `${esc(stamp(c.used_at))} · ${esc(c.used_by_username || '未知')} 注册成功` + : (c.status === 'revoked' ? `${esc(stamp(c.revoked_at))} 管理员作废` : '—'); + return `` + + `${esc(fresh || c.code_masked)}` + + `${tone}` + + `${esc(stamp(c.created_at) || '—')}` + + `${use}` + + `` + + (c.status === 'unused' + ? `` + + `` + : '') + + ``; + }).join(''); + const counts = [1, 2, 3, 5, 10].map((n) => ``).join(''); + const invitePanel = panelHtml('一次性邀请码 INVITE CODES', + `生成` + + `` + + ``, + `
${ledHtml('slow')}每个邀请码仅可成功注册一次 · 注册必须提交有效邀请码 · 并发重复使用只会成功一次,其余请求立即失效
` + + `
` + + `` + + `${codeRows || ''}
邀请码状态生成时间使用情况操作
还没有邀请码
` + + `
邀请码仅掩码显示 · 完整码仅在生成瞬间与「复制」动作中可得 · 已使用 / 已作废不可恢复
`, false); + return `${strip}
${memberPanel}${invitePanel}
`; +} + +async function loadMembers() { + const [members, invites] = await Promise.all([api('/admin/api/members'), api('/admin/api/invites')]); + state.members = members; + state.invites = invites; +} + /* ===================================================================== 页面路由 / 顶栏 / EVENT TAPE / 抽屉 / 危险操作弹层 ===================================================================== */ const mainEl = $('mainEl'); function pageFromHash() { const h = location.hash.replace('#', ''); - return ['overview', 'sources', 'lineage'].includes(h) ? h : 'overview'; + return ['overview', 'sources', 'models', 'members', 'lineage'].includes(h) ? h : 'overview'; } function renderPage(p) { state.page = p; @@ -922,7 +1268,14 @@ function renderPage(p) { document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p)); if (p === 'overview') { mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); } else if (p === 'sources') { mainEl.innerHTML = sourcesHtml(); paintSparks(); } - else { + else if (p === 'models') { + mainEl.innerHTML = modelsHtml(); + restoreVendorDrafts(); + if (!state.models) loadModels().then(() => { if (state.page === 'models') renderPage('models'); }).catch((err) => toast(err.message, 'err')); + } else if (p === 'members') { + mainEl.innerHTML = membersHtml(); + if (!state.members) loadMembers().then(() => { if (state.page === 'members') renderPage('members'); }).catch((err) => toast(err.message, 'err')); + } else { mainEl.innerHTML = lineageHtml(); const vm = buildLineageVM(); window.__linVM = vm; @@ -968,16 +1321,286 @@ function updatePhaseTag() { $('phaseTag').innerHTML = `${ledHtml('rev', true)} ${esc(ov.session_phase || '')}`; } + +/* ---------- 模型池 / 会员页的事件(沿用 mainEl 的委托,不另加监听) ---------- */ +function handleModelClick(e) { + const fetchBtn = e.target.closest('[data-vend-fetch]'); + if (fetchBtn) { fetchVendorModels(fetchBtn.dataset.vendFetch); return true; } + const saveVendor = e.target.closest('[data-vend-save]'); + if (saveVendor) { saveVendorProfile(saveVendor.dataset.vendSave); return true; } + const manual = e.target.closest('[data-vend-manual]'); + if (manual) { vendorDraft(manual.dataset.vendManual).manual = true; renderPage('models'); return true; } + const manualAdd = e.target.closest('[data-vend-manual-add]'); + if (manualAdd) { addManualModel(manualAdd.dataset.vendManualAdd); return true; } + const add = e.target.closest('[data-vend-add]'); + if (add) { addPickedModels(add.dataset.vendAdd); return true; } + const test = e.target.closest('[data-model-test]'); + if (test) { testModel(test.dataset.modelTest); return true; } + const remove = e.target.closest('[data-model-remove]'); + if (remove) { removeModel(remove.dataset.modelRemove); return true; } + if (e.target.closest('#saveRoles')) { saveRoles(); return true; } + if (e.target.closest('#addVendor')) { addVendor(); return true; } + return false; +} + +function vendorInputs(baseUrl) { + const draft = vendorDraft(baseUrl); + const urlEl = document.querySelector(`[data-vend-url="${CSS.escape(baseUrl)}"]`); + const keyEl = document.querySelector(`[data-vend-key="${CSS.escape(baseUrl)}"]`); + draft.url = (urlEl ? urlEl.value : draft.url || baseUrl).trim(); + draft.key = (keyEl ? keyEl.value : draft.key).trim(); + return { url: draft.url || baseUrl, key: draft.key }; +} + +function restoreVendorDrafts() { + Object.keys(state.vendorDraft).forEach((baseUrl) => { + const draft = state.vendorDraft[baseUrl]; + const urlEl = document.querySelector(`[data-vend-url="${CSS.escape(baseUrl)}"]`); + const keyEl = document.querySelector(`[data-vend-key="${CSS.escape(baseUrl)}"]`); + if (urlEl && draft.url) urlEl.value = draft.url; + if (keyEl && draft.key) keyEl.value = draft.key; + }); +} + +function vendorGroup(baseUrl) { + return (state.models.groups || []).find((group) => group.base_url === baseUrl) || { models: [], configured: false }; +} + +async function fetchVendorModels(baseUrl) { + const { url, key } = vendorInputs(baseUrl); + const draft = vendorDraft(baseUrl); + if (!url) { toast('请先填写 API BASE URL', 'err'); return; } + if (!key && !vendorGroup(baseUrl).configured) { toast('请先填写 API Key,再拉取模型列表', 'err'); return; } + draft.fetching = true; + draft.error = ''; + renderPage('models'); + try { + const result = await api('/admin/api/models/fetch', { method: 'POST', body: JSON.stringify({ base_url: url, api_key: key }) }); + draft.fetched = result.models || []; + draft.fetchedAt = `今天 ${p2(new Date().getHours())}:${p2(new Date().getMinutes())}`; + if (!draft.fetched.length) draft.error = '供应商未返回可用模型'; + } catch (err) { + draft.fetched = null; + draft.error = err.message; + } + draft.fetching = false; + renderPage('models'); +} + +function nextModelId(model) { + // 主站要求模型 ID 匹配 [A-Za-z0-9_-]{3,80},这里把模型名压成合法且唯一的 ID。 + const used = new Set(state.models.models.map((m) => m.id)); + let base = model.replace(/[^A-Za-z0-9]+/g, '-').toLowerCase().replace(/^-+|-+$/g, '').slice(0, 70); + if (base.length < 3) base = `model-${base}`.slice(0, 70); + let id = base; + let i = 2; + while (used.has(id)) { id = `${base}-${i}`; i += 1; } + return id; +} + +function rebasedModels(baseUrl, url, key) { + // 改地址等于整组搬迁;Key 留空表示沿用主站已存的那把。 + return state.models.models.map((model) => { + if (model.base_url !== baseUrl) return Object.assign({}, model); + const moved = Object.assign({}, model, { base_url: url }); + if (key) moved.api_key = key; + return moved; + }); +} + +async function saveVendorProfile(baseUrl) { + const { url, key } = vendorInputs(baseUrl); + if (!url) { toast('请先填写 API BASE URL', 'err'); return; } + const group = vendorGroup(baseUrl); + if (!group.models.length) { + if (!key) { toast('新供应商需要先填写 API Key,再拉取或手动录入模型', 'err'); return; } + toast('该供应商还没有模型,请先拉取或手动录入', 'err'); + return; + } + try { + await saveModels(rebasedModels(baseUrl, url, key)); + vendorDraft(baseUrl).key = ''; + toast('供应商已保存', 'ok'); + } catch (err) { toast(err.message, 'err'); } +} + +async function addModels(baseUrl, names) { + const { url, key } = vendorInputs(baseUrl); + if (!key && !vendorGroup(baseUrl).configured) { toast('新供应商需要先填写 API Key', 'err'); return; } + const models = rebasedModels(baseUrl, url, key); + const existingNames = new Set(models.map((model) => String(model.name || ''))); + const added = []; + names.forEach((name) => { + // 主站要求模型名称全局唯一,同名模型接在供应商域名后区分。 + let label = name; + if (existingNames.has(label)) label = `${name} @ ${url.split('//').pop().split('/')[0]}`.slice(0, 50); + if (existingNames.has(label)) return; + existingNames.add(label); + const entry = { id: nextModelId(name), name: label, model: name, base_url: url }; + if (key) entry.api_key = key; + models.push(entry); + added.push(entry); + }); + if (!added.length) { toast('这些模型已在池中', 'err'); return; } + const draft = vendorDraft(baseUrl); + draft.fetched = null; + draft.manual = false; + draft.key = ''; + const extra = {}; + // 池子非空时主站强制要求主模型在池内,首个模型自动兼任主模型。 + if (!state.models.primary_model_id) extra.primary_model_id = added[0].id; + await saveModels(models, extra); + toast(`已加入 ${added.length} 个模型`, 'ok'); +} + +function addPickedModels(baseUrl) { + const picks = Array.from(document.querySelectorAll(`[data-vend-pick="${CSS.escape(baseUrl)}"]`)) + .filter((box) => box.checked && !box.disabled).map((box) => box.value); + if (!picks.length) { toast('请先勾选要纳入的模型', 'err'); return; } + addModels(baseUrl, picks).catch((err) => toast(err.message, 'err')); +} + +function addManualModel(baseUrl) { + const input = document.querySelector(`[data-vend-manual-input="${CSS.escape(baseUrl)}"]`); + const name = input ? input.value.trim() : ''; + if (!name) { toast('请填写模型标识', 'err'); return; } + addModels(baseUrl, [name]).catch((err) => toast(err.message, 'err')); +} + +async function testModel(modelId) { + state.modelTests[modelId] = { pending: true }; + renderPage('models'); + try { + const response = await api('/admin/api/models/test', { method: 'POST', body: JSON.stringify({ model_id: modelId }) }); + const detail = response.result || {}; + state.modelTests[modelId] = { + ok: Boolean(detail.ok), + ms: detail.latency_ms || 0, + error: plainError(String(detail.error || '失败'), 18), + }; + toast(detail.ok ? '模型连通' : '模型不可用:' + (detail.error || '未知错误'), detail.ok ? 'ok' : 'err'); + } catch (err) { + state.modelTests[modelId] = { ok: false, ms: 0, error: plainError(err.message, 18) }; + toast(err.message, 'err'); + } + renderPage('models'); +} + +async function removeModel(modelId) { + const data = state.models; + if (modelId === data.primary_model_id || modelId === data.fallback_model_id) { + toast('该模型正被主/辅分工引用,请先在「调用编排」里换掉再删除', 'err'); + return; + } + if (!window.confirm('确定从模型池移除该模型?')) return; + try { + await saveModels(data.models.filter((model) => model.id !== modelId)); + toast('模型已移除', 'ok'); + } catch (err) { toast(err.message, 'err'); } +} + +async function saveRoles() { + const primary = $('primaryModel').value; + const fallback = $('fallbackModel').value; + if (primary && fallback && primary === fallback) { toast('主模型与辅助模型不能相同', 'err'); return; } + try { + await saveModels(state.models.models, { primary_model_id: primary, fallback_model_id: fallback }); + toast('模型分工已保存', 'ok'); + } catch (err) { toast(err.message, 'err'); } +} + +function addVendor() { + const url = ($('newVendorUrl').value || $('newVendorPreset').value).trim(); + if (!url) { toast('请先选择或填写供应商接口地址', 'err'); return; } + if (state.models.groups.some((group) => group.base_url === url)) { toast('该供应商已在列表中', 'err'); return; } + const preset = state.models.vendors.find((vendor) => vendor.base_url === url); + state.models.groups.push({ + base_url: url, + label: (preset || {}).label || url.split('//').pop().split('/')[0], + configured: false, key_last4: '', models: [], + }); + renderPage('models'); +} + +function handleMemberClick(e) { + const save = e.target.closest('[data-member-save]'); + if (save) { saveMember(save.dataset.memberSave); return true; } + if (e.target.closest('#saveQuota')) { saveQuota(); return true; } + if (e.target.closest('#createInvites')) { createInvites(); return true; } + const copy = e.target.closest('[data-invite-copy]'); + if (copy) { copyInvite(copy.dataset.inviteCopy); return true; } + const revoke = e.target.closest('[data-invite-revoke]'); + if (revoke) { revokeInvite(revoke.dataset.inviteRevoke); return true; } + return false; +} + +async function saveMember(userId) { + const status = document.querySelector(`[data-member-status="${CSS.escape(userId)}"]`); + const duration = document.querySelector(`[data-member-duration="${CSS.escape(userId)}"]`); + const payload = { user_id: Number(userId), status: status ? status.value : 'inactive' }; + if (payload.status === 'active') { + payload.duration = duration ? duration.value : ''; + if (!payload.duration) { toast('开通或续期需要先选择时长', 'err'); return; } + } + try { + state.members = await api('/admin/api/members/save', { method: 'POST', body: JSON.stringify(payload) }); + renderPage('members'); + toast('会员设置已保存', 'ok'); + } catch (err) { toast(err.message, 'err'); } +} + +async function saveQuota() { + const value = Number($('memberQuota').value || 0); + if (!Number.isFinite(value) || value < 1) { toast('每日调用额度至少为 1', 'err'); return; } + try { + state.members = await api('/admin/api/members/quota', { method: 'POST', body: JSON.stringify({ member_daily_limit: value }) }); + renderPage('members'); + toast('会员调用额度已保存', 'ok'); + } catch (err) { toast(err.message, 'err'); } +} + +async function createInvites() { + const count = Number($('inviteCount').value || 1); + try { + const result = await api('/admin/api/invites/create', { method: 'POST', body: JSON.stringify({ count: count }) }); + // 完整邀请码只在本次响应里出现,之后列表只给掩码,所以先缓存在内存里供复制。 + (result.created || []).forEach((item) => { state.freshCodes[item.code_id] = item.code; }); + state.invites = { summary: result.summary, codes: result.codes }; + renderPage('members'); + toast(`已生成 ${(result.created || []).length} 个邀请码,请立即复制`, 'ok'); + } catch (err) { toast(err.message, 'err'); } +} + +async function copyInvite(codeId) { + const code = state.freshCodes[codeId]; + if (!code) { toast('完整邀请码仅在生成后当次可见,请重新生成', 'err'); return; } + try { + await navigator.clipboard.writeText(code); + toast('邀请码已复制:' + code, 'ok'); + } catch { + toast('复制失败,请手动记录:' + code, 'err'); + } +} + +async function revokeInvite(codeId) { + if (!window.confirm('作废后该邀请码将无法用于注册,确定继续?')) return; + try { + state.invites = await api('/admin/api/invites/revoke', { method: 'POST', body: JSON.stringify({ code_id: codeId }) }); + renderPage('members'); + toast('邀请码已作废', 'ok'); + } catch (err) { toast(err.message, 'err'); } +} + mainEl.addEventListener('click', (e) => { const pb = e.target.closest('[data-probe]'); if (pb) { doProbe(pb.dataset.probe); return; } const tg = e.target.closest('[data-toggle]'); if (tg) { const ui = srcUiState[tg.dataset.toggle]; if (ui) { ui.open = !ui.open; renderSourceCards(); } return; } - const ch = e.target.closest('[data-cred-hint]'); - if (ch) { - toast('iFinD 凭证需在部署侧配置(DATAHUB_IFIND_* 环境变量或 /v1/credentials/ifind),后台暂未开放在线写入', 'err'); - return; - } + const cs = e.target.closest('[data-cred-save]'); + if (cs) { saveCredential(cs.dataset.credSave); return; } + const ct = e.target.closest('[data-cred-test]'); + if (ct) { doProbe(ct.dataset.credTest); return; } + if (handleModelClick(e) || handleMemberClick(e)) return; const goto = e.target.closest('[data-goto-lineage]'); if (goto) { linFilter.q = ''; location.hash = 'lineage'; return; } const inc = e.target.closest('[data-incident-action]'); @@ -991,7 +1614,9 @@ mainEl.addEventListener('click', (e) => { return; } }); -mainEl.addEventListener('change', (e) => { if (e.target.id === 'linSel') { linFilter.src = e.target.value.split('· ')[1] || '全部'; renderLinRows(window.__linVM); } }); +mainEl.addEventListener('change', (e) => { + if (e.target.id === 'newVendorPreset' && e.target.value) { $('newVendorUrl').value = e.target.value; return; } + if (e.target.id === 'linSel') { linFilter.src = e.target.value.split('· ')[1] || '全部'; renderLinRows(window.__linVM); } }); mainEl.addEventListener('input', (e) => { if (e.target.id === 'linQ') { linFilter.q = e.target.value; renderLinRows(window.__linVM); } }); mainEl.addEventListener('mouseover', (e) => { const g = e.target.closest('g[data-nid]'); if (g) setLinHover(g.dataset.nid, window.__linVM); }); @@ -1025,7 +1650,7 @@ function renderTape(freshIds) { const items = tapeBuf.slice(-14); const html = items.length ? items.map((e) => tapeEventHtml(e, freshIds && freshIds.has(e.sortId))).join('') - : `暂无真实事件 · 等待首次调用/任务///`; + : `暂无真实事件 · 等待首次调用/任务///`; $('tapeA').innerHTML = html; $('tapeB').innerHTML = html; } @@ -1098,14 +1723,14 @@ function renderDrawerJobs() {
调度任务定义
- ${jobs.map((j) => ` + ${jobs.map((j) => ``).join('')}
任务时刻操作
${esc(j.id)} · ${esc(j.title)}${esc(j.at)}
${esc(j.id)} · ${esc(j.title)}${esc(j.at)}
最近运行(最新 20 条)
- ${runs.slice(0, 20).map((r) => ``).join('') || ''} + ${runs.slice(0, 20).map((r) => ``).join('') || ''}
ID任务状态开始结束错误
${r.id}${esc(r.job_id)}${pillHtml(bucketOf(r.state))}${esc(r.started_at || '')}${esc(r.finished_at || '')}${esc(r.error || '')}
暂无运行记录
${r.id}${esc(r.job_id)}${pillHtml(bucketOf(r.state))}${esc(r.started_at || '')}${esc(r.finished_at || '')}${esc(r.error || '')}
暂无运行记录
`; $('drawerBody').querySelectorAll('[data-run]').forEach((btn) => { @@ -1122,7 +1747,7 @@ function renderDrawerJobs() { function renderDrawerRelease() { const date = drawerCtx.date || today(); $('drawerBody').innerHTML = ` -
+
`; @@ -1137,19 +1762,19 @@ async function loadRelease() { $('relBody').innerHTML = `
发布映射
- ${data.publications.map((row) => ` + ${data.publications.map((row) => ``).join('') || ''}
数据集活跃批次上一批次状态发布时间操作
${esc(row.dataset)}${esc(row.active_batch)}${esc(row.prev_batch || '')}${pillHtml(bucketOf(row.state))}${esc(row.published_at || '')}
${esc(row.dataset)}${esc(row.active_batch)}${esc(row.prev_batch || '')}${pillHtml(bucketOf(row.state))}${esc(row.published_at || '')} ${row.prev_batch ? `` : '—'}
当日暂无发布记录
批次
- ${data.batches.map((row) => ``).join('') || ''} + ${data.batches.map((row) => ``).join('') || ''}
batch_id数据集状态行数错误
${esc(row.batch_id)}${esc(row.dataset)}${pillHtml(bucketOf(row.state))}${row.rows_out ?? ''}${esc(row.error || '')}
当日暂无批次
${esc(row.batch_id)}${esc(row.dataset)}${pillHtml(bucketOf(row.state))}${row.rows_out ?? ''}${esc(row.error || '')}
当日暂无批次
`; $('relBody').querySelectorAll('[data-rollback]').forEach((btn) => btn.addEventListener('click', () => openDangerModal('rollback', { dataset: btn.dataset.rollback, date }))); } function renderDrawerAudit() { const items = (RAW.audit && RAW.audit.items) || []; $('drawerBody').innerHTML = ` - ${items.map((row) => ``).join('') || ''} + ${items.map((row) => ``).join('') || ''}
时间操作者动作对象详情
${esc(row.created_at)}${esc(row.actor)}${esc(row.action)}${esc(row.target || '')}${esc(row.detail || '')}
暂无审计记录
${esc(row.created_at)}${esc(row.actor)}${esc(row.action)}${esc(row.target || '')}${esc(row.detail || '')}
暂无审计记录
`; } @@ -1267,7 +1892,7 @@ function updateDynamics() { const s = Math.max(0, Math.floor((now - ms) / 1000)); const txt = fmtAgeSec(s); if (el.textContent !== txt) el.textContent = txt; - const color = s > 30 ? '#fbbf24' : '#8b9bb4'; + const color = s > 30 ? 'var(--amb)' : 'var(--mut)'; if (el.style.color !== color) el.style.color = color; }); document.querySelectorAll('[data-cd-epoch]').forEach((el) => { @@ -1370,8 +1995,8 @@ function pulseSourcesOnNewCalls() { ring.setAttribute('cy', circle.getAttribute('cy')); ring.setAttribute('r', '5'); ring.setAttribute('fill', 'none'); - ring.setAttribute('stroke', '#22d3ee'); ring.setAttribute('stroke-width', '1.2'); + ring.style.stroke = 'var(--cy)'; ring.setAttribute('class', 'node-ping'); g.appendChild(ring); setTimeout(() => ring.remove(), 1700); diff --git a/xiaobai-datahub/admin/index.html b/xiaobai-datahub/admin/index.html index 1c9e5a7..c583293 100644 --- a/xiaobai-datahub/admin/index.html +++ b/xiaobai-datahub/admin/index.html @@ -1,5 +1,5 @@ - + @@ -9,29 +9,16 @@
- - - -
小白复盘 · 数据中枢DATA-HUB
+
小白复盘 · 数据中枢DATA-HUB
@@ -62,6 +51,7 @@ |:: + @@ -96,8 +86,8 @@