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 @@ - + +