Compare commits

..
Author SHA1 Message Date
总工 44d6de3e2b Merge commit 'fbcab30' into agent/agent/f3cda1e338e1 2026-09-16 15:29:04 +08:00
施工员andmultica-agent a4dbe2bcf8 fix(HEL-564): 数据中枢入口固定内网 IP,不再按主机名推导
- 主站桌面端/移动端「数据中枢」入口固定 http://192.168.200.11:8766/admin/
  (XIAOBAI_DATAHUB_URL 仍可覆盖):域名只反代 8765,推导出的 <域名>:8766 打不开,
  同时避免数据中枢被外网摸到
- 中枢 _review_url 不再从 Host 头推导,统一取 REVIEW_PUBLIC_URL,
  默认 http://192.168.200.11:8765;compose 与 .env.example 示例值同步
- 补回归测试:Host 为域名且未配 REVIEW_PUBLIC_URL 时登录链接仍为固定内网地址
- 文档写明 Cookie 按门牌区分的边界与部署说明

Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 15:23:45 +08:00
总工andmultica-agent fbcab3064c fix(HEL-560): verify tools provide a non-mutating --help path
Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 12:00:55 +08:00
施工员andmultica-agent b402b3b2a8 fix(HEL-560): 问天冻结区不再被陈旧样式清理波及
上一提交为过 CSS 门禁删掉了 heaven/foundation.css 里一条已无人引用的 .model.on,
但问天是冻结区,不该为了门禁变绿去动它。改为把冻结样式表从"每条选择器都要有
运行时消费者"这一项里排除,文件本身恢复原样;其余门禁照常覆盖问天。

Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 11:47:13 +08:00
施工员andmultica-agent 3eaa36a8d5 feat(HEL-560): 数据中枢接管数据源/模型池/会员,注册改一次性邀请码
主站
- 新增 m0006 invite_codes 迁移;注册强制邀请码(首个管理员除外),消码与建号
  同一事务,并发提交只有一个能成功
- 新增 /api/hub-admin/* 服务端点(共享 HUB_ADMIN_TOKEN,先于鉴权校验),供数据
  中枢桥接读写会话/密码/模型池/会员/邀请码,并提供供应商模型列表拉取
- 前端:注册表单加邀请码(桌面 login、index.html、移动端);「系统管理」改为
  「数据中枢」入口指向 8766,原模型池与会员管理分区移除,仅留「行情管理」;
  随之清理陈旧 CSS

数据中枢
- 取消独立账号:删除 hub_admin/hub_sessions 与登录、改密、锁定逻辑,改为校验
  主站 xiaobai_session,仅管理员可进,CSRF 由会话派生,危险操作二次确认走主站
- 控制台新增数据源凭证可编辑区(原有内容一项不删)、供应商制模型池(自动拉取
  /models,失败退回卡内手动录入)、会员管理与邀请码页
- 日夜双主题:颜色收敛为同名 token 换值,SVG 改用 inline style 以吃到变量

自测
- 主站 verify_baseline 通过(498 项);数据中枢 235 项通过
- tools/verify_datahub_console.py 端到端跑通两服务真实对话;
  tools/verify_datahub_console_ui.py 浏览器跑通门禁/凭证/模型池/会员/主题/1030 窄屏

Co-authored-by: multica-agent <github@multica.ai>
2026-09-16 11:44:09 +08:00
69 changed files with 3712 additions and 1566 deletions
+4
View File
@@ -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=
+19
View File
@@ -274,3 +274,22 @@ 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` 是浏览器可达的主站地址,
留空则用固定内网地址 `http://192.168.200.11:8765`
- 主站右上角「数据中枢」入口固定指向 `http://192.168.200.11:8766/admin/`,不按浏览器
当前主机名推导(域名 `xiaobaifupan.com` 只反代 8765,推导出的 `<域名>:8766` 打不开),
外网也就摸不到这个页面。
- 会话 cookie 按"门牌"(域名 / IP)区分:只有用 `http://192.168.200.11:8765` 登录主站,
点入口才免登录直通中枢;用域名登录主站再点入口会落到门禁面板,按提示登录一次即可。
8766 与 8765 同样只在可信内网开放。
+6 -2
View File
@@ -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 配置只用于初始化系统配置。
管理员的配置入口在数据中枢(页面右上角「数据中枢」按钮,固定指向 `http://192.168.200.11:8766/admin/`):数据源凭证、模型池、会员与邀请码都在那里维护,数据仍存在主站同一份 SQLite 里。主站自身只保留「行情管理」面板(Tushare Token、后台刷新开关、手动刷新与补数)。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。
普通用户在「账号设置」中维护个人资料、查看会员状态和修改密码,不配置个人 LLM。有效会员使用平台模型;管理员可开通、续期、停用会员。平台模型受每日调用次数限制,管理员账号始终可用
数据中枢用主站的管理员账号进入,没有独立账号;桥接令牌 `HUB_ADMIN_TOKEN` 需在主站与中枢两侧 `.env` 填成同一个值。详见 [xiaobai-datahub/README.md](xiaobai-datahub/README.md)
普通用户在「账号设置」中维护个人资料、查看会员状态和修改密码,不配置个人 LLM。有效会员使用平台模型;管理员在数据中枢开通、续期、停用会员。平台模型受每日调用次数限制,管理员账号始终可用。
相关文档:
+3
View File
@@ -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,
+2
View File
@@ -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"]
@@ -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",
)
+8 -2
View File
@@ -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)
+1
View File
@@ -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:
+87
View File
@@ -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(
+110 -2
View File
@@ -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:
+1
View File
@@ -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 {
+22
View File
@@ -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():
+9
View File
@@ -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)
+155
View File
@@ -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)
+32
View File
@@ -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(
(
+31
View File
@@ -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,
+5 -2
View File
@@ -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:-http://192.168.200.11:8765}"
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
+4
View File
@@ -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: ""
+25 -24
View File
@@ -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": [
@@ -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,
@@ -716,6 +717,11 @@
"bytes": 4712,
"lines": 106
},
{
"path": "frontend/shared/admin.js",
"bytes": 4500,
"lines": 109
},
{
"path": "backend/data/providers/tushare_helpers.py",
"bytes": 4406,
@@ -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",
+5 -32
View File
@@ -73,6 +73,7 @@
<label class="form-field"><span>账号名</span><input id="authUsername" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>
<label class="form-field"><span>密码</span><input id="authPassword" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>
<label id="authConfirmField" class="form-field" hidden><span>确认密码</span><input id="authPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>
<label id="authInviteField" class="form-field" hidden><span>邀请码</span><input id="authInviteCode" type="text" maxlength="32" autocomplete="off" spellcheck="false" placeholder="XB-XXXX-XXXX-XXXX"></label>
<p id="authError" class="auth-error" hidden></p>
<button id="authSubmitButton" class="button primary" type="submit">登录</button>
</form>
@@ -156,7 +157,8 @@
</div>
<button id="refreshButton" class="button command-button" type="button" title="刷新"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
<button id="syncButton" class="button primary command-button" type="button" title="后台刷新" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
<button id="settingsButton" class="button command-button" type="button" title="系统管理" hidden><i data-lucide="settings-2"></i><span>系统管理</span></button>
<button id="marketAdminButton" class="button command-button" type="button" title="行情管理" hidden><i data-lucide="settings-2"></i><span>行情管理</span></button>
<button id="settingsButton" class="button command-button" type="button" title="数据中枢" hidden><i data-lucide="database"></i><span>数据中枢</span></button>
<div class="account-menu-shell">
<div id="accountRoleBadges" class="account-role-badges" aria-label="账号身份">
<span id="accountAdminBadge" class="account-role-badge admin-role-badge" title="管理员" hidden><i data-lucide="shield-check"></i><span>管理员</span></span>
@@ -592,18 +594,11 @@
<dialog id="adminDialog" class="settings-dialog admin-dialog" aria-labelledby="adminDialogTitle">
<div class="dialog-header">
<div><span class="dialog-eyebrow">管理员</span><h2 id="adminDialogTitle">系统配置</h2></div>
<div><span class="dialog-eyebrow">管理员</span><h2 id="adminDialogTitle">行情管理</h2></div>
<button id="closeAdminDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
</div>
<div id="adminConnectionStatus" class="connection-status" aria-live="polite">正在读取系统状态</div>
<div class="admin-section-picker">
<label for="adminSectionSelect">管理项目</label>
<select id="adminSectionSelect">
<option value="market">行情管理</option>
<option value="models">模型池</option>
<option value="members">会员管理</option>
</select>
</div>
<p class="form-hint">模型池、会员与邀请码已统一在数据中枢管理,点击顶栏「数据中枢」进入。</p>
<div class="admin-panel" data-admin-panel="market">
<form id="systemMarketForm" class="settings-section">
<div class="settings-section-heading"><h3>公共行情</h3><span id="systemDataStatus">待检查</span></div>
@@ -624,28 +619,6 @@
<div class="dialog-actions"><button id="backfillButton" class="button" type="button">开始回补</button></div>
</section>
</div>
<div class="admin-panel" data-admin-panel="models" hidden>
<form id="systemModelsForm" class="settings-section">
<div class="settings-section-heading"><h3>平台模型池</h3><span>会员共享</span></div>
<div class="model-role-selectors">
<label class="form-field"><span>主模型</span><select id="platformPrimaryModelSelect"></select></label>
<label class="form-field"><span>辅助模型</span><select id="platformFallbackModelSelect"><option value="">不启用辅助模型</option></select></label>
</div>
<div id="modelPoolList" class="model-pool-list"></div>
<div class="dialog-actions admin-inline-actions"><button id="addPlatformModel" class="button" type="button"><i data-lucide="plus"></i>添加模型</button><button class="button primary" type="submit">保存模型池</button></div>
</form>
</div>
<div class="admin-panel" data-admin-panel="members" hidden>
<form id="membershipSettingsForm" class="settings-section membership-limit-form">
<div class="settings-section-heading"><h3>会员调用额度</h3><span>每日自动重置</span></div>
<label class="form-field compact-number-field"><span>会员每日智能分析上限</span><input id="memberDailyLimit" type="number" min="1" max="1000" value="50"></label>
<div class="dialog-actions"><button class="button primary" type="submit">保存调用额度</button></div>
</form>
<section class="settings-section">
<div class="settings-section-heading"><h3>会员账号</h3><span>手动开通与续期</span></div>
<div id="adminUsersList" class="admin-users-list"></div>
</section>
</div>
</dialog>
<div id="loadingOverlay" class="loading-overlay" hidden>
+11 -2
View File
@@ -15,6 +15,7 @@
error: "",
username: "",
password: "",
inviteCode: "",
passwordVisible: false,
};
@@ -119,6 +120,7 @@
`<label class="form-field"><span>账号名</span><input id="loginUsername" type="text" minlength="3" maxlength="30" autocomplete="username" placeholder="请输入账号名" value="${escapeHtml(state.username)}" required></label>`,
`<label class="form-field"><span>密码</span><span class="login-password-wrap"><input id="loginPassword" class="${invalid.trim()}" type="${passwordType}" minlength="8" maxlength="128" autocomplete="${registering ? "new-password" : "current-password"}" placeholder="请输入密码" value="${escapeHtml(state.password)}" required><button class="login-password-toggle" type="button" data-login-action="toggle-password" aria-pressed="${state.passwordVisible ? "true" : "false"}" aria-label="${state.passwordVisible ? "隐藏密码" : "显示密码"}">${passwordToggle}</button></span></label>`,
`<label class="form-field" id="loginConfirmField"${registering ? "" : " hidden"}><span>确认密码</span><input id="loginPasswordConfirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"${registering ? " required" : ""}></label>`,
`<label class="form-field" id="loginInviteField"${registering ? "" : " hidden"}><span>邀请码</span><input id="loginInviteCode" type="text" maxlength="32" autocomplete="off" spellcheck="false" placeholder="XB-XXXX-XXXX-XXXX" value="${escapeHtml(state.inviteCode)}"${registering ? " required" : ""}></label>`,
state.error ? `<p class="login-error">${escapeHtml(state.error)}</p>` : '<p class="login-error" hidden></p>',
`<button class="button primary login-submit" type="submit"${state.loading ? " disabled" : ""}>`,
state.loading ? '<span class="login-spinner" aria-hidden="true"></span>' : "",
@@ -289,9 +291,14 @@
event.preventDefault();
const username = document.querySelector("#loginUsername").value.trim();
const password = document.querySelector("#loginPassword").value;
const registering = state.mode === "register";
const inviteCode = registering
? document.querySelector("#loginInviteCode").value.trim()
: "";
state.username = username;
state.password = password;
if (state.mode === "register" && password !== document.querySelector("#loginPasswordConfirm").value) {
state.inviteCode = inviteCode;
if (registering && password !== document.querySelector("#loginPasswordConfirm").value) {
setError("两次输入的密码不一致。");
render();
return;
@@ -300,7 +307,9 @@
setError("");
render();
try {
await api.request(`/api/auth/${state.mode}`, "POST", { username, password });
await api.request(`/api/auth/${state.mode}`, "POST", registering
? { username, password, invite_code: inviteCode }
: { username, password });
await celebrateLogin();
enterApp();
} catch (error) {
+1 -2
View File
@@ -52,8 +52,7 @@
{ key: "system/profile", label: "账号资料", icon: "user" },
{ key: "system/password", label: "修改密码", icon: "lock" },
{ key: "system/membership", label: "会员状态", icon: "gem" },
{ key: "system/admin", label: "系统设置", icon: "sliders-horizontal", adminOnly: true },
{ key: "system/members", label: "会员管理", icon: "users", adminOnly: true }
{ key: "system/admin", label: "行情管理", icon: "sliders-horizontal", adminOnly: true }
]
}
},
+27 -361
View File
@@ -198,11 +198,7 @@
system: {
account: null,
admin: null,
adminTab: "market",
models: [],
accounts: [],
editingModelId: "",
editingUserId: "",
},
};
@@ -3137,7 +3133,6 @@
"system/password": setupSystemPage,
"system/membership": setupSystemPage,
"system/admin": setupSystemPage,
"system/members": setupSystemPage,
};
const COMPLEX_LOADERS = {
@@ -3160,7 +3155,6 @@
"system/password": loadSystem,
"system/membership": loadSystem,
"system/admin": loadSystem,
"system/members": loadSystem,
};
function isComplexPage(key) {
@@ -4938,7 +4932,7 @@
return '<div class="m-state m-motion-rise-in" data-system-page="forbidden">' +
'<span class="m-state-icon">' + icon("lock", 26) + "</span>" +
"<p>仅管理员可访问</p>" +
"<small>系统设置和会员管理需要管理员权限。</small>" +
"<small>行情管理与数据中枢需要管理员权限。</small>" +
"</div>";
}
@@ -4974,7 +4968,6 @@
state.sort = { key: "", dir: null };
state.sortTable = { cols: null, reapply: null };
state.detail = null;
if (key === "system/admin") state.system.adminTab = state.system.adminTab || "market";
document.getElementById("m-view").classList.add("m-view-feature");
global.MobileRouter.updateHeader({ title: findLabel(key) || key, back: true, actions: "" });
document.getElementById("m-view").innerHTML = complexFrame(key, complexScroll(skeletonHtml(6)));
@@ -4982,7 +4975,7 @@
function loadSystem() {
const key = state.key;
if ((key === "system/admin" || key === "system/members") && !global.MobileSession.isAdmin()) {
if (key === "system/admin" && !global.MobileSession.isAdmin()) {
systemFill(systemForbiddenHtml());
return;
}
@@ -4991,15 +4984,12 @@
return;
}
const seq = nextSeq();
const url = (key === "system/admin" || key === "system/members") ? "/api/admin/settings" : "/api/account/status";
global.MobileAPI.request(url).then(function (payload) {
const url = key === "system/admin" ? "/api/admin/settings" : "/api/account/status";
return global.MobileAPI.request(url).then(function (payload) {
if (seq !== state.seq || state.key !== key) return;
if (key === "system/admin" || key === "system/members") {
if (key === "system/admin") {
state.system.admin = payload || {};
state.system.models = ((payload.llm && payload.llm.models) || []).map(function (item) {
return Object.assign({}, item);
});
renderSystemAdmin(key);
renderSystemAdmin();
} else {
state.system.account = payload || {};
if (key === "system/profile") renderSystemProfile();
@@ -5089,8 +5079,7 @@
{ key: "system/membership", icon: "gem", label: "会员状态", hint: "有效期与智能分析额度" }
];
const adminRows = [
{ key: "system/admin", icon: "sliders-horizontal", label: "系统设置", hint: "行情数据 · 模型池" },
{ key: "system/members", icon: "users", label: "会员管理", hint: "开通 · 续期 · 额度" }
{ key: "system/admin", icon: "sliders-horizontal", label: "行情管理", hint: "数据源状态 · 后台刷新 · 历史回补" }
];
const html =
'<div class="m-sys-home" data-system-page="home">' +
@@ -5104,7 +5093,7 @@
'<h3 class="m-sys-group-title">偏好</h3>' +
'<div class="m-card m-sys-list">' + systemThemeRowHtml() + "</div>" +
(global.MobileSession.isAdmin()
? '<h3 class="m-sys-group-title">管理员专区</h3><div class="m-card m-sys-list">' + adminRows.map(systemRowHtml).join("") + "</div>"
? '<h3 class="m-sys-group-title">管理员专区</h3><div class="m-card m-sys-list">' + adminRows.map(systemRowHtml).join("") + dataHubRowHtml() + "</div>"
: "") +
'<h3 class="m-sys-group-title">其他</h3>' +
'<div class="m-card m-sys-list">' +
@@ -5117,7 +5106,7 @@
'<span class="m-sys-row-body"><strong>退出登录</strong><small>退出后需要重新登录</small></span>' +
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>" +
"</div>" +
'<p class="m-sys-foot">' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "系统设置与会员管理仅管理员可见") + "</p>" +
'<p class="m-sys-foot">' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "行情管理与数据中枢仅管理员可见") + "</p>" +
"</div>";
document.getElementById("m-view").innerHTML = html;
}
@@ -5207,14 +5196,6 @@
systemFill(html);
}
function adminTabHtml() {
const tab = state.system.adminTab === "models" ? "models" : "market";
return '<div class="m-source-tabs" role="tablist" aria-label="系统设置分类">' +
'<button class="m-source-tab' + (tab === "market" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="market">行情管理</button>' +
'<button class="m-source-tab' + (tab === "models" ? " active" : "") + '" type="button" role="tab" data-system-admin-tab="models">模型池</button>' +
"</div>";
}
function statusDot(ok) {
return '<span class="m-sys-dot' + (ok ? " m-sys-dot--ok" : "") + '"></span>';
}
@@ -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 =
'<div class="m-sys-body" data-system-admin-panel="market">' +
'<div class="m-card m-sys-section"><strong>数据源状态</strong>' +
@@ -5263,178 +5238,29 @@
formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) +
'<button class="m-btn-outline" type="button" data-system-backfill>开始回补</button>' +
'<p class="m-sys-hint">回补用于补齐缺失的历史行情,开始前会再次确认;回补期间页面可正常使用。</p></div>' +
'<div class="m-card m-sys-section"><strong>模型池 · 会员 · 邀请码</strong>' +
'<p class="m-sys-hint">这些配置已统一在数据中枢管理。</p>' +
'<button class="m-btn-outline" type="button" data-system-datahub>打开数据中枢</button></div>' +
"</div>";
const models = state.system.models || [];
const modelsHtml =
'<div class="m-sys-body" data-system-admin-panel="models">' +
'<div class="m-card m-sys-section"><strong>模型分工</strong>' +
formFieldHtml("主模型", '<select id="m-sys-primary-model"></select>', false) +
formFieldHtml("辅助模型", '<select id="m-sys-fallback-model"></select>', false) +
'<p class="m-sys-hint">主模型不可用时自动改用辅助模型</p>' +
'<button class="m-btn-primary" type="button" data-system-save-models>保存分工</button></div>' +
'<div class="m-card m-sys-section"><strong>模型池 · ' + models.length + " 个</strong>" +
'<div id="m-sys-model-list">' + renderModelPoolHtml(models) + "</div>" +
'<p class="m-sys-hint">点任意模型卡片进入编辑:改名称、地址、密钥、测试连接或删除</p>' +
'<button class="m-btn-primary" type="button" data-system-add-model>+ 添加模型</button></div>' +
"</div>";
const page = document.querySelector(".m-page");
if (!page) return;
page.innerHTML = adminTabHtml() + '<div class="m-scroll" id="m-scroll">' + (tab === "models" ? modelsHtml : marketHtml) + "</div>";
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);
// HEL-564: 固定内网地址,见 frontend/shared/admin.js 同名函数。
return "http://192.168.200.11:8766/admin/";
}
function renderModelPoolHtml(models) {
if (!models.length) {
return '<div class="m-state"><p>模型池为空,请先添加模型</p></div>';
}
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('<span class="m-sys-badge m-sys-badge--admin">主模型</span>');
if (item.id === llm.fallback_model_id) badges.push('<span class="m-sys-badge">辅助</span>');
badges.push('<span class="m-sys-badge' + (item.configured ? " m-sys-badge--ok" : "") + '">' + (item.configured ? "已配置" : "待配置") + "</span>");
return '<button class="m-sys-model-card" type="button" data-model-id="' + escapeHtml(item.id) + '" data-system-edit-model>' +
"<div><strong>" + escapeHtml(item.name || "未命名模型") + "</strong>" +
'<div class="m-sys-badges">' + badges.join("") + "</div>" +
'<p class="m-sys-hint">' + escapeHtml(hostOfUrl(item.base_url) + " · " + (item.model || "未填写标识")) + "</p></div>" +
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>";
}).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 '<option value="' + escapeHtml(item.id) + '">' + escapeHtml(item.name || item.model || "未命名模型") + "</option>";
}).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 : '<option value="">暂无模型</option>';
fallback.innerHTML = '<option value="">不启用辅助模型</option>' + 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(
'<div class="m-sheet-head"><h2>编辑模型</h2>' +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
'<div class="m-sheet-body" data-model-id="' + escapeHtml(modelId) + '">' +
formFieldHtml("显示名称", '<input data-model-field="name" maxlength="50" value="' + escapeHtml(item.name || "") + '">', true) +
formFieldHtml("API Base URL", '<input data-model-field="base_url" type="url" value="' + escapeHtml(item.base_url || "https://api.openai.com/v1") + '">', true) +
formFieldHtml("模型标识", '<input data-model-field="model" maxlength="100" value="' + escapeHtml(item.model || "") + '">', true) +
formFieldHtml("API Key", '<input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="' + (item.configured ? "留空则保留已保存的 Key" : "输入 API Key") + '">', !item.configured) +
'<div class="m-sys-test-row"><button class="m-btn-outline" type="button" data-system-test-model>测试连接</button>' +
'<span data-model-test-status>未测试</span></div>' +
'<div class="m-sys-sheet-actions">' +
'<button class="m-btn-outline-danger" type="button" data-system-delete-model>删除模型</button>' +
'<button class="m-btn-primary" type="button" data-system-save-model>保存</button>' +
"</div></div>"
);
}
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('<span class="m-sys-badge m-sys-badge--admin">管理员</span>');
if (member) badges.push('<span class="m-sys-badge m-sys-badge--ok">会员</span>');
else badges.push('<span class="m-sys-badge">普通用户</span>');
const expiry = member
? (user.membership_expires_at ? "有效至 " + membershipDateLabel(user.membership_expires_at) : "永久有效")
: user.membership_status === "suspended"
? "会员已停用"
: "尚未开通";
return '<div class="m-sys-user-row" data-admin-user="' + number(user.id) + '">' +
'<div class="m-sys-row-body"><strong>' + escapeHtml(user.username) + "</strong>" +
'<div class="m-sys-badges">' + badges.join("") + "</div>" +
'<p class="m-sys-hint">' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次</p></div>" +
'<button class="m-btn-outline" type="button" data-system-open-member>管理</button></div>';
}).join("") || '<div class="m-state"><p>暂无注册用户</p></div>';
const html =
'<div class="m-sys-body" data-system-page="members">' +
'<div class="m-card m-sys-section"><strong>全局额度</strong>' +
formFieldHtml("会员每日智能分析上限", '<input id="m-sys-member-limit" type="number" min="1" max="1000" value="' + (number(membership.member_daily_limit) || 50) + '">', false) +
'<p class="m-sys-hint">对所有会员生效,每日 0 点自动重置。</p>' +
'<button class="m-btn-primary" type="button" data-system-save-limit>保存额度</button></div>' +
'<div class="m-card m-sys-section"><strong>会员账号 · ' + users.length + " 个</strong>" +
userHtml +
'<p class="m-sys-hint">点「管理」为对应账号开通、续期或停用会员。</p></div>' +
"</div>";
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(
'<div class="m-sheet-head"><h2>管理会员 · ' + escapeHtml(user.username) + "</h2>" +
'<button class="m-sheet-close" type="button" data-sheet-close aria-label="关闭">' + icon("close", 20) + "</button></div>" +
'<div class="m-sheet-body" data-admin-user="' + number(user.id) + '">' +
'<p class="m-sys-lead">当前状态:' + escapeHtml(expiry) + " · 今日已用 " + number(user.used_today) + " 次</p>" +
formFieldHtml("会员状态", '<select data-member-status>' +
'<option value="inactive"' + (user.membership_status === "inactive" ? " selected" : "") + ">未开通</option>" +
'<option value="active"' + (user.membership_status === "active" ? " selected" : "") + ">有效</option>" +
'<option value="suspended"' + (user.membership_status === "suspended" ? " selected" : "") + ">停用</option>" +
"</select>", false) +
formFieldHtml("开通 / 续期时长", '<select data-member-duration><option value="">选择时长</option>' +
'<option value="1_month">1个月</option><option value="3_months">3个月</option>' +
'<option value="12_months">12个月</option><option value="3_years">3年</option>' +
'<option value="permanent">永久</option></select>', false) +
'<p class="m-sys-hint">从当前时间开始顺延;已有会员则叠加续期。</p>' +
'<div class="m-sys-sheet-actions">' +
'<button class="m-btn-outline" type="button" data-sheet-close>取消</button>' +
'<button class="m-btn-primary" type="button" data-system-save-member>应用</button>' +
"</div></div>"
);
function dataHubRowHtml() {
return '<button class="m-sys-row" type="button" data-system-datahub>' +
'<span class="m-sys-row-icon">' + icon("database", 18) + "</span>" +
'<span class="m-sys-row-body"><strong>数据中枢</strong><small>数据源 · 模型池 · 会员与邀请码</small></span>' +
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>";
}
function setFieldError(inputId, message) {
@@ -5571,97 +5397,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 +5434,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 +5561,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 +5568,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]");
+4 -1
View File
@@ -263,6 +263,7 @@
'<label class="m-form-field"><span>账号名</span><input id="m-auth-username" type="text" minlength="3" maxlength="30" autocomplete="username" required></label>',
'<label class="m-form-field"><span>密码</span><input id="m-auth-password" type="password" minlength="8" maxlength="128" autocomplete="current-password" required></label>',
'<label class="m-form-field" id="m-auth-confirm-field" hidden><span>确认密码</span><input id="m-auth-confirm" type="password" minlength="8" maxlength="128" autocomplete="new-password"></label>',
'<label class="m-form-field" id="m-auth-invite-field" hidden><span>邀请码</span><input id="m-auth-invite" type="text" maxlength="32" autocomplete="off" spellcheck="false" placeholder="XB-XXXX-XXXX-XXXX"></label>',
'<p class="m-auth-error" id="m-auth-error" hidden></p>',
'<button class="m-btn-primary" id="m-auth-submit" type="submit"><span class="m-btn-spinner" aria-hidden="true" hidden></span><span class="m-btn-label">登录</span></button>',
"</form>",
@@ -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);
}
+3 -2
View File
@@ -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);
}
+1 -1
View File
@@ -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);
+14 -188
View File
@@ -39,21 +39,28 @@ 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);
// HEL-564: 固定内网地址。域名只反代 8765,按主机名推导会拼出打不开的
// <域名>:8766;数据中枢也不该被外网摸到。
return "http://192.168.200.11: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 +81,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) => `
<article class="model-pool-row" data-model-id="${escapeHtml(item.id)}">
<div class="model-pool-heading"><strong>${escapeHtml(item.name || `模型 ${index + 1}`)}</strong><span>${item.configured ? "已保存密钥" : "待配置"}</span></div>
<div class="model-pool-fields">
<label class="form-field"><span>显示名称 *</span><input data-model-field="name" maxlength="50" value="${escapeHtml(item.name || "")}" required></label>
<label class="form-field"><span>API Base URL *</span><input data-model-field="base_url" type="url" value="${escapeHtml(item.base_url || "https://api.openai.com/v1")}" required></label>
<label class="form-field"><span>模型标识 *</span><input data-model-field="model" maxlength="100" value="${escapeHtml(item.model || "")}" required></label>
<label class="form-field"><span>API Key${item.configured ? "" : " *"}</span><input data-model-field="api_key" type="password" autocomplete="off" maxlength="300" placeholder="${item.configured ? "留空保留已保存的 Key" : "输入 API Key"}" ${item.configured ? "" : "required"}></label>
</div>
<div class="model-test-row"><button class="button" type="button" data-test-model>测试连接</button><span class="model-test-status" aria-live="polite">未测试</span><button class="icon-button model-delete-button" type="button" data-delete-model aria-label="删除模型" title="删除模型"><i data-lucide="trash-2"></i></button></div>
</article>
`).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) => `<option value="${escapeHtml(item.id)}">${escapeHtml(item.name || item.model || "未命名模型")}</option>`).join("");
const primary = document.querySelector("#platformPrimaryModelSelect");
const fallback = document.querySelector("#platformFallbackModelSelect");
primary.innerHTML = models.length ? options : '<option value="">暂无模型</option>';
fallback.innerHTML = `<option value="">不启用辅助模型</option>${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 `<article class="admin-user-row" data-admin-user="${number(user.id)}">
<div class="admin-user-identity"><strong>${escapeHtml(user.username)}</strong><span>${escapeHtml(identityLabels)}</span><small>${escapeHtml(expiry)}</small></div>
<div class="admin-user-usage">今日调用 <b>${number(user.used_today)}</b></div>
<form class="membership-form">
<input type="hidden" name="user_id" value="${number(user.id)}">
<label><span>状态</span><select name="status"><option value="inactive" ${user.membership_status === "inactive" ? "selected" : ""}>未开通</option><option value="active" ${user.membership_status === "active" ? "selected" : ""}>有效</option><option value="suspended" ${user.membership_status === "suspended" ? "selected" : ""}>停用</option></select></label>
<label><span>开通 / 续期时长</span><select name="duration"><option value="">选择时长</option><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></label>
<div class="membership-expiry"><span>当前到期</span><strong>${escapeHtml(expiry)}</strong></div>
<button class="button" type="submit">应用</button>
</form>
</article>`;
}).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 +98,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);
}
-271
View File
@@ -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) {
-58
View File
@@ -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);
-16
View File
@@ -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;
+8 -1
View File
@@ -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;
+8 -4
View File
@@ -632,6 +632,7 @@ test("admin shell opens every primary workspace and global search", async ({ pag
await expect(page.locator("#authGate")).toBeHidden();
await openHeaderCommandMenu(page);
await expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#marketAdminButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeVisible();
await page.keyboard.press("Escape");
await page.locator("#alertButton").click();
@@ -1753,6 +1754,7 @@ test("regular account cannot see admin controls and member features are gated",
await mockApplication(page, session("user", false));
await page.goto("/index.html");
await expect(page.locator("#settingsButton")).toBeHidden();
await expect(page.locator("#marketAdminButton")).toBeHidden();
await expect(page.locator("#syncButton")).toBeHidden();
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
await page.locator('[data-view="screenerView"]').first().click();
@@ -3332,9 +3334,9 @@ test("global dialogs share the stage 18 geometry without changing account or adm
await page.locator("#closeSettingsDialog").click();
await openHeaderCommandMenu(page);
await page.locator("#settingsButton").click();
await page.locator("#marketAdminButton").click();
await expect(page.locator("#adminDialog")).toHaveAttribute("aria-labelledby", "adminDialogTitle");
await expect(page.locator("#adminSectionSelect")).toBeVisible();
await expect(page.locator("#systemMarketForm")).toBeVisible();
const adminBox = await page.locator("#adminDialog").boundingBox();
expect(adminBox.width).toBeLessThanOrEqual(902);
expect(Math.abs(adminBox.x + adminBox.width / 2 - 720)).toBeLessThanOrEqual(2);
@@ -3510,7 +3512,7 @@ test("B-199 screener review and account surfaces fit day night viewports", async
await page.locator("#closeSettingsDialog").click();
await openHeaderCommandMenu(page);
await page.locator("#settingsButton").click();
await page.locator("#marketAdminButton").click();
await expect(page.locator("#adminDialog")).toBeVisible();
await shot("admin-1600-day");
await page.locator("#closeAdminDialog").click();
@@ -3609,6 +3611,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeVisible();
await expect(page.locator("#settingsButton")).toBeVisible();
await expect(page.locator("#marketAdminButton")).toBeVisible();
await expect(page.locator("#accountAdminBadge")).toBeVisible();
await expect(page.locator("#accountVipBadge")).toBeVisible();
await expect(page.locator("#accountButton")).toBeVisible();
@@ -3621,7 +3624,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
expect(geometry.nameFits, `${viewport.width} account name truncated`).toBe(true);
await page.locator("#refreshButton").click();
await expect(page.locator("#loadingOverlay")).toBeHidden();
await page.locator("#settingsButton").click();
await page.locator("#marketAdminButton").click();
await expect(page.locator("#adminDialog")).toBeVisible();
await page.locator("#closeAdminDialog").click();
await page.locator("#accountButton").click();
@@ -3668,6 +3671,7 @@ test("desktop header keeps refresh, admin commands and account identity visible"
await expect(page.locator("#refreshButton")).toBeVisible();
await expect(page.locator("#syncButton")).toBeHidden();
await expect(page.locator("#settingsButton")).toBeHidden();
await expect(page.locator("#marketAdminButton")).toBeHidden();
await expect(page.locator("#accountAdminBadge")).toBeHidden();
await expect(page.locator("#accountVipBadge")).toBeVisible();
await expect(page.locator("#accountName")).toHaveText("normal_user");
+4 -9
View File
@@ -264,8 +264,7 @@ const SYSTEM_PAGES = [
["system/profile", "账号资料"],
["system/password", "修改密码"],
["system/membership", "会员状态"],
["system/admin", "系统设置"],
["system/members", "会员管理"],
["system/admin", "行情管理"],
];
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
@@ -360,8 +359,7 @@ test("system management pages render real content instead of placeholders", asyn
await expect(page.locator(".m-sys-grid")).toBeVisible();
await navigateToFeature(page, "system/admin");
await expect(page.locator("#m-sys-token")).toBeVisible();
await navigateToFeature(page, "system/members");
await expect(page.locator("#m-sys-member-limit")).toBeVisible();
await expect(page.locator("[data-system-datahub]")).toBeVisible();
});
test("system home groups entries and keeps admin-only items gated", async ({ page }) => {
@@ -403,10 +401,7 @@ test("system settings tabs, model editor, delete confirm and theme toggle work",
await page.locator("[data-theme-toggle]").click();
await expect.poll(async () => page.locator("#m-app").getAttribute("data-theme")).not.toBe(before);
await navigateToFeature(page, "system/members");
await page.locator("[data-system-open-member]").click();
await expect(page.locator(".m-sheet-root.is-open")).toBeVisible();
await expect(page.locator(".m-sheet-head h2")).toContainText("管理会员");
await expect(page.locator("[data-system-datahub]").first()).toBeVisible();
});
test("password mismatch shows inline error instead of a silent submit", async ({ page }) => {
@@ -439,7 +434,7 @@ test("non-admin cannot open system admin pages as placeholders", async ({ page }
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
await expect(page.locator("[data-system-page='home']")).toBeVisible();
await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0);
await expect(page.locator('[data-route="#/feature/system/members"]')).toHaveCount(0);
await expect(page.locator("[data-system-datahub]")).toHaveCount(0);
await expect(page.locator("[data-system-switch]")).toBeVisible();
await navigateToFeature(page, "system/admin");
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
+6 -1
View File
@@ -35,7 +35,12 @@ class AccountSwitchGrantTests(unittest.TestCase):
self.bound_user_id = int(user_id)
def _register(self, username: str, device_hash: str = "") -> dict:
return self.service.register(username, "Password123", device_hash or self.device_a)
invite = ""
if self.database.count_users():
invite = self.service.generate_invite_codes(1)[0]["code"]
return self.service.register(
username, "Password123", device_hash or self.device_a, invite
)
def test_login_records_accounts_for_the_current_device_only(self) -> None:
first = self._register("alpha_user")
+6
View File
@@ -37,6 +37,10 @@ MODULE_STYLESHEETS = (
"pages/review/foundation.css",
)
# 问天是冻结区:即便某条规则在当前运行时里已经没人引用,也不在这里做陈旧清理,
# 免得为了让门禁变绿去动冻结代码。其余门禁(归属唯一、无空声明等)照常覆盖它。
FROZEN_STYLESHEETS = ("pages/heaven/foundation.css",)
RETIRED_STYLESHEETS = (
"styles/styles.css",
"styles/renovation.css",
@@ -559,6 +563,8 @@ class CssGovernanceTests(unittest.TestCase):
def test_every_selector_has_a_runtime_consumer(self) -> None:
stale: list[str] = []
for relative, keys in self.rule_keys.items():
if relative in FROZEN_STYLESHEETS:
continue
for contexts, selector in keys:
if selector.lower().startswith(("@keyframes", "@-webkit-keyframes")):
continue
+4 -3
View File
@@ -26,6 +26,7 @@ class DatabaseMigrationTests(unittest.TestCase):
("0003", "extend_llm_audit"),
("0004", "add_mentor_note"),
("0005", "create_account_switch_grants"),
("0006", "create_invite_codes"),
],
)
columns = {
@@ -40,7 +41,7 @@ class DatabaseMigrationTests(unittest.TestCase):
count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"]
self.assertEqual(count, 5)
self.assertEqual(count, 6)
def test_database_with_recorded_0004_and_note_column_starts_without_reapply(
self,
@@ -61,7 +62,7 @@ class DatabaseMigrationTests(unittest.TestCase):
count = connection.execute(
"SELECT COUNT(*) AS count FROM schema_migrations"
).fetchone()["count"]
self.assertEqual(count, 5)
self.assertEqual(count, 6)
def test_old_database_without_0004_upgrades_and_adds_note_column(self) -> None:
with tempfile.TemporaryDirectory() as root:
@@ -88,7 +89,7 @@ class DatabaseMigrationTests(unittest.TestCase):
"PRAGMA table_info(mentor_preferences)"
)
]
self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005"})
self.assertEqual(versions, {"0001", "0002", "0003", "0004", "0005", "0006"})
self.assertIn("note", note_rows)
def test_database_with_unknown_migration_is_rejected(self) -> None:
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
import json
import unittest
from http import HTTPStatus
from pathlib import Path
from unittest import mock
from backend.application import HUB_SERVICE_HANDLERS, RequestHandler
ROOT = Path(__file__).resolve().parents[1]
SERVICE_TOKEN = "hub-admin-token-for-tests"
class FakeHeaders(dict):
def get(self, name, default=""): # type: ignore[override]
return super().get(name, default)
class HubAdminBridgeTests(unittest.TestCase):
def handler(self, path: str, token: str, calls: list[str]) -> RequestHandler:
handler = RequestHandler.__new__(RequestHandler)
handler.path = path
handler.headers = FakeHeaders({"X-Hub-Admin-Token": token} if token else {})
handler.responses = []
handler.send_json = lambda payload, status=HTTPStatus.OK, headers=None: (
handler.responses.append((status, payload))
)
handler.require_auth = lambda: calls.append("auth") or True
handler.require_csrf = lambda: calls.append("csrf") or True
handler.require_access = lambda method, route: calls.append("access") or True
return handler
def test_every_bridge_path_has_a_real_handler(self) -> None:
for path, handler_name in HUB_SERVICE_HANDLERS.items():
with self.subTest(path=path):
self.assertTrue(path.startswith("/api/hub-admin/"))
self.assertTrue(callable(getattr(RequestHandler, handler_name)))
def test_bridge_paths_stay_out_of_the_browser_route_registry(self) -> None:
registry = json.loads(
(ROOT / "config" / "api.config.json").read_text(encoding="utf-8")
)
registered = {route["path"] for route in registry["routes"]}
for path in HUB_SERVICE_HANDLERS:
with self.subTest(path=path):
self.assertNotIn(path, registered)
self.assertIsNone(RequestHandler.route_registry.resolve("POST", path))
def test_missing_or_wrong_service_token_is_rejected(self) -> None:
for token in ("", "wrong-token"):
with self.subTest(token=token), mock.patch.dict(
"os.environ", {"HUB_ADMIN_TOKEN": SERVICE_TOKEN}
):
calls: list[str] = []
handler = self.handler("/api/hub-admin/status", token, calls)
handler.hub_system_status = lambda: calls.append("dispatched")
RequestHandler.do_POST(handler)
self.assertEqual(calls, [])
status, payload = handler.responses[-1]
self.assertEqual(status, HTTPStatus.UNAUTHORIZED)
self.assertIn("服务令牌", payload["error"])
def test_unset_server_token_refuses_every_bridge_call(self) -> None:
with mock.patch.dict("os.environ", {"HUB_ADMIN_TOKEN": ""}):
calls: list[str] = []
handler = self.handler("/api/hub-admin/status", SERVICE_TOKEN, calls)
handler.hub_system_status = lambda: calls.append("dispatched")
RequestHandler.do_POST(handler)
self.assertEqual(calls, [])
self.assertEqual(handler.responses[-1][0], HTTPStatus.UNAUTHORIZED)
def test_valid_service_token_dispatches_without_session_guards(self) -> None:
for path, handler_name in HUB_SERVICE_HANDLERS.items():
with self.subTest(path=path), mock.patch.dict(
"os.environ", {"HUB_ADMIN_TOKEN": SERVICE_TOKEN}
):
calls: list[str] = []
handler = self.handler(path, SERVICE_TOKEN, calls)
setattr(handler, handler_name, lambda: calls.append(handler_name))
RequestHandler.do_POST(handler)
self.assertEqual(calls, [handler_name])
if __name__ == "__main__":
unittest.main()
+135
View File
@@ -0,0 +1,135 @@
from __future__ import annotations
import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from tempfile import TemporaryDirectory
from backend.features.accounts.security import SecretVault
from backend.features.accounts.service import AccountService
from database import ReviewDatabase
class InviteRegistrationTests(unittest.TestCase):
def setUp(self) -> None:
self.temp = TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.database = ReviewDatabase(Path(self.temp.name) / "review.db")
self.bound_user_id = 0
self.service = AccountService(
database=self.database,
vault=SecretVault(SecretVault.generate_key()),
current_user_supplier=lambda: self.bound_user_id,
access_supplier=lambda: self.database.user_access(self.bound_user_id) or {},
bind_user=self._bind,
personal_field_builder=lambda *args, **kwargs: {},
auth_lock=threading.Lock(),
)
def _bind(self, user_id: int) -> None:
self.bound_user_id = int(user_id)
def _bootstrap_admin(self) -> None:
self.service.register("root_admin", "Password123")
def _one_code(self) -> str:
return self.service.generate_invite_codes(1)[0]["code"]
def test_first_account_is_created_without_an_invite_code(self) -> None:
result = self.service.register("root_admin", "Password123")
self.assertEqual(result["user"]["role"], "admin")
def test_registration_requires_an_invite_code_once_an_account_exists(self) -> None:
self._bootstrap_admin()
with self.assertRaises(ValueError) as error:
self.service.register("second_user", "Password123")
self.assertIn("邀请码", str(error.exception))
self.assertEqual(self.database.count_users(), 1)
def test_unknown_used_and_revoked_codes_are_all_rejected(self) -> None:
self._bootstrap_admin()
with self.assertRaises(ValueError):
self.service.register("second_user", "Password123", "", "XB-AAAA-AAAA-AAAA")
code = self._one_code()
self.service.register("second_user", "Password123", "", code)
with self.assertRaises(ValueError) as used:
self.service.register("third_user", "Password123", "", code)
self.assertIn("已被使用", str(used.exception))
revoked = self._one_code()
self.service.revoke_invite_code(revoked)
with self.assertRaises(ValueError) as gone:
self.service.register("fourth_user", "Password123", "", revoked)
self.assertIn("作废", str(gone.exception))
self.assertEqual(self.database.count_users(), 2)
def test_invite_code_is_accepted_with_or_without_separators(self) -> None:
self._bootstrap_admin()
code = self._one_code()
self.service.register("second_user", "Password123", "", code.replace("-", "").lower())
record = self.database.invite_code(code)
self.assertEqual(record["status"], "used")
self.assertTrue(record["used_at"])
def test_concurrent_registrations_consume_one_code_once(self) -> None:
self._bootstrap_admin()
code = self._one_code()
def attempt(index: int) -> str:
try:
self.service.register(f"racer_{index}", "Password123", "", code)
return "ok"
except ValueError as exc:
return str(exc)
with ThreadPoolExecutor(max_workers=6) as pool:
outcomes = list(pool.map(attempt, range(6)))
self.assertEqual(outcomes.count("ok"), 1)
self.assertEqual(self.database.count_users(), 2)
self.assertEqual(self.database.count_invite_codes()["used"], 1)
def test_failed_account_creation_keeps_the_code_available(self) -> None:
self._bootstrap_admin()
code = self._one_code()
with self.assertRaises(ValueError):
self.service.register("root_admin", "Password123", "", code)
self.assertEqual(self.database.invite_code(code)["status"], "unused")
self.service.register("second_user", "Password123", "", code)
self.assertEqual(self.database.invite_code(code)["status"], "used")
def test_used_code_cannot_be_revoked_and_stays_reported(self) -> None:
self._bootstrap_admin()
code = self._one_code()
self.service.register("second_user", "Password123", "", code)
with self.assertRaises(ValueError):
self.service.revoke_invite_code(code)
overview = self.service.invite_overview()
# total 让页面能直接显示"共 N 个",不必自己加总
self.assertEqual(overview["summary"], {"unused": 0, "used": 1, "revoked": 0})
row = overview["codes"][0]
self.assertEqual(row["used_by_username"], "second_user")
self.assertNotIn(code, row["code_masked"])
self.assertTrue(row["code_masked"].endswith("••••"))
self.assertEqual(row["code_id"], AccountService.invite_handle(code))
def test_codes_can_be_revoked_through_their_public_handle(self) -> None:
self._bootstrap_admin()
code = self._one_code()
self.service.revoke_invite_code(AccountService.invite_handle(code))
self.assertEqual(self.database.invite_code(code)["status"], "revoked")
def test_batch_generation_is_bounded(self) -> None:
self._bootstrap_admin()
with self.assertRaises(ValueError):
self.service.generate_invite_codes(AccountService.INVITE_MAX_BATCH + 1)
created = self.service.generate_invite_codes(3, "内部测试")
self.assertEqual(len({item["code"] for item in created}), 3)
self.assertEqual(self.database.count_invite_codes()["unused"], 3)
self.assertEqual(self.service.invite_overview()["codes"][0]["note"], "内部测试")
if __name__ == "__main__":
unittest.main()
+4 -9
View File
@@ -25,7 +25,6 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
"system/password",
"system/membership",
"system/admin",
"system/members",
],
)
for key in keys:
@@ -47,19 +46,16 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
'data-system-page="profile"',
'data-system-page="password"',
'data-system-page="membership"',
'data-system-page="members"',
'data-system-page="forbidden"',
'data-system-admin-panel="market"',
"m-sys-birth-date",
"m-sys-password-current",
"m-sys-token",
"m-sys-member-limit",
"data-system-switch",
"data-system-edit-model",
"data-system-open-member",
"data-system-datahub",
"管理员专区",
"刷新状态",
"保存分工",
"打开数据中枢",
'location.assign("/login/")',
):
self.assertIn(marker, pages)
@@ -75,12 +71,11 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
for name in (
"data-system-save-birth",
"data-system-save-password",
"data-system-add-model",
"data-system-save-models",
"data-system-save-market",
"data-system-refresh",
"data-system-toggle-refresh",
"data-system-save-model",
"data-system-backfill",
"data-system-datahub",
):
self.assertIn(name, pages)
self.assertNotIn(name + '">', pages)
+7
View File
@@ -17,6 +17,13 @@ registry, and verification tools.
`backend/features/*/routes.py` owners.
- `python tools/build_architecture_inventory.py [--check]`: generate or verify
`config/architecture-inventory.json` from the current source tree.
- `python tools/verify_datahub_console.py`: end-to-end self-test for the Data Hub console
bridge — invite-code single use, admin-only gate, credential masking, model pool and
membership round-trips, service-token checks. Starts both services on temporary ports with
temporary data directories and restores the repository state on exit.
- `python tools/verify_datahub_console_ui.py [--shots <dir>]`: the browser pass over the same
sandbox (gate, credential editor, vendor model pool, members and invite codes, day/night
themes, 1030px narrow layout). Requires Playwright and a local Chromium.
- `python tools/backfill_recent_snapshots.py --account <admin> [--lookback 60] [--dry-run]`:
auditable recent trading-day dashboard snapshot backfill. See
`docs/maintenance/行情历史补档.md`.
+300
View File
@@ -0,0 +1,300 @@
"""数据中枢控制台端到端自测:主站与中枢真的对话一遍,不是 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:
if any(argument in {"-h", "--help"} for argument in sys.argv[1:]):
print(__doc__.strip())
return 0
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())
+299
View File
@@ -0,0 +1,299 @@
"""数据中枢控制台浏览器自测:真的打开控制台,点一遍新页面。
跑法: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
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:
if any(argument in {"-h", "--help"} for argument in sys.argv[1:]):
print(__doc__.strip())
return 0
from playwright.sync_api import sync_playwright
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())
+8 -2
View File
@@ -4,8 +4,14 @@ 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=
# 浏览器可达的主站地址,用于门禁面板的「去主站登录」跳转。留空时用固定内网地址
# http://192.168.200.11:8765HEL-564:不再按请求 Host 推导,域名只反代 8765)。
REVIEW_PUBLIC_URL=http://192.168.200.11:8765
# Tushare Pro token. Stored encrypted after first launch; never returned by API or admin pages.
TUSHARE_TOKEN=
+30 -3
View File
@@ -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 按"门牌"(域名 / IP)区分,只有用 `http://192.168.200.11:8765` 登录主站,浏览器才会把它带到 `192.168.200.11:8766`,此时打开 8766 免登录直通;用域名登录主站再进 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` | 中枢 | 浏览器可达的主站地址,用于门禁的登录跳转;留空则用固定内网地址 `http://192.168.200.11:8765` |
## 从主站迁入的两块配置
- **模型池**:按供应商组织(同一 API 地址下可挂多个模型),填好地址与 Key 后可自动拉取 `/models` 勾选纳入;供应商不支持或拉取失败时用卡内「手动录入」兜底。主 / 辅模型分工在「调用编排」里指定。密钥加密存于主站,界面只回显后四位。
- **会员与邀请码**:会员开通 / 续期 / 停用、每日调用额度,以及一次性邀请码的生成、复制、作废。注册必须提交有效邀请码,每个码只能成功注册一次(并发提交也只有一个成功)。列表只显示掩码,完整码仅在生成瞬间与「复制」动作中可得。
数据仍归主站所有(同一个 `review.db`),中枢只是唯一的管理入口;主站页面上原本的模型池与会员管理分区已移除,「数据中枢」按钮固定指向 `http://192.168.200.11:8766/admin/`HEL-564,不按主机名推导)。
## Docker(独立 compose,不改现网 review 服务)
```bash
File diff suppressed because it is too large Load Diff
+19 -29
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="zh-CN" data-theme="night">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -9,29 +9,16 @@
<body>
<div id="app">
<div id="login-view" class="auth-wrap bg-ambient" hidden>
<section class="panel auth-panel">
<h1>数据中枢</h1>
<p class="muted">内网管理后台 · 运行总览 / 数据源配置 / 数据血缘。</p>
<form id="login-form">
<label>账号 <input name="username" value="hub_admin" autocomplete="username" /></label>
<label>密码 <input name="password" type="password" autocomplete="current-password" /></label>
<button type="submit">登录</button>
<p id="login-error" class="error" hidden></p>
</form>
</section>
</div>
<div id="change-view" class="auth-wrap bg-ambient" hidden>
<section class="panel auth-panel">
<h1>修改初始密码</h1>
<p class="muted">首次登录需先设置新密码才能进入。</p>
<form id="change-form">
<label>当前密码 <input name="current" type="password" autocomplete="current-password" /></label>
<label>新密码(至少 8 位) <input name="new_password" type="password" autocomplete="new-password" /></label>
<button type="submit">保存并继续</button>
<p id="change-error" class="error" hidden></p>
</form>
<!-- 数据中枢没有独立账号:门禁只负责把未登录/非管理员引回主站。 -->
<div id="gate-view" class="auth-wrap bg-ambient" hidden>
<section class="panel gate-panel">
<h1 id="gate-title">数据中枢</h1>
<p id="gate-desc">正在校验小白复盘主站登录状态…</p>
<div class="gate-actions">
<a id="gate-login" class="pbtn" href="#" hidden>去主站登录</a>
<a id="gate-site" class="tbtn" href="#" hidden>返回小白复盘</a>
<button id="gate-retry" class="tbtn" type="button">重新校验</button>
</div>
</section>
</div>
@@ -46,14 +33,16 @@
<div class="ring1" style="inset:5.72px"></div>
<div class="ring2" style="inset:9.88px"></div>
<div class="center"></div>
<div class="blip pulse-dot" id="radarBlipOk" style="left:64%;top:30%;background:#34d399"></div>
<div class="blip pulse-dot" id="radarBlipBad" style="left:30%;top:62%;background:#f87171;animation-delay:.6s;display:none"></div>
<div class="blip pulse-dot" id="radarBlipOk" style="left:64%;top:30%;background:var(--mint)"></div>
<div class="blip pulse-dot" id="radarBlipBad" style="left:30%;top:62%;background:var(--rd);animation-delay:.6s;display:none"></div>
</div>
<div class="brand"><span class="b1">小白复盘 <span style="color:#22d3ee">·</span> 数据中枢</span><span class="b2">DATA-HUB</span></div>
<div class="brand"><span class="b1">小白复盘 <span style="color:var(--cy)">·</span> 数据中枢</span><span class="b2">DATA-HUB</span></div>
<span class="vsep"></span>
<nav class="nav" id="navEl">
<button class="navbtn" data-nav="overview"><span class="tri"></span>运行总览<span class="en">OVERVIEW</span></button>
<button class="navbtn" data-nav="sources"><span class="tri"></span>数据源配置<span class="en">SOURCES</span></button>
<button class="navbtn" data-nav="models"><span class="tri"></span>模型池<span class="en">MODELS</span></button>
<button class="navbtn" data-nav="members"><span class="tri"></span>会员管理<span class="en">MEMBERS</span></button>
<button class="navbtn" data-nav="lineage"><span class="tri"></span>数据血缘<span class="en">LINEAGE</span></button>
</nav>
<span class="flex1"></span>
@@ -62,6 +51,7 @@
<span class="clock num" id="clock"><span id="ckD"></span><span class="csep">|</span><span class="ct"><span id="ckH"></span><span class="blink cc">:</span><span id="ckM"></span><span class="blink cc">:</span><span id="ckS"></span></span></span>
<span class="vsep"></span>
<button class="tbtn" id="opsBtn" style="font-size:10px">调度 / 发布 / 审计</button>
<button class="tbtn" id="themeBtn" style="font-size:10px">日间</button>
<button class="tbtn" id="calmBtn" style="font-size:10px">减少动态</button>
<span id="who" class="muted" style="font-size:10px"></span>
<button class="tbtn" id="logout-btn" style="font-size:10px">退出</button>
@@ -96,8 +86,8 @@
<div class="modal-box">
<h3 id="modalTitle">危险操作确认</h3>
<p id="modalDesc"></p>
<label>管理密码 <input id="modalPassword" type="password" autocomplete="current-password" /></label>
<label id="modalConfirmWrap">请输入确认词 <span id="modalConfirmWord" class="num" style="color:#fbbf24"></span> <input id="modalConfirm" type="text" autocomplete="off" /></label>
<label>主站账号密码 <input id="modalPassword" type="password" autocomplete="current-password" /></label>
<label id="modalConfirmWrap">请输入确认词 <span id="modalConfirmWord" class="num" style="color:var(--amb)"></span> <input id="modalConfirm" type="text" autocomplete="off" /></label>
<p class="modal-err" id="modalErr"></p>
<div class="modal-actions">
<button class="tbtn" id="modalCancel">取消</button>
+268 -109
View File
@@ -8,7 +8,8 @@
}
/* ================= index.css 逐行照抄(去掉 tailwind 指令) ================= */
:root {
:root,
:root[data-theme="night"] {
--ink: #050810;
--panel: #0c1220;
--panel2: #111a2b;
@@ -21,6 +22,74 @@
--txt: #d7e1f0;
--mut: #8b9bb4;
--dim: #54637e;
/* HEL-560: neutrals that used to be repeated as literals across the
stylesheet and the inline styles in app.js. They are variables so the
day theme below can remap the whole console in one place. */
--txt-strong: #e8f1ff;
--txt-soft: #a9bcd6;
--faint: #3d4c66;
--off: #334155;
--off2: #475569;
--scroll-hover: #31406b;
--on-accent: #0a1420;
--rd-text: #fbd2d2;
--mint-text: #cdf5e4;
--line-soft: rgba(26, 37, 64, 0.55);
--grid-line: rgba(26, 37, 64, 0.35);
--scan-line: rgba(255, 255, 255, 0.022);
--panel-grad-a: rgba(17, 26, 43, 0.6);
--panel-grad-b: rgba(12, 18, 32, 0.9);
--drawer-grad-a: rgba(17, 26, 43, 0.98);
--drawer-grad-b: rgba(9, 13, 23, 0.99);
--chrome-bg: rgba(5, 8, 16, 0.88);
--chrome-bg-strong: rgba(5, 8, 16, 0.92);
--btn-bg: rgba(17, 26, 43, 0.5);
--mask: rgba(2, 4, 10, 0.58);
--shadow: rgba(0, 0, 0, 0.35);
--shadow-strong: rgba(0, 0, 0, 0.4);
--flash-ink: #e8fbff;
}
/* 日间主题(HEL-558 定稿映射):同一组变量名换一套值,accent 保持同色相族
并加深以满足白底对比度,不新增变量名、不新增组件。 */
:root[data-theme="day"] {
--ink: #eef2f8;
--panel: #ffffff;
--panel2: #f3f7fc;
--line: #d9e2ee;
--line2: #c3d0e2;
--cy: #0e7490;
--mint: #047857;
--amb: #b45309;
--rd: #dc2626;
--txt: #17263e;
--mut: #49607f;
--dim: #7d8da6;
--txt-strong: #0b1a2f;
--txt-soft: #33507a;
--faint: #93a3bb;
--off: #b6c3d6;
--off2: #a3b2c8;
--scroll-hover: #a9b8cd;
--on-accent: #ffffff;
--rd-text: #7f1d1d;
--mint-text: #065f46;
--line-soft: rgba(195, 208, 226, 0.65);
--grid-line: rgba(148, 168, 196, 0.28);
--scan-line: rgba(23, 38, 62, 0.014);
--panel-grad-a: rgba(255, 255, 255, 0.96);
--panel-grad-b: rgba(243, 247, 252, 0.98);
--drawer-grad-a: rgba(255, 255, 255, 0.99);
--drawer-grad-b: rgba(243, 247, 252, 0.99);
--chrome-bg: rgba(238, 242, 248, 0.9);
--chrome-bg-strong: rgba(238, 242, 248, 0.94);
--btn-bg: rgba(255, 255, 255, 0.75);
--mask: rgba(23, 38, 62, 0.32);
--shadow: rgba(23, 38, 62, 0.14);
--shadow-strong: rgba(23, 38, 62, 0.18);
--flash-ink: #06384a;
}
html, body, #root { height: 100%; }
@@ -51,8 +120,8 @@ body {
}
.bg-gridlines {
background-image:
linear-gradient(rgba(26, 37, 64, 0.35) 1px, transparent 1px),
linear-gradient(90deg, rgba(26, 37, 64, 0.35) 1px, transparent 1px);
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(120% 90% at 50% 0%, black 30%, transparent 90%);
-webkit-mask-image: radial-gradient(120% 90% at 50% 0%, black 30%, transparent 90%);
@@ -60,8 +129,8 @@ body {
.scanlines {
background: repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0.022) 0px,
rgba(255, 255, 255, 0.022) 1px,
var(--scan-line) 0px,
var(--scan-line) 1px,
transparent 1px,
transparent 3px
);
@@ -79,7 +148,7 @@ body {
/* ---------- panels ---------- */
.panel {
background: linear-gradient(180deg, rgba(17, 26, 43, 0.6), rgba(12, 18, 32, 0.9));
background: linear-gradient(180deg, var(--panel-grad-a), var(--panel-grad-b));
border: 1px solid var(--line);
border-radius: 6px;
position: relative;
@@ -106,8 +175,8 @@ body {
.led.slow { background: var(--amb); box-shadow: 0 0 6px 1px rgba(251,191,36,.7); }
.led.fail { background: var(--rd); box-shadow: 0 0 6px 1px rgba(248,113,113,.8); }
.led.rev { background: var(--cy); box-shadow: 0 0 6px 1px rgba(34,211,238,.7); }
.led.plan { background: #475569; box-shadow: none; }
.led.off { background: transparent; border: 1px dashed #475569; box-shadow: none; }
.led.plan { background: var(--off2); box-shadow: none; }
.led.off { background: transparent; border: 1px dashed var(--off2); box-shadow: none; }
.led.pulse::after {
content: ""; position: absolute; inset: -4px; border-radius: 9999px;
border: 1px solid currentColor; opacity: .6;
@@ -137,7 +206,7 @@ body {
/* ---------- flash on value change ---------- */
.flash { animation: flash .45s ease-out; }
@keyframes flash {
0% { background: rgba(34, 211, 238, 0.22); color: #e8fbff; }
0% { background: rgba(34, 211, 238, 0.22); color: var(--flash-ink); }
100% { background: transparent; }
}
@@ -175,7 +244,7 @@ body {
border: 1px solid var(--line2);
color: var(--mut);
padding: 4px 12px; border-radius: 4px;
background: rgba(17,26,43,.5);
background: var(--btn-bg);
transition: all .15s ease;
cursor: pointer;
font-family: inherit;
@@ -192,7 +261,7 @@ body {
color: var(--dim); font-weight: 500; text-align: left;
padding: 7px 10px; border-bottom: 1px solid var(--line);
}
.dtable td { padding: 7px 10px; border-bottom: 1px solid rgba(26,37,64,.55); vertical-align: middle; }
.dtable td { padding: 7px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
.dtable tr:last-child td { border-bottom: none; }
.dtable tbody tr { transition: background .12s ease; }
.dtable tbody tr:hover { background: rgba(34,211,238,.04); }
@@ -201,7 +270,7 @@ body {
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--ink); }
::-webkit-scrollbar-thumb { background: var(--line2); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #31406b; }
::-webkit-scrollbar-thumb:hover { background: var(--scroll-hover); }
* { scrollbar-width: thin; scrollbar-color: var(--line2) var(--ink); }
/* ---------- reduced motion ---------- */
@@ -226,66 +295,66 @@ body {
.scanlines.z50 { opacity: .7; }
/* ---------- 顶栏 ---------- */
.hdr { position: sticky; top: 0; z-index: 30; border-bottom: 1px solid #1a2540; background: rgba(5,8,16,.88); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
.hdr { position: sticky; top: 0; z-index: 30; border-bottom: 1px solid var(--line); background: var(--chrome-bg); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
.hdr-in { display: flex; align-items: center; gap: 12px; padding: 0 16px; height: 48px; }
.hdr-in > * { flex: none; }
.hdr-in > .flex1 { flex: 1; }
.brand { display: flex; flex-direction: column; line-height: 1; flex: none; white-space: nowrap; }
.brand .b1 { font-size: 13px; font-weight: 600; color: #e8f1ff; letter-spacing: .06em; }
.brand .b2 { font-size: 9px; color: #54637e; letter-spacing: .26em; margin-top: 4px; }
.vsep { width: 1px; height: 16px; background: #1a2540; margin: 0 4px; display: inline-block; flex: none; }
.brand .b1 { font-size: 13px; font-weight: 600; color: var(--txt-strong); letter-spacing: .06em; }
.brand .b2 { font-size: 9px; color: var(--dim); letter-spacing: .26em; margin-top: 4px; }
.vsep { width: 1px; height: 16px; background: var(--line); margin: 0 4px; display: inline-block; flex: none; }
.nav { display: flex; align-items: center; gap: 4px; margin-left: 8px; flex: none; }
.navbtn { position: relative; padding: 6px 14px; font-size: 12px; letter-spacing: .08em; border-radius: 4px; transition: all .15s; background: none; border: none; cursor: pointer; color: #8b9bb4; font-family: inherit; }
.navbtn:hover { color: #d7e1f0; background: rgba(34,211,238,.04); }
.navbtn.act { color: #22d3ee; background: rgba(34,211,238,.08); box-shadow: inset 0 0 0 1px rgba(34,211,238,.35), 0 0 14px rgba(34,211,238,.12); }
.navbtn { position: relative; padding: 6px 14px; font-size: 12px; letter-spacing: .08em; border-radius: 4px; transition: all .15s; background: none; border: none; cursor: pointer; color: var(--mut); font-family: inherit; }
.navbtn:hover { color: var(--txt); background: rgba(34,211,238,.04); }
.navbtn.act { color: var(--cy); background: rgba(34,211,238,.08); box-shadow: inset 0 0 0 1px rgba(34,211,238,.35), 0 0 14px rgba(34,211,238,.12); }
.navbtn .tri { margin-right: 4px; font-size: 9px; display: none; }
.navbtn.act .tri { display: inline; }
.navbtn .en { margin-left: 6px; font-size: 9px; letter-spacing: .14em; color: #3d4c66; }
.navbtn .en { margin-left: 6px; font-size: 9px; letter-spacing: .14em; color: var(--faint); }
.navbtn.act .en { color: rgba(34,211,238,.6); }
.flex1 { flex: 1; }
.mdtag { display: none; align-items: center; gap: 8px; border-radius: 3px; border: 1px solid rgba(34,211,238,.4); background: rgba(34,211,238,.07); padding: 4px 8px; font-size: 10px; letter-spacing: .14em; color: #22d3ee; }
.mdtag { display: none; align-items: center; gap: 8px; border-radius: 3px; border: 1px solid rgba(34,211,238,.4); background: rgba(34,211,238,.07); padding: 4px 8px; font-size: 10px; letter-spacing: .14em; color: var(--cy); }
@media (min-width: 768px) { .mdtag { display: flex; } }
@media (max-width: 1150px) { .mdtag { display: none; } }
.livespan { display: none; align-items: center; gap: 6px; font-size: 10px; letter-spacing: .18em; color: #34d399; }
.livespan { display: none; align-items: center; gap: 6px; font-size: 10px; letter-spacing: .18em; color: var(--mint); }
@media (min-width: 640px) { .livespan { display: flex; } }
.livedot { width: 6px; height: 6px; border-radius: 9999px; background: #34d399; box-shadow: 0 0 6px 1px rgba(52,211,153,.7); }
.clock { font-size: 12px; color: #8b9bb4; letter-spacing: .05em; white-space: nowrap; }
.clock .csep { margin: 0 6px; color: #243152; }
.clock .ct { color: #d7e1f0; }
.clock .cc { color: #22d3ee; }
.demotag { display: inline-flex; align-items: center; gap: 4px; border-radius: 3px; border: 1px solid rgba(251,191,36,.45); background: rgba(251,191,36,.06); color: #fbbf24; padding: 2px 6px; font-size: 10px; letter-spacing: .08em; white-space: nowrap; }
.livedot { width: 6px; height: 6px; border-radius: 9999px; background: var(--mint); box-shadow: 0 0 6px 1px rgba(52,211,153,.7); }
.clock { font-size: 12px; color: var(--mut); letter-spacing: .05em; white-space: nowrap; }
.clock .csep { margin: 0 6px; color: var(--line2); }
.clock .ct { color: var(--txt); }
.clock .cc { color: var(--cy); }
.demotag { display: inline-flex; align-items: center; gap: 4px; border-radius: 3px; border: 1px solid rgba(251,191,36,.45); background: rgba(251,191,36,.06); color: var(--amb); padding: 2px 6px; font-size: 10px; letter-spacing: .08em; white-space: nowrap; }
/* ---------- 雷达 logo ---------- */
.radar { position: relative; flex: none; border-radius: 9999px; border: 1px solid rgba(34,211,238,.5); overflow: hidden; box-shadow: 0 0 12px rgba(34,211,238,.25), inset 0 0 8px rgba(34,211,238,.15); }
.radar .sweep { position: absolute; inset: 0; }
.radar .ring1 { position: absolute; border-radius: 9999px; border: 1px solid rgba(34,211,238,.3); }
.radar .ring2 { position: absolute; border-radius: 9999px; border: 1px solid rgba(34,211,238,.2); }
.radar .center { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 3px; height: 3px; border-radius: 9999px; background: #22d3ee; box-shadow: 0 0 6px 2px rgba(34,211,238,.8); }
.radar .center { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 3px; height: 3px; border-radius: 9999px; background: var(--cy); box-shadow: 0 0 6px 2px rgba(34,211,238,.8); }
.radar .blip { position: absolute; width: 2.5px; height: 2.5px; border-radius: 9999px; }
/* ---------- EVENT TAPE ---------- */
.tape { position: fixed; bottom: 0; left: 0; right: 0; height: 32px; z-index: 40; display: flex; align-items: center; border-top: 1px solid #1a2540; background: rgba(5,8,16,.92); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); overflow: hidden; }
.tape-label { flex: none; display: flex; align-items: center; gap: 6px; padding: 0 12px; height: 100%; border-right: 1px solid #1a2540; font-size: 10px; letter-spacing: .18em; color: #54637e; }
.tape { position: fixed; bottom: 0; left: 0; right: 0; height: 32px; z-index: 40; display: flex; align-items: center; border-top: 1px solid var(--line); background: var(--chrome-bg-strong); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); overflow: hidden; }
.tape-label { flex: none; display: flex; align-items: center; gap: 6px; padding: 0 12px; height: 100%; border-right: 1px solid var(--line); font-size: 10px; letter-spacing: .18em; color: var(--dim); }
.tape-view { flex: 1; overflow: hidden; position: relative; }
.tape-item { display: inline-flex; align-items: center; gap: 8px; margin: 0 20px; font-size: 10.5px; letter-spacing: .04em; }
.tape-item .tt { color: #54637e; }
.tape-item .ts { color: #22d3ee; }
.tape-item .tm { color: #8b9bb4; }
.tape-item .tok { color: #34d399; }
.tape-item .tbad { color: #f87171; }
.tape-item .tsep { color: #1a2540; margin-left: 12px; }
.tape-item .tt { color: var(--dim); }
.tape-item .ts { color: var(--cy); }
.tape-item .tm { color: var(--mut); }
.tape-item .tok { color: var(--mint); }
.tape-item .tbad { color: var(--rd); }
.tape-item .tsep { color: var(--line); margin-left: 12px; }
/* ---------- 主区 ---------- */
.main { position: relative; z-index: 10; padding: 12px 16px 48px; max-width: 1720px; margin: 0 auto; }
.col { display: flex; flex-direction: column; gap: 12px; }
/* ---------- tag / pill ---------- */
.tag { display: inline-flex; align-items: center; gap: 4px; border-radius: 3px; border: 1px solid #243152; padding: 2px 6px; font-size: 10px; letter-spacing: .08em; line-height: 1.5; color: #8b9bb4; white-space: nowrap; }
.tag.cy { border-color: rgba(34,211,238,.45); color: #22d3ee; background: rgba(34,211,238,.06); }
.tag.amb { border-color: rgba(251,191,36,.45); color: #fbbf24; background: rgba(251,191,36,.06); }
.tag.rd { border-color: rgba(248,113,113,.45); color: #f87171; background: rgba(248,113,113,.07); }
.tag.mint { border-color: rgba(52,211,153,.45); color: #34d399; background: rgba(52,211,153,.06); }
.tag.dashed { border-style: dashed; border-color: #243152; color: #54637e; }
.tag { display: inline-flex; align-items: center; gap: 4px; border-radius: 3px; border: 1px solid var(--line2); padding: 2px 6px; font-size: 10px; letter-spacing: .08em; line-height: 1.5; color: var(--mut); white-space: nowrap; }
.tag.cy { border-color: rgba(34,211,238,.45); color: var(--cy); background: rgba(34,211,238,.06); }
.tag.amb { border-color: rgba(251,191,36,.45); color: var(--amb); background: rgba(251,191,36,.06); }
.tag.rd { border-color: rgba(248,113,113,.45); color: var(--rd); background: rgba(248,113,113,.07); }
.tag.mint { border-color: rgba(52,211,153,.45); color: var(--mint); background: rgba(52,211,153,.06); }
.tag.dashed { border-style: dashed; border-color: var(--line2); color: var(--dim); }
.minidot { width: 6px; height: 6px; border-radius: 9999px; display: inline-block; }
.spill { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; white-space: nowrap; }
@@ -293,21 +362,21 @@ body {
.hero { padding: 16px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 16px 32px; }
.hero-l { display: flex; flex-direction: column; gap: 8px; min-width: 300px; }
.hero-title { font-size: 22px; font-weight: 600; letter-spacing: .025em; }
.hero-title .dot { color: #54637e; margin: 0 8px; }
.hero-title .dot { color: var(--dim); margin: 0 8px; }
.hero-tags { display: flex; align-items: center; gap: 8px; }
.hero-sub { font-size: 11px; color: #54637e; }
.hero-sub .sep { margin: 0 8px; color: #243152; }
.hero-sub { font-size: 11px; color: var(--dim); }
.hero-sub .sep { margin: 0 8px; color: var(--line2); }
.kpis { display: flex; align-items: flex-start; gap: 32px; padding-right: 8px; }
.kpi { display: flex; flex-direction: column; gap: 4px; }
.kpi .kv { font-size: 26px; line-height: 1; font-weight: 600; letter-spacing: -.025em; color: #e8f1ff; }
.kpi .ks { font-size: 10px; color: #54637e; }
.kpi .kv { font-size: 26px; line-height: 1; font-weight: 600; letter-spacing: -.025em; color: var(--txt-strong); }
.kpi .ks { font-size: 10px; color: var(--dim); }
/* ---------- 数据集卡 ---------- */
.ds-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
@media (min-width: 640px) { .ds-grid { grid-template-columns: repeat(5, 1fr); } }
@media (min-width: 1280px) { .ds-grid { grid-template-columns: repeat(10, 1fr); } }
.dsc { border-radius: 5px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; transition: all .15s; cursor: default; border: 1px solid #1a2540; }
.dsc:hover { transform: translateY(-1px); border-color: #243152; }
.dsc { border-radius: 5px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; transition: all .15s; cursor: default; border: 1px solid var(--line); }
.dsc:hover { transform: translateY(-1px); border-color: var(--line2); }
.dsc.fail { border-color: rgba(248,113,113,.55); box-shadow: 0 0 18px rgba(248,113,113,.14); }
.dsc.fail:hover { border-color: rgba(248,113,113,.55); }
.dsc.slow { border-color: rgba(251,191,36,.4); }
@@ -315,8 +384,8 @@ body {
.dsc.review { border-color: rgba(34,211,238,.45); box-shadow: 0 0 14px rgba(34,211,238,.1); }
.dsc.review:hover { border-color: rgba(34,211,238,.45); }
.dsc-top { display: flex; align-items: center; justify-content: space-between; }
.dsc-name { font-size: 12px; font-weight: 500; color: #e8f1ff; }
.dsc-note { font-size: 10px; color: #54637e; line-height: 1.6; min-height: 28px; }
.dsc-name { font-size: 12px; font-weight: 500; color: var(--txt-strong); }
.dsc-note { font-size: 10px; color: var(--dim); line-height: 1.6; min-height: 28px; }
/* ---------- 总览下半网格 ---------- */
.ov-grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
@@ -325,8 +394,8 @@ body {
.legend { display: flex; align-items: center; gap: 12px; font-size: 10px; letter-spacing: .1em; }
.legend span { display: flex; align-items: center; gap: 4px; }
.jit { padding: 0 4px; border-radius: 3px; }
.obs-note { font-size: 10px; color: #fbbf24; }
.site-foot { padding: 10px 12px; border-top: 1px solid #1a2540; display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; font-size: 10px; color: #54637e; }
.obs-note { font-size: 10px; color: var(--amb); }
.site-foot { padding: 10px 12px; border-top: 1px solid var(--line); display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; font-size: 10px; color: var(--dim); }
.tbtn.mini { padding: 3px 8px; }
/* ---------- 时间线 ---------- */
@@ -336,35 +405,35 @@ body {
.now-r { animation: nowR 1.6s ease-in-out infinite; }
/* ---------- 异常卡 ---------- */
.inc { display: flex; align-items: center; gap: 12px; border-radius: 4px; border: 1px solid #1a2540; border-left-width: 3px; padding: 10px 12px; }
.inc.fail { border-left-color: #f87171; background: rgba(248,113,113,.05); }
.inc.slow { border-left-color: #fbbf24; background: rgba(251,191,36,.04); }
.inc.off { border-left-color: #334155; background: transparent; border-style: dashed; border-color: #243152; border-left-width: 3px; border-left-style: solid; border-left-color: #334155; }
.inc-title { font-size: 12px; color: #e8f1ff; }
.inc-meta { font-size: 10px; color: #54637e; margin-top: 2px; }
.inc { display: flex; align-items: center; gap: 12px; border-radius: 4px; border: 1px solid var(--line); border-left-width: 3px; padding: 10px 12px; }
.inc.fail { border-left-color: var(--rd); background: rgba(248,113,113,.05); }
.inc.slow { border-left-color: var(--amb); background: rgba(251,191,36,.04); }
.inc.off { border-left-color: var(--off); background: transparent; border-style: dashed; border-color: var(--line2); border-left-width: 3px; border-left-style: solid; border-left-color: var(--off); }
.inc-title { font-size: 12px; color: var(--txt-strong); }
.inc-meta { font-size: 10px; color: var(--dim); margin-top: 2px; }
/* ---------- Sources 页 ---------- */
.strip { padding: 12px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 24px; }
.src-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 12px 16px; border-bottom: 1px solid #1a2540; }
.src-name { font-size: 15px; font-weight: 600; color: #e8f1ff; letter-spacing: .025em; }
.src-role { font-size: 10px; color: #54637e; letter-spacing: .1em; }
.src-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; border-bottom: 1px solid #1a2540; font-size: 11px; color: #8b9bb4; }
.src-meta .msep { color: #243152; }
.src-head { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 12px 16px; border-bottom: 1px solid var(--line); }
.src-name { font-size: 15px; font-weight: 600; color: var(--txt-strong); letter-spacing: .025em; }
.src-role { font-size: 10px; color: var(--dim); letter-spacing: .1em; }
.src-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 16px; padding: 8px 16px; border-bottom: 1px solid var(--line); font-size: 11px; color: var(--mut); }
.src-meta .msep { color: var(--line2); }
.mx-grid { display: grid; grid-template-columns: 1fr; gap: 16px 24px; padding: 16px; }
@media (min-width: 768px) { .mx-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1280px) { .mx-grid { grid-template-columns: repeat(3, 1fr); } }
.mx-grid > div { min-width: 0; }
.mx-grid table { table-layout: auto; max-width: 100%; }
.offbox { border-radius: 4px; border: 1px dashed #243152; padding: 24px 16px; text-align: center; font-size: 11px; color: #54637e; }
.offbox { border-radius: 4px; border: 1px dashed var(--line2); padding: 24px 16px; text-align: center; font-size: 11px; color: var(--dim); }
.rsv-grid { display: grid; grid-template-columns: 1fr; gap: 12px; }
@media (min-width: 768px) { .rsv-grid { grid-template-columns: repeat(3, 1fr); } }
.rsv { border-radius: 6px; border: 1px dashed #243152; padding: 16px; display: flex; align-items: center; gap: 12px; color: #54637e; }
.rsv { border-radius: 6px; border: 1px dashed var(--line2); padding: 16px; display: flex; align-items: center; gap: 12px; color: var(--dim); }
@keyframes spin { to { transform: rotate(360deg); } }
.animate-spin { display: inline-block; animation: spin 1s linear infinite; }
/* ---------- Lineage 页 ---------- */
.lin-cols { display: flex; justify-content: space-between; padding: 4px 24px 0; font-size: 10px; letter-spacing: .2em; color: #54637e; }
.lin-hint { position: absolute; left: 24px; bottom: 4px; font-size: 10px; color: #54637e; letter-spacing: .1em; }
.lin-cols { display: flex; justify-content: space-between; padding: 4px 24px 0; font-size: 10px; letter-spacing: .2em; color: var(--dim); }
.lin-hint { position: absolute; left: 24px; bottom: 4px; font-size: 10px; color: var(--dim); letter-spacing: .1em; }
@keyframes gnodePulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } }
.gnode-pulse { animation: gnodePulse 1.8s ease-in-out infinite; }
/* 真实调用事件脉冲(HEL-529 数据修正第 6 项):血缘图来源节点在真实调用
@@ -387,10 +456,10 @@ table.dtable tbody tr:nth-child(3n) .spark-end { animation-delay: -.35s; }
.ds-grid .dsc:nth-child(2n) .spark-end { animation-delay: -.5s; }
.ds-grid .dsc:nth-child(3n) .spark-end { animation-delay: -.9s; }
.ds-grid .dsc:nth-child(5n) .spark-end { animation-delay: -.2s; }
.lin-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid #1a2540; }
.lin-sel, .lin-q { background: #0c1220; border: 1px solid #243152; border-radius: 4px; font-size: 11px; color: #8b9bb4; padding: 6px 8px; outline: none; font-family: inherit; }
.lin-q { color: #d7e1f0; padding: 6px 10px; width: 220px; }
.lin-q::placeholder { color: #3d4c66; }
.lin-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--line); }
.lin-sel, .lin-q { background: var(--panel); border: 1px solid var(--line2); border-radius: 4px; font-size: 11px; color: var(--mut); padding: 6px 8px; outline: none; font-family: inherit; }
.lin-q { color: var(--txt); padding: 6px 10px; width: 220px; }
.lin-q::placeholder { color: var(--faint); }
.lin-sel:focus, .lin-q:focus { border-color: rgba(34,211,238,.5); }
.lin-scroll { overflow-x: auto; }
/* HEL-529:宽档下由 app.js 的 fitLineageTable() 按剩余可视高度写入
@@ -399,7 +468,7 @@ table.dtable tbody tr:nth-child(3n) .spark-end { animation-delay: -.35s; }
.lin-scroll { overflow-y: auto; }
.lin-table { min-width: 1080px; }
.lin-table-n { display: none; }
.lin-table-n .sub { display: block; font-size: 10px; color: #54637e; margin-top: 2px; }
.lin-table-n .sub { display: block; font-size: 10px; color: var(--dim); margin-top: 2px; }
/* ---------- Lineage 页压缩(仅血缘页,其他页不受影响) ---------- */
#mainEl[data-page="lineage"] .col { gap: 8px; }
#mainEl[data-page="lineage"] { padding-bottom: 36px; }
@@ -407,13 +476,122 @@ table.dtable tbody tr:nth-child(3n) .spark-end { animation-delay: -.35s; }
#mainEl[data-page="lineage"] .lin-filter { padding: 6px 12px; }
#mainEl[data-page="lineage"] .dtable th { padding: 5px 10px; }
#mainEl[data-page="lineage"] .dtable td { padding: 4px 10px; }
/* =================================================================
HEL-560 新增组件:表单原子、数据源凭证可编辑区、模型池供应商卡、
会员/邀请码表、主站会话门禁。全部复用上方 token,日夜两套主题
靠同名变量换值,不另写一套覆盖样式。
================================================================= */
/* ---------- 表单原子(按钮 / 字段 / 提示) ---------- */
.pbtn {
font-size: 11px; letter-spacing: .08em; border: 1px solid var(--cy);
color: var(--on-accent); background: var(--cy); padding: 6px 14px; border-radius: 4px;
cursor: pointer; font-family: inherit; font-weight: 600; transition: filter .15s ease;
}
.pbtn:hover { filter: brightness(1.08); }
.pbtn:disabled { opacity: .45; cursor: not-allowed; filter: none; }
.field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
.field > .lab { margin: 0; }
.field input, .field select, .cred-line input, .vend-manual input, .dtoolbar select {
background: var(--panel); border: 1px solid var(--line2); border-radius: 4px;
color: var(--txt); font-family: inherit; font-size: 12px; padding: 7px 9px; outline: none;
min-width: 0; width: 100%;
}
.field input:focus, .field select:focus, .cred-line input:focus, .vend-manual input:focus {
border-color: var(--cy);
}
.field input:disabled, .field select:disabled { opacity: .55; cursor: not-allowed; }
.field-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 14px; }
.grid-1 { grid-template-columns: minmax(0, 1fr); }
.form-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
.form-hint { font-size: 10.5px; color: var(--dim); letter-spacing: .04em; line-height: 1.6; }
.muted { font-size: 11px; color: var(--dim); line-height: 1.6; margin: 0; }
/* ---------- 凭证可编辑区(数据源卡内,只增不删) ---------- */
.cred-box { padding: 12px 16px; border-bottom: 1px solid var(--line); display: flex; flex-direction: column; gap: 7px; }
.cred-box:last-child { border-bottom: none; }
.cred-flag { color: var(--cy); letter-spacing: .06em; }
.cred-line { display: flex; align-items: center; gap: 10px; }
.cred-line input { flex: 1 1 auto; }
.cred-acts { display: flex; align-items: center; gap: 8px; flex: none; }
.cred-feet { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 16px; }
/* ---------- 模型池:供应商卡 ---------- */
.vend-hd { flex-wrap: wrap; gap: 8px; justify-content: flex-start; }
.vend-name { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; color: var(--txt-strong); letter-spacing: .02em; }
.vend-body { display: flex; flex-direction: column; gap: 9px; }
/* 样图:地址 / Key 并排,操作按钮贴在同一行右端并与输入框底边对齐 */
.vend-line { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 10px 14px; }
.vend-line > .field { flex: 1 1 260px; }
.vend-acts { display: flex; align-items: center; gap: 8px; flex: none; padding-top: 19px; }
.route-line { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px 14px; }
.route-line > .field { flex: 0 1 260px; }
.vend-note { display: flex; align-items: center; gap: 6px; font-size: 10.5px; color: var(--mut); letter-spacing: .04em; }
.vend-note.bad { color: var(--rd); }
.vend-fetch-list { display: flex; flex-direction: column; gap: 5px; padding: 2px 0 2px 2px; }
.vend-pick { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--txt); }
.vend-pick input { accent-color: var(--cy); }
.vend-pick .added { font-size: 10px; color: var(--dim); letter-spacing: .06em; }
.vend-manual {
display: flex; flex-wrap: wrap; align-items: flex-end; gap: 8px 12px;
padding: 10px 12px; border: 1px dashed var(--line2); border-radius: 4px;
}
.vend-manual > .lab { margin: 0 0 4px; }
.vend-manual .field { flex: 1 1 220px; }
.model-row {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px;
padding: 9px 12px; border: 1px solid var(--line); border-radius: 4px; background: var(--btn-bg);
}
.model-row .mr-main { flex: 1 1 220px; min-width: 0; }
.model-row .mr-name { font-size: 12px; color: var(--txt-strong); font-weight: 600; display: flex; align-items: center; gap: 7px; }
.model-row .mr-sub { font-size: 10px; color: var(--cy); margin-top: 3px; word-break: break-all; }
.model-row .mr-test { display: inline-flex; align-items: center; gap: 8px; flex: none; }
.model-row .mr-state { font-size: 11px; color: var(--mut); white-space: nowrap; }
/* ---------- 会员 / 邀请码 ---------- */
.wide-scroll { overflow-x: auto; }
.wide-table { min-width: 720px; }
.cell-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.invite-code { letter-spacing: .06em; color: var(--txt-strong); white-space: nowrap; }
.invite-note { font-size: 10px; color: var(--dim); }
.table-foot {
display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
gap: 4px 16px; padding: 8px 16px; border-top: 1px solid var(--line); font-size: 10.5px; color: var(--dim);
}
/* ---------- 主站会话门禁 ---------- */
.gate-panel { width: 100%; max-width: 420px; padding: 28px 26px 24px; text-align: left; }
.gate-panel h1 { font-size: 16px; font-weight: 600; color: var(--txt-strong); margin: 0 0 8px; letter-spacing: .02em; }
.gate-panel p { font-size: 11.5px; color: var(--mut); line-height: 1.8; margin: 0 0 18px; }
.gate-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
.gate-actions .pbtn, .gate-actions .tbtn { text-decoration: none; display: inline-flex; align-items: center; }
@media (max-width: 1100px) {
.field-row { grid-template-columns: minmax(0, 1fr); }
.form-actions { width: 100%; }
/* 样图 1030:输入框独占一行,按钮整排落到下一行,不再与输入框挤同一行 */
.cred-line { flex-direction: column; align-items: stretch; }
.cred-acts { width: 100%; }
.cred-acts .tbtn, .cred-acts .pbtn { flex: 1 1 auto; text-align: center; }
.cred-feet { flex-direction: column; align-items: flex-start; }
.vend-hd { padding: 10px 12px; }
.vend-line, .route-line { flex-direction: column; align-items: stretch; }
.vend-acts { width: 100%; padding-top: 0; }
.vend-acts .tbtn, .vend-acts .pbtn { flex: 1 1 auto; text-align: center; }
.route-line > .field { flex: 1 1 auto; }
.model-row { flex-direction: column; align-items: stretch; }
.model-row .mr-test { justify-content: flex-start; }
.model-row > .tbtn { align-self: flex-start; }
.vend-manual { flex-direction: column; align-items: stretch; }
.vend-manual > .tbtn { align-self: flex-start; }
.src-head { row-gap: 6px; }
.hdr-in { flex-wrap: wrap; height: auto; padding-top: 6px; padding-bottom: 6px; row-gap: 6px; }
.hdr-in > .flex1 { flex: 1 1 100%; height: 0; }
.mdtag { display: flex; }
.lin-table { display: none; }
.lin-table-n { display: table; width: 100%; }
}
.row-fail { background: rgba(248,113,113,.045); }
.row-slow { background: rgba(251,191,36,.03); }
.row-off { opacity: .55; }
@@ -424,55 +602,36 @@ table.dtable tbody tr:nth-child(3n) .spark-end { animation-delay: -.35s; }
/* =================================================================
以下为 HEL-529 第十二版生产实现补充样式(非 Kimi 打样内容):
登录/改密视图、抽屉(调度任务/盘后发布/审计合并入口)、危险操作弹层。
门禁视图、抽屉(调度任务/盘后发布/审计合并入口)、危险操作弹层。
全部复用上方 token--ink/--panel/--line/--cy/--mint/--amb/--rd 等),
不引入新色值、不新增动效缓动函数。
HEL-560:原独立登录/改密表单随账号体系下线一并移除,仅留门禁复用的
.auth-wrap 居中容器。
================================================================= */
/* ---------- 登录 / 改密(保留原有表单结构,套用新 token ---------- */
.auth-wrap {
min-height: 100%; display: flex; align-items: center; justify-content: center;
padding: 24px;
}
.auth-panel {
width: 100%; max-width: 380px; padding: 28px 26px 24px;
}
.auth-panel h1 { font-size: 16px; font-weight: 600; color: #e8f1ff; margin: 0 0 6px; letter-spacing: .02em; }
.auth-panel .muted { font-size: 11px; color: var(--dim); line-height: 1.6; margin: 0 0 18px; }
.auth-panel form { display: flex; flex-direction: column; gap: 12px; }
.auth-panel label { display: flex; flex-direction: column; gap: 6px; font-size: 11px; color: var(--mut); letter-spacing: .04em; }
.auth-panel input {
background: #0c1220; border: 1px solid var(--line2); border-radius: 4px;
color: var(--txt); font-family: inherit; font-size: 13px; padding: 8px 10px; outline: none;
}
.auth-panel input:focus { border-color: rgba(34,211,238,.55); box-shadow: 0 0 0 2px rgba(34,211,238,.12); }
.auth-panel button[type="submit"] {
margin-top: 4px; font-size: 12px; letter-spacing: .08em; border: 1px solid rgba(34,211,238,.5);
color: #0a1420; background: var(--cy); padding: 9px 12px; border-radius: 4px; cursor: pointer;
font-family: inherit; font-weight: 600; transition: filter .15s ease;
}
.auth-panel button[type="submit"]:hover { filter: brightness(1.08); }
.auth-panel .error { color: var(--rd); font-size: 11px; margin: 2px 0 0; }
/* ---------- 抽屉(调度任务 / 盘后发布 / 审计) ---------- */
.drawer-mask {
position: fixed; inset: 0; background: rgba(2,4,10,.55); z-index: 60;
position: fixed; inset: 0; background: var(--mask); z-index: 60;
opacity: 0; pointer-events: none; transition: opacity .2s ease-out;
}
.drawer-mask.open { opacity: 1; pointer-events: auto; }
.drawer {
position: fixed; top: 0; right: 0; bottom: 0; width: min(720px, 92vw);
background: linear-gradient(180deg, rgba(17,26,43,.98), rgba(9,13,23,.99));
background: linear-gradient(180deg, var(--drawer-grad-a), var(--drawer-grad-b));
border-left: 1px solid var(--line); z-index: 61; display: flex; flex-direction: column;
transform: translateX(100%); transition: transform .28s cubic-bezier(.16,1,.3,1);
box-shadow: -18px 0 40px rgba(0,0,0,.4);
box-shadow: -18px 0 40px var(--shadow-strong);
}
.drawer-mask.open .drawer { transform: translateX(0); }
.drawer-hd {
display: flex; align-items: center; gap: 10px; padding: 14px 18px;
border-bottom: 1px solid var(--line); flex: none;
}
.drawer-hd .ttl { font-size: 13px; font-weight: 600; color: #e8f1ff; letter-spacing: .04em; }
.drawer-hd .ttl { font-size: 13px; font-weight: 600; color: var(--txt-strong); letter-spacing: .04em; }
.drawer-hd .sub { font-size: 10px; color: var(--dim); }
.drawer-tabs { display: flex; gap: 4px; padding: 10px 18px 0; flex: none; }
.drawer-tab {
@@ -492,13 +651,13 @@ table.dtable tbody tr:nth-child(3n) .spark-end { animation-delay: -.35s; }
.drawer-section .lab { margin-bottom: 8px; }
.dtoolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.dtoolbar input {
background: #0c1220; border: 1px solid var(--line2); border-radius: 4px; color: var(--txt);
background: var(--panel); border: 1px solid var(--line2); border-radius: 4px; color: var(--txt);
font-family: inherit; font-size: 11px; padding: 5px 8px; outline: none;
}
/* ---------- 危险操作确认弹层 ---------- */
.modal-mask {
position: fixed; inset: 0; background: rgba(2,4,10,.6); z-index: 70;
position: fixed; inset: 0; background: var(--mask); z-index: 70;
display: none; align-items: center; justify-content: center; padding: 20px;
}
.modal-mask.open { display: flex; }
@@ -510,33 +669,33 @@ table.dtable tbody tr:nth-child(3n) .spark-end { animation-delay: -.35s; }
.modal-box p { margin: 0 0 12px; font-size: 11px; color: var(--mut); line-height: 1.7; }
.modal-box label { display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: var(--mut); margin-bottom: 10px; }
.modal-box input {
background: #0c1220; border: 1px solid var(--line2); border-radius: 4px; color: var(--txt);
background: var(--panel); border: 1px solid var(--line2); border-radius: 4px; color: var(--txt);
font-family: inherit; font-size: 12px; padding: 7px 9px; outline: none;
}
.modal-box input:focus { border-color: rgba(248,113,113,.55); }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
.modal-actions .tbtn.danger { color: var(--rd); border-color: rgba(248,113,113,.5); }
.modal-actions .tbtn.danger:hover { color: #fff; background: rgba(248,113,113,.18); box-shadow: 0 0 10px rgba(248,113,113,.2); }
.modal-actions .tbtn.danger:hover { color: var(--txt-strong); background: rgba(248,113,113,.18); box-shadow: 0 0 10px rgba(248,113,113,.2); }
.modal-err { color: var(--rd); font-size: 11px; margin: 6px 0 0; min-height: 14px; }
/* ---------- 小工具 ---------- */
.opbtn {
font-size: 10px; letter-spacing: .06em; border: 1px solid var(--line2); color: var(--mut);
background: rgba(17,26,43,.5); padding: 3px 9px; border-radius: 3px; cursor: pointer; font-family: inherit;
background: var(--btn-bg); padding: 3px 9px; border-radius: 3px; cursor: pointer; font-family: inherit;
}
.opbtn:hover { color: var(--cy); border-color: rgba(34,211,238,.5); }
.opbtn.danger { color: var(--rd); border-color: rgba(248,113,113,.4); }
.opbtn.danger:hover { color: #fff; background: rgba(248,113,113,.14); }
.opbtn.danger:hover { color: var(--txt-strong); background: rgba(248,113,113,.14); }
.empty-hint { text-align: center; padding: 22px 0; color: var(--dim); font-size: 11px; }
.toast {
position: fixed; left: 50%; bottom: 44px; transform: translate(-50%, 8px); z-index: 80;
background: var(--panel2); border: 1px solid var(--line2); border-radius: 5px; padding: 9px 16px;
font-size: 12px; color: var(--txt); opacity: 0; pointer-events: none; transition: opacity .18s ease-out, transform .18s ease-out;
box-shadow: 0 8px 24px rgba(0,0,0,.35);
box-shadow: 0 8px 24px var(--shadow);
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
.toast.err { border-color: rgba(248,113,113,.5); color: #fbd2d2; }
.toast.ok { border-color: rgba(52,211,153,.5); color: #cdf5e4; }
.toast.err { border-color: rgba(248,113,113,.5); color: var(--rd-text); }
.toast.ok { border-color: rgba(52,211,153,.5); color: var(--mint-text); }
/* 减少动态:抽屉/弹层的滑入过渡也一并归零,保持与其余过渡同一门禁 */
.reduce-motion .drawer,
+3 -1
View File
@@ -12,7 +12,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:-http://192.168.200.11:8765}"
TUSHARE_TOKEN: "${TUSHARE_TOKEN:-}"
IFIND_REFRESH_TOKEN: "${IFIND_REFRESH_TOKEN:-}"
IFIND_ACCESS_TOKEN: "${IFIND_ACCESS_TOKEN:-}"
+41 -8
View File
@@ -16,12 +16,23 @@ from datahub.timeutil import isoformat, now_shanghai, session_phase, yyyymmdd
class AdminAPI:
def __init__(self, db: HubDB, pipeline: Pipeline, scheduler: Scheduler, auth: AuthService, ifind: Any = None) -> None:
def __init__(
self,
db: HubDB,
pipeline: Pipeline,
scheduler: Scheduler,
auth: AuthService,
ifind: Any = None,
site_auth: Any = None,
) -> None:
self.db = db
self.pipeline = pipeline
self.scheduler = scheduler
self.auth = auth
self.ifind = ifind
# HEL-560: dangerous operations confirm against the review site account
# that is driving the console, not against a console-local password.
self.site_auth = site_auth
def overview(self) -> dict[str, Any]:
today = yyyymmdd(now_shanghai())
@@ -211,18 +222,34 @@ class AdminAPI:
def audit(self) -> dict[str, Any]:
return {"items": self.db.fetchall("SELECT * FROM audit_log ORDER BY id DESC LIMIT 200")}
def rollback(self, dataset: str, trade_date: str, password: str, confirm: str, actor: str) -> dict[str, Any]:
self._dangerous(password, confirm, f"{dataset}:{trade_date}")
def rollback(
self,
dataset: str,
trade_date: str,
password: str,
confirm: str,
actor: str,
actor_id: int = 0,
) -> dict[str, Any]:
self._dangerous(password, confirm, f"{dataset}:{trade_date}", actor_id)
result = self.pipeline.rollback(dataset, trade_date, actor=actor)
return result
def backfill(self, dataset: str, trade_date: str, password: str, confirm: str, actor: str) -> dict[str, Any]:
def backfill(
self,
dataset: str,
trade_date: str,
password: str,
confirm: str,
actor: str,
actor_id: int = 0,
) -> dict[str, Any]:
day = yyyymmdd(trade_date or now_shanghai())
if dataset == "history":
self._dangerous(password, confirm, "history:full")
self._dangerous(password, confirm, "history:full", actor_id)
result = self.pipeline.backfill_history(day)
else:
self._dangerous(password, confirm, f"{dataset}:{day}")
self._dangerous(password, confirm, f"{dataset}:{day}", actor_id)
if dataset == "reference":
result = self.pipeline.ingest_reference(day)
elif dataset in OFFICIAL_DATASETS or dataset == STOCKS_DATASET:
@@ -244,12 +271,18 @@ class AdminAPI:
self.pipeline.audit(actor, "backfill", f"{dataset}:{day}", json.dumps({"ok": True}))
return result
def _dangerous(self, password: str, confirm: str, expected: str) -> None:
if not self.auth.confirm_password(password):
def _dangerous(self, password: str, confirm: str, expected: str, actor_id: int = 0) -> None:
if not self._confirm_password(actor_id, password):
raise ApiError("UNAUTHORIZED", "二次确认密码错误")
if confirm.strip() != expected:
raise ApiError("INVALID_ARGUMENT", f"确认词必须为 {expected}")
def _confirm_password(self, actor_id: int, password: str) -> bool:
"""Second factor for destructive ops: the operator's review-site password."""
if self.site_auth is None:
raise ApiError("UNAVAILABLE", "主站桥接未配置,无法校验管理员口令")
return bool(self.site_auth.confirm_password(actor_id, password))
def _public_calls(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
out = []
+10 -124
View File
@@ -1,39 +1,12 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import os
import secrets
from datetime import timedelta
from typing import Any
from datahub.crypto import SecretVault, mask_secret
from datahub.db import HubDB
from datahub.timeutil import isoformat, now_shanghai
from datahub.timeutil import isoformat
PBKDF2_ROUNDS = 200_000
SESSION_HOURS = 12
LOGIN_FAIL_LIMIT = 5
LOCK_MINUTES = 10
def hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
raw_salt = salt or os.urandom(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), raw_salt, PBKDF2_ROUNDS, dklen=32)
return (
base64.urlsafe_b64encode(raw_salt).decode("ascii"),
base64.urlsafe_b64encode(digest).decode("ascii"),
)
def verify_password(password: str, salt_text: str, expected_hash: str) -> bool:
try:
salt = base64.urlsafe_b64decode(salt_text.encode("ascii"))
_, actual = hash_password(password, salt)
except (ValueError, TypeError):
return False
return hmac.compare_digest(actual, expected_hash)
def token_hash(token: str) -> str:
@@ -41,12 +14,18 @@ def token_hash(token: str) -> str:
class AuthService:
def __init__(self, db: HubDB, vault: SecretVault, api_token: str, admin_password: str) -> None:
"""Machine credentials only: the `/v1` API token and the provider secrets.
Operator accounts live on the review site (HEL-560) — the console verifies
them through `SiteAuth`, so nothing here authenticates a person.
"""
def __init__(self, db: HubDB, vault: SecretVault, api_token: str) -> None:
self.db = db
self.vault = vault
self._bootstrap(api_token, admin_password)
self._bootstrap(api_token)
def _bootstrap(self, api_token: str, admin_password: str) -> None:
def _bootstrap(self, api_token: str) -> None:
if api_token:
existing = self.db.fetchone("SELECT token_hash FROM api_tokens WHERE name = ?", ("review",))
hashed = token_hash(api_token)
@@ -61,17 +40,6 @@ class AuthService:
"UPDATE api_tokens SET token_hash = ?, last4 = ? WHERE name = ?",
(hashed, last4, "review"),
)
admin = self.db.fetchone("SELECT id FROM hub_admin WHERE username = ?", ("hub_admin",))
if admin is None and admin_password:
salt, hashed = hash_password(admin_password)
now = isoformat()
self.db.execute(
"""
INSERT INTO hub_admin(username, password_salt, password_hash, password_must_change, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)
""",
("hub_admin", salt, hashed, now, now),
)
def check_api_token(self, supplied: str) -> bool:
if not supplied:
@@ -82,88 +50,6 @@ class AuthService:
)
return row is not None
def login(self, username: str, password: str) -> dict[str, Any]:
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", (username,))
if not user:
raise PermissionError("账号或密码错误")
now = now_shanghai()
locked_until = user.get("locked_until")
if locked_until:
try:
from datetime import datetime
if datetime.fromisoformat(str(locked_until)) > now:
raise PermissionError("账号已锁定,请稍后再试")
except ValueError:
pass
if not verify_password(password, str(user["password_salt"]), str(user["password_hash"])):
fails = int(user["failed_attempts"] or 0) + 1
lock = isoformat(now + timedelta(minutes=LOCK_MINUTES)) if fails >= LOGIN_FAIL_LIMIT else None
self.db.execute(
"UPDATE hub_admin SET failed_attempts = ?, locked_until = ? WHERE id = ?",
(fails, lock, user["id"]),
)
raise PermissionError("账号或密码错误")
self.db.execute(
"UPDATE hub_admin SET failed_attempts = 0, locked_until = NULL WHERE id = ?",
(user["id"],),
)
session = secrets.token_urlsafe(32)
csrf = secrets.token_urlsafe(24)
expires = isoformat(now + timedelta(hours=SESSION_HOURS))
self.db.execute(
"INSERT INTO hub_sessions(token_hash, csrf_token, expires_at, created_at) VALUES (?,?,?,?)",
(token_hash(session), csrf, expires, isoformat(now)),
)
return {
"session": session,
"csrf": csrf,
"must_change": bool(user["password_must_change"]),
"expires_at": expires,
}
def session_user(self, raw_token: str) -> dict[str, Any] | None:
if not raw_token:
return None
row = self.db.fetchone(
"SELECT * FROM hub_sessions WHERE token_hash = ?",
(token_hash(raw_token),),
)
if not row:
return None
if str(row["expires_at"]) < isoformat():
self.db.execute("DELETE FROM hub_sessions WHERE token_hash = ?", (row["token_hash"],))
return None
admin = self.db.fetchone("SELECT username, password_must_change FROM hub_admin WHERE username = ?", ("hub_admin",))
return {
"username": (admin or {}).get("username") or "hub_admin",
"csrf_token": row["csrf_token"],
"must_change": bool((admin or {}).get("password_must_change")),
"token_hash": row["token_hash"],
}
def logout(self, raw_token: str) -> None:
if raw_token:
self.db.execute("DELETE FROM hub_sessions WHERE token_hash = ?", (token_hash(raw_token),))
def change_password(self, current: str, new_password: str) -> None:
if len(new_password) < 8:
raise ValueError("新密码至少 8 位")
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", ("hub_admin",))
if not user or not verify_password(current, str(user["password_salt"]), str(user["password_hash"])):
raise PermissionError("当前密码错误")
salt, hashed = hash_password(new_password)
self.db.execute(
"UPDATE hub_admin SET password_salt=?, password_hash=?, password_must_change=0, updated_at=? WHERE id=?",
(salt, hashed, isoformat(), user["id"]),
)
def confirm_password(self, password: str) -> bool:
user = self.db.fetchone("SELECT * FROM hub_admin WHERE username = ?", ("hub_admin",))
if not user:
return False
return verify_password(password, str(user["password_salt"]), str(user["password_hash"]))
def credential_status(self, name: str) -> dict[str, Any]:
row = self.db.fetchone("SELECT last4, updated_at FROM credentials WHERE name = ?", (name,))
if not row:
-17
View File
@@ -24,24 +24,7 @@ CREATE TABLE IF NOT EXISTS credentials (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hub_admin (
id INTEGER PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_salt TEXT NOT NULL,
password_hash TEXT NOT NULL,
password_must_change INTEGER NOT NULL DEFAULT 1,
failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS hub_sessions (
token_hash TEXT PRIMARY KEY,
csrf_token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS api_tokens (
token_hash TEXT PRIMARY KEY,
+98 -31
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import json
import mimetypes
import secrets
from http import HTTPStatus
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -12,9 +11,10 @@ from urllib.parse import unquote, urlparse
from datahub.hub import Hub
from datahub.logutil import configure_logging, get_logger
from datahub.serving import ApiError, parse_query
from datahub.settings import DEFAULT_REVIEW_PUBLIC_URL
from datahub.siteauth import SITE_SESSION_COOKIE, SiteBridgeError
LOGGER = get_logger()
SESSION_COOKIE = "datahub_session"
class HubRequestHandler(BaseHTTPRequestHandler):
@@ -85,37 +85,48 @@ class HubRequestHandler(BaseHTTPRequestHandler):
self._json(payload, HTTPStatus.OK)
def _admin_api(self, method: str, path: str) -> None:
if path == "/admin/api/login" and method == "POST":
body = self._read_json()
result = self.hub.auth.login(str(body.get("username") or "hub_admin"), str(body.get("password") or ""))
session_token = self._cookie_value(SITE_SESSION_COOKIE)
user = self._site_user(session_token)
if user is None:
if path == "/admin/api/session" and method == "GET":
self._json(
{"authenticated": False, "login_url": self._login_url()},
HTTPStatus.UNAUTHORIZED,
)
return
raise ApiError("UNAUTHORIZED", "请先在小白复盘主站登录")
if not user["is_admin"]:
self._json(
{"ok": True, "must_change": result["must_change"], "csrf": result["csrf"]},
HTTPStatus.OK,
extra_headers=[self._cookie(result["session"])],
{
"error": {"code": "PERMISSION_DENIED", "message": "数据中枢仅管理员可进入"},
"authenticated": True,
"is_admin": False,
"username": user["username"],
},
HTTPStatus.FORBIDDEN,
)
return
user = self.hub.auth.session_user(self._cookie_value(SESSION_COOKIE))
if not user:
raise ApiError("UNAUTHORIZED", "请先登录")
if method == "POST" and path != "/admin/api/login":
csrf = self.headers.get("X-CSRF-Token", "")
if not csrf or not secrets.compare_digest(csrf, str(user["csrf_token"])):
raise ApiError("UNAUTHORIZED", "CSRF 校验失败")
if path == "/admin/api/logout" and method == "POST":
self.hub.auth.logout(self._cookie_value(SESSION_COOKIE))
self._json({"ok": True}, HTTPStatus.OK, extra_headers=[self._cookie("", clear=True)])
return
if method == "POST" and not self.hub.site_auth.check_csrf(session_token, self.headers.get("X-CSRF-Token", "")):
raise ApiError("UNAUTHORIZED", "CSRF 校验失败")
if path == "/admin/api/session" and method == "GET":
self._json({"username": user["username"], "must_change": user["must_change"], "csrf": user["csrf_token"]}, HTTPStatus.OK)
self._json(
{
"authenticated": True,
"is_admin": True,
"username": user["username"],
"csrf": self.hub.site_auth.csrf_token(session_token),
"review_url": self._review_url(),
},
HTTPStatus.OK,
)
return
if path == "/admin/api/change-password" and method == "POST":
body = self._read_json()
self.hub.auth.change_password(str(body.get("current") or ""), str(body.get("new_password") or ""))
self.hub.pipeline.audit(user["username"], "change_password", "hub_admin", "")
self._json({"ok": True}, HTTPStatus.OK)
if path == "/admin/api/logout" and method == "POST":
self.hub.site_auth.logout(session_token)
self.hub.pipeline.audit(user["username"], "logout", "site_session", "")
self._json({"ok": True, "login_url": self._login_url()}, HTTPStatus.OK)
return
if self._console_api(method, path, user):
return
if user["must_change"] and path not in {"/admin/api/change-password", "/admin/api/session"}:
raise ApiError("UNAUTHORIZED", "请先修改初始密码")
if path == "/admin/api/overview" and method == "GET":
self._json(self.hub.admin.overview(), HTTPStatus.OK)
return
@@ -175,6 +186,7 @@ class HubRequestHandler(BaseHTTPRequestHandler):
str(body.get("password") or ""),
str(body.get("confirm") or ""),
user["username"],
int(user["id"]),
)
self._json(result, HTTPStatus.OK)
return
@@ -186,11 +198,70 @@ class HubRequestHandler(BaseHTTPRequestHandler):
str(body.get("password") or ""),
str(body.get("confirm") or ""),
user["username"],
int(user["id"]),
)
self._json(result, HTTPStatus.OK)
return
raise ApiError("INVALID_ARGUMENT", f"unknown admin endpoint: {path}")
def _site_user(self, session_token: str) -> dict[str, Any] | None:
try:
return self.hub.site_auth.verify(session_token)
except SiteBridgeError as exc:
raise ApiError("UNAVAILABLE", str(exc)) from exc
def _review_url(self) -> str:
"""Browser-reachable review site URL.
HEL-564: fixed to REVIEW_PUBLIC_URL (default the intranet address)
instead of being derived from the browser's Host header — a domain
that only proxies 8765 would send the console back to a dead port.
"""
configured = self.hub.settings.review_public_url or DEFAULT_REVIEW_PUBLIC_URL
return configured.rstrip("/")
def _login_url(self) -> str:
return f"{self._review_url()}/login/"
def _console_api(self, method: str, path: str, user: dict[str, Any]) -> bool:
"""Endpoints backed by the review site: model pool, members, invites.
Returns True when the request was handled so the caller can fall
through to the hub-owned endpoints otherwise.
"""
console = self.hub.site_console
if path == "/admin/api/models" and method == "GET":
self._json(console.models(), HTTPStatus.OK)
elif path == "/admin/api/models/save" and method == "POST":
self._json(console.save_models(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/models/test" and method == "POST":
self._json(console.test_model(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/models/fetch" and method == "POST":
self._json(console.fetch_models(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/members" and method == "GET":
self._json(console.members(), HTTPStatus.OK)
elif path == "/admin/api/members/save" and method == "POST":
self._json(console.save_member(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/members/quota" and method == "POST":
self._json(console.save_quota(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/invites" and method == "GET":
self._json(console.invites(), HTTPStatus.OK)
elif path == "/admin/api/invites/create" and method == "POST":
self._json(console.create_invites(self._read_json(allow_empty=True), int(user["id"])), HTTPStatus.OK)
elif path == "/admin/api/invites/revoke" and method == "POST":
self._json(console.revoke_invite(self._read_json()), HTTPStatus.OK)
elif path == "/admin/api/credentials/tushare" and method == "POST":
payload = self.hub.put_tushare_credentials(self._read_json())
self.hub.pipeline.audit(user["username"], "store_credential", "tushare_token", "")
self._json(payload, HTTPStatus.OK)
elif path == "/admin/api/credentials/ifind" and method == "POST":
payload = self.hub.put_ifind_credentials(self._read_json())
self.hub.pipeline.audit(user["username"], "store_credential", "ifind_tokens", "")
self._json(payload, HTTPStatus.OK)
else:
return False
return True
def _admin_static(self, path: str) -> None:
relative = path[len("/admin"):].lstrip("/") or "index.html"
candidate = (self.hub.static_dir / relative).resolve()
@@ -239,10 +310,6 @@ class HubRequestHandler(BaseHTTPRequestHandler):
morsel = cookie.get(name)
return morsel.value if morsel else ""
def _cookie(self, value: str, clear: bool = False) -> str:
max_age = 0 if clear else 12 * 3600
return f"{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}"
def _json(self, payload: dict[str, Any], status: HTTPStatus, extra_headers: list[str] | None = None) -> None:
raw = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
+37 -3
View File
@@ -16,10 +16,17 @@ from datahub.pipeline import Pipeline
from datahub.scheduler import Scheduler
from datahub.serving import V1API
from datahub.settings import Settings, load_settings
from datahub.siteauth import SiteAuth, SiteBridge
from datahub.siteconsole import SiteConsole
class Hub:
def __init__(self, settings: Settings, adapter: TushareAdapter | None = None) -> None:
def __init__(
self,
settings: Settings,
adapter: TushareAdapter | None = None,
site_auth: SiteAuth | None = None,
) -> None:
if not settings.encryption_key:
raise SystemExit("DATAHUB_ENCRYPTION_KEY 未配置")
self.settings = settings
@@ -31,7 +38,7 @@ class Hub:
# directly in tests) defaults to enabled — see observability.is_enabled.
self.db.observability_enabled = settings.observability_enabled
self.vault = SecretVault(settings.encryption_key)
self.auth = AuthService(self.db, self.vault, settings.api_token, settings.admin_password)
self.auth = AuthService(self.db, self.vault, settings.api_token)
token = settings.tushare_token or self.auth.load_credential("tushare_token")
if settings.tushare_token:
self.auth.store_credential("tushare_token", settings.tushare_token)
@@ -56,9 +63,36 @@ class Hub:
self.lkg = LastKnownGood(self.db)
self.scheduler = Scheduler(self.db, self.pipeline)
self.api = V1API(self.db, self.pipeline, settings, ifind=self.ifind)
self.admin = AdminAPI(self.db, self.pipeline, self.scheduler, self.auth, ifind=self.ifind)
self.site_bridge = SiteBridge(settings.review_base_url, settings.hub_admin_token)
# Tests inject a stub so the console gate can run without a live site.
self.site_auth = site_auth or SiteAuth(self.site_bridge, settings.encryption_key)
self.site_console = SiteConsole(self.site_bridge)
self.admin = AdminAPI(
self.db,
self.pipeline,
self.scheduler,
self.auth,
ifind=self.ifind,
site_auth=self.site_auth,
)
self.static_dir = Path(__file__).resolve().parents[1] / "admin"
def put_tushare_credentials(self, body: dict[str, Any] | None) -> dict[str, Any]:
"""Store and hot-swap the Tushare token without a restart.
The adapter holds the token in memory, so writing the credential and
pushing it into the live adapter in one step is what makes the save take
effect for the next fetch instead of the next deploy.
"""
from datahub.serving import envelope
token = str((body or {}).get("tushare_token") or "").strip()
if not token:
raise ValueError("Tushare Token 不能为空")
self.auth.store_credential("tushare_token", token)
self.adapter.token = token
return envelope(self.adapter.probe(), {"source": "tushare"})
def put_ifind_credentials(self, body: dict[str, Any] | None) -> dict[str, Any]:
from datahub.serving import envelope
+12 -2
View File
@@ -10,6 +10,9 @@ ROOT = Path(__file__).resolve().parents[1]
DEFAULT_DB_PATH = Path(os.environ.get("DATAHUB_DB_PATH") or (ROOT / "data" / "datahub.db"))
DEFAULT_BACKUP_DIR = Path(os.environ.get("DATAHUB_BACKUP_DIR") or (ROOT / "data" / "backups"))
DEFAULT_CONFIG_PATH = ROOT / "config" / "hub-quality.config.json"
# HEL-564: 浏览器可达的主站地址固定成内网 IP,不再按请求 Host 推导——域名只
# 反代 8765,推导出来的地址在域名访问下必然打不开。
DEFAULT_REVIEW_PUBLIC_URL = "http://192.168.200.11:8765"
def _load_quality(path: Path) -> dict[str, Any]:
@@ -24,7 +27,12 @@ class Settings:
port: int = 8766
encryption_key: str = ""
api_token: str = ""
admin_password: str = ""
# HEL-560: the console has no accounts of its own. It verifies the review
# site's session over the bridge, so it needs the site's internal base URL,
# the shared bridge token, and the browser-reachable site URL for redirects.
review_base_url: str = ""
review_public_url: str = ""
hub_admin_token: str = ""
tushare_token: str = ""
ifind_refresh_token: str = ""
ifind_access_token: str = ""
@@ -122,7 +130,9 @@ def load_settings(
port=int(environ.get("DATAHUB_PORT") or 8766),
encryption_key=str(environ.get("DATAHUB_ENCRYPTION_KEY") or "").strip(),
api_token=str(environ.get("DATAHUB_TOKEN") or "").strip(),
admin_password=str(environ.get("DATAHUB_ADMIN_PASSWORD") or "").strip(),
review_base_url=str(environ.get("REVIEW_BASE_URL") or "http://xiaobai-review:8765").strip(),
review_public_url=str(environ.get("REVIEW_PUBLIC_URL") or DEFAULT_REVIEW_PUBLIC_URL).strip(),
hub_admin_token=str(environ.get("HUB_ADMIN_TOKEN") or "").strip(),
tushare_token=str(environ.get("TUSHARE_TOKEN") or "").strip(),
ifind_refresh_token=str(environ.get("IFIND_REFRESH_TOKEN") or "").strip(),
ifind_access_token=str(environ.get("IFIND_ACCESS_TOKEN") or "").strip(),
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
import hashlib
import hmac
import json
import threading
import time
import urllib.error
import urllib.request
from typing import Any
from datahub.logutil import get_logger
LOGGER = get_logger()
SITE_SESSION_COOKIE = "xiaobai_session"
VERIFY_CACHE_SECONDS = 20.0
BRIDGE_TIMEOUT_SECONDS = 6.0
class SiteBridgeError(RuntimeError):
"""The review site could not answer a bridge call.
``status`` carries the review site's HTTP status when it answered with one.
A 4xx there means the operator's input was rejected (bad API key, invalid
model id), which must not surface here as a console fault.
"""
def __init__(self, message: str, status: int = 0) -> None:
super().__init__(message)
self.status = status
@property
def caller_fault(self) -> bool:
return 400 <= self.status < 500
class SiteBridge:
"""Service-to-service client for the review site's ``/api/hub-admin/*`` endpoints.
The shared ``HUB_ADMIN_TOKEN`` is the only credential; the review site
checks it before any handler runs, so nothing here needs a browser session.
"""
def __init__(self, base_url: str, token: str, timeout: float = BRIDGE_TIMEOUT_SECONDS) -> None:
self.base_url = (base_url or "").rstrip("/")
self.token = token or ""
self.timeout = timeout
@property
def configured(self) -> bool:
return bool(self.base_url and self.token)
def call(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
if not self.configured:
raise SiteBridgeError("主站桥接未配置:请设置 REVIEW_BASE_URL 与 HUB_ADMIN_TOKEN")
body = json.dumps(payload or {}, ensure_ascii=False).encode("utf-8")
request = urllib.request.Request(
f"{self.base_url}{path}",
data=body,
method="POST",
headers={
"Content-Type": "application/json; charset=utf-8",
"X-Hub-Admin-Token": self.token,
},
)
try:
with urllib.request.urlopen(request, timeout=self.timeout) as response:
raw = response.read()
except urllib.error.HTTPError as exc:
detail = _error_detail(exc.read())
raise SiteBridgeError(detail or f"主站返回 {exc.code}", exc.code) from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise SiteBridgeError(f"主站不可达:{exc}") from exc
try:
parsed = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise SiteBridgeError("主站返回的不是合法 JSON") from exc
if not isinstance(parsed, dict):
raise SiteBridgeError("主站返回的不是合法 JSON")
if parsed.get("error"):
raise SiteBridgeError(str(parsed["error"]))
return parsed
def _error_detail(raw: bytes) -> str:
try:
parsed = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return ""
if isinstance(parsed, dict) and parsed.get("error"):
return str(parsed["error"])
return ""
class SiteAuth:
"""Admin gate for the console: the review site owns accounts, we only verify.
The console has no accounts of its own. Every request carries the review
site's ``xiaobai_session`` cookie (same host, different port, so the browser
sends it), which we hand to the site for verification. Results are cached
for a few seconds so a page full of panels does not fan out one bridge call
per request.
CSRF is stateless: the token is an HMAC of the session token under a server
secret, so it is unguessable without the secret yet needs no storage and
stays valid exactly as long as the session does.
"""
def __init__(self, bridge: SiteBridge, secret: str, cache_seconds: float = VERIFY_CACHE_SECONDS) -> None:
self.bridge = bridge
self._secret = (secret or "").encode("utf-8")
self._cache_seconds = cache_seconds
self._cache: dict[str, tuple[float, dict[str, Any] | None]] = {}
self._lock = threading.Lock()
def verify(self, session_token: str) -> dict[str, Any] | None:
if not session_token:
return None
key = hashlib.sha256(session_token.encode("utf-8")).hexdigest()
now = time.monotonic()
with self._lock:
cached = self._cache.get(key)
if cached and cached[0] > now:
return cached[1]
user = self._verify_remote(session_token)
with self._lock:
self._cache[key] = (now + self._cache_seconds, user)
if len(self._cache) > 256:
self._prune(now)
return user
def _prune(self, now: float) -> None:
for cached_key in [key for key, (expires, _) in self._cache.items() if expires <= now]:
self._cache.pop(cached_key, None)
def _verify_remote(self, session_token: str) -> dict[str, Any] | None:
payload = self.bridge.call("/api/hub-admin/session", {"session_token": session_token})
if not payload.get("authenticated"):
return None
user = payload.get("user") or {}
return {
"id": int(user.get("id") or 0),
"username": str(user.get("username") or ""),
"role": str(user.get("role") or "user"),
"is_admin": bool(user.get("is_admin")),
}
def invalidate(self, session_token: str) -> None:
key = hashlib.sha256(session_token.encode("utf-8")).hexdigest()
with self._lock:
self._cache.pop(key, None)
def csrf_token(self, session_token: str) -> str:
digest = hashlib.sha256(session_token.encode("utf-8")).digest()
return hmac.new(self._secret, digest, hashlib.sha256).hexdigest()
def check_csrf(self, session_token: str, supplied: str) -> bool:
if not supplied:
return False
return hmac.compare_digest(self.csrf_token(session_token), supplied)
def logout(self, session_token: str) -> None:
self.invalidate(session_token)
if not session_token:
return
try:
self.bridge.call("/api/hub-admin/session/logout", {"session_token": session_token})
except SiteBridgeError:
LOGGER.warning("site logout bridge call failed")
def confirm_password(self, user_id: int, password: str) -> bool:
if not password:
return False
payload = self.bridge.call(
"/api/hub-admin/password/check",
{"user_id": int(user_id), "password": password},
)
return bool(payload.get("verified"))
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
from typing import Any
from datahub.serving import ApiError
from datahub.siteauth import SiteBridge, SiteBridgeError
VENDOR_PRESETS: tuple[dict[str, str], ...] = (
{"id": "openai", "label": "OpenAI", "base_url": "https://api.openai.com/v1"},
{"id": "deepseek", "label": "DeepSeek", "base_url": "https://api.deepseek.com/v1"},
{"id": "moonshot", "label": "Moonshot", "base_url": "https://api.moonshot.cn/v1"},
{"id": "dashscope", "label": "阿里云百炼", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1"},
{"id": "zhipu", "label": "智谱 GLM", "base_url": "https://open.bigmodel.cn/api/paas/v4"},
{"id": "siliconflow", "label": "SiliconFlow", "base_url": "https://api.siliconflow.cn/v1"},
)
class SiteConsole:
"""Console-side view of the data the review site still owns.
The model pool, member roster and invite codes live in the review site's
database this console reads and writes them over the bridge instead of
copying them, so there is exactly one source of truth. Every method turns a
bridge failure into an ``ApiError`` the console frontend already knows how
to render.
"""
def __init__(self, bridge: SiteBridge) -> None:
self.bridge = bridge
def _call(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
try:
return self.bridge.call(path, payload)
except SiteBridgeError as exc:
# 主站因入参不合法而拒绝(Key 不对、模型 ID 不合法)是操作者的问题,
# 照原样退回 400;只有主站真的不可达才算中枢侧不可用。
code = "INVALID_ARGUMENT" if exc.caller_fault else "SOURCE_UNAVAILABLE"
raise ApiError(code, str(exc)) from exc
# ---------------------------------------------------------------- models
def models(self) -> dict[str, Any]:
payload = self._call("/api/hub-admin/status")
llm = payload.get("llm") or {}
models = list(llm.get("models") or [])
return {
"vendors": [dict(preset) for preset in VENDOR_PRESETS],
"groups": _group_by_vendor(models),
"primary_model_id": str(llm.get("primary_model_id") or ""),
"fallback_model_id": str(llm.get("fallback_model_id") or ""),
"models": models,
}
def save_models(self, body: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {}
if "models" in body:
payload["models"] = body.get("models") or []
for key in ("primary_model_id", "fallback_model_id"):
if key in body:
payload[key] = str(body.get(key) or "")
if not payload:
raise ApiError("INVALID_ARGUMENT", "没有需要保存的模型配置")
self._call("/api/hub-admin/settings/save", payload)
return self.models()
def test_model(self, body: dict[str, Any]) -> dict[str, Any]:
payload = self._call(
"/api/hub-admin/settings/test",
{"model_id": str(body.get("model_id") or ""), "profile": body.get("profile") or {}},
)
return {"result": payload.get("result") or {}}
def fetch_models(self, body: dict[str, Any]) -> dict[str, Any]:
base_url = str(body.get("base_url") or "").strip()
if not base_url:
raise ApiError("INVALID_ARGUMENT", "请先填写供应商接口地址")
payload = self._call(
"/api/hub-admin/models/fetch",
{"base_url": base_url, "api_key": str(body.get("api_key") or "")},
)
return {"models": payload.get("models") or []}
# --------------------------------------------------------------- members
def members(self) -> dict[str, Any]:
payload = self._call("/api/hub-admin/members")
return {
"users": payload.get("users") or [],
"membership": payload.get("membership") or {},
}
def save_member(self, body: dict[str, Any]) -> dict[str, Any]:
payload = dict(body or {})
if not payload:
raise ApiError("INVALID_ARGUMENT", "没有需要保存的会员设置")
self._call("/api/hub-admin/membership/save", payload)
return self.members()
def save_quota(self, body: dict[str, Any]) -> dict[str, Any]:
"""Daily call quota is a system setting, not a per-user membership row."""
try:
limit = int(body.get("member_daily_limit") or 0)
except (TypeError, ValueError) as exc:
raise ApiError("INVALID_ARGUMENT", "每日调用额度必须是整数") from exc
if limit < 1:
raise ApiError("INVALID_ARGUMENT", "每日调用额度至少为 1")
self._call("/api/hub-admin/settings/save", {"member_daily_limit": limit})
return self.members()
# --------------------------------------------------------------- invites
def invites(self) -> dict[str, Any]:
payload = self._call("/api/hub-admin/invites")
return {"summary": payload.get("summary") or {}, "codes": payload.get("codes") or []}
def create_invites(self, body: dict[str, Any], created_by: int) -> dict[str, Any]:
payload = self._call(
"/api/hub-admin/invites/create",
{
"count": body.get("count") or 1,
"note": str(body.get("note") or ""),
"created_by": int(created_by or 0),
},
)
# `created` carries the plaintext codes and is the only moment they are
# ever returned; the list under `codes` is the masked roster.
return {
"created": payload.get("created") or [],
"summary": payload.get("summary") or {},
"codes": payload.get("codes") or [],
}
def revoke_invite(self, body: dict[str, Any]) -> dict[str, Any]:
reference = str(body.get("code_id") or body.get("code") or "").strip()
if not reference:
raise ApiError("INVALID_ARGUMENT", "请选择要作废的邀请码")
self._call("/api/hub-admin/invites/revoke", {"code_id": reference})
return self.invites()
def _group_by_vendor(models: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Bucket a flat model list by base URL so the console can render vendors.
The review site stores one row per model with its own base URL; the console
shows vendors with their models nested, so the base URL is the grouping key
and the preset table only supplies a friendly label when it recognises one.
"""
labels = {preset["base_url"]: preset["label"] for preset in VENDOR_PRESETS}
order: list[str] = []
buckets: dict[str, list[dict[str, Any]]] = {}
for model in models:
base_url = str(model.get("base_url") or "").strip()
if base_url not in buckets:
buckets[base_url] = []
order.append(base_url)
buckets[base_url].append(model)
groups: list[dict[str, Any]] = []
for base_url in order:
entries = buckets[base_url]
configured = next((entry for entry in entries if entry.get("api_key_last4")), None)
groups.append(
{
"base_url": base_url,
"label": labels.get(base_url) or _vendor_label(base_url),
"configured": any(entry.get("configured") for entry in entries),
"key_last4": str((configured or {}).get("api_key_last4") or ""),
"models": entries,
}
)
return groups
def _vendor_label(base_url: str) -> str:
host = base_url.split("//")[-1].split("/")[0]
return host or "自定义供应商"
+48
View File
@@ -113,3 +113,51 @@ def fake_transport(api_name: str, params: dict, fields: str):
if limit_type:
rows = [row for row in rows if str(row.get("limit_type") or "") == limit_type]
return rows
class StubSiteAuth:
"""Stand-in for the review-site session bridge.
The console verifies operators against the review site over HTTP, which
tests must not depend on. This stub answers from a fixed session -> user
map and keeps the same stateless-HMAC CSRF contract as the real service, so
tests exercise the console's own gate rather than the network hop.
"""
SESSION = "site-session-token"
ADMIN = {"id": 1, "username": "admin", "role": "admin", "is_admin": True}
MEMBER = {"id": 2, "username": "member", "role": "user", "is_admin": False}
def __init__(self, password: str = "AdminPass1", secret: str = "stub-secret") -> None:
self.password = password
self.secret = secret
self.sessions = {self.SESSION: dict(self.ADMIN)}
self.logged_out: list[str] = []
def add_session(self, token: str, user: dict) -> None:
self.sessions[token] = dict(user)
def verify(self, session_token: str):
return self.sessions.get(session_token)
def csrf_token(self, session_token: str) -> str:
import hashlib
import hmac
digest = hashlib.sha256(session_token.encode("utf-8")).digest()
return hmac.new(self.secret.encode("utf-8"), digest, hashlib.sha256).hexdigest()
def check_csrf(self, session_token: str, supplied: str) -> bool:
import hmac
return bool(supplied) and hmac.compare_digest(self.csrf_token(session_token), supplied)
def logout(self, session_token: str) -> None:
self.logged_out.append(session_token)
self.sessions.pop(session_token, None)
def confirm_password(self, user_id: int, password: str) -> bool:
return bool(password) and password == self.password
def invalidate(self, session_token: str) -> None:
pass
+116 -39
View File
@@ -16,26 +16,40 @@ from datahub.crypto import SecretVault
from datahub.httpapp import make_handler
from datahub.hub import Hub
from datahub.logutil import JsonFormatter
from datahub.settings import Settings
from tests.fixtures import fake_transport
from datahub.settings import DEFAULT_REVIEW_PUBLIC_URL, Settings
from tests.fixtures import StubSiteAuth, fake_transport
class AdminTests(unittest.TestCase):
"""HEL-560: the console has no accounts — it rides the review site session.
Every case here drives the console the way a browser does: the review
site's `xiaobai_session` cookie plus the stateless CSRF token derived from
it. There is no console login endpoint left to exercise.
"""
def setUp(self) -> None:
self.tmp = tempfile.TemporaryDirectory()
settings = Settings(
encryption_key=SecretVault.generate_key(),
api_token="z" * 32,
admin_password="StartPass1",
tushare_token="real-tushare-token-abcdef",
db_path=Path(self.tmp.name) / "hub.db",
scheduler_enabled=False,
review_public_url="http://127.0.0.1:8765",
)
self.site_auth = StubSiteAuth()
self.hub = Hub(
settings,
adapter=TushareAdapter("real-tushare-token-abcdef", transport=fake_transport),
site_auth=self.site_auth,
)
self.hub = Hub(settings, adapter=TushareAdapter("real-tushare-token-abcdef", transport=fake_transport))
handler = make_handler(self.hub)
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
self.cookie = f"xiaobai_session={StubSiteAuth.SESSION}"
self.csrf = self.site_auth.csrf_token(StubSiteAuth.SESSION)
def tearDown(self) -> None:
self.server.shutdown()
@@ -54,51 +68,110 @@ class AdminTests(unittest.TestCase):
set_cookie = resp.headers.get("Set-Cookie", "")
return resp.status, json.loads(resp.read().decode()), set_cookie
def test_login_change_password_and_secret_masking(self) -> None:
status, body, cookie_header = self._json(
"/admin/api/login", "POST", {"username": "hub_admin", "password": "StartPass1"}
)
self.assertEqual(status, 200)
self.assertTrue(body["must_change"])
cookie = cookie_header.split(";")[0]
csrf = body["csrf"]
status, _, _ = self._json(
"/admin/api/change-password",
"POST",
{"current": "StartPass1", "new_password": "NewPass123"},
cookie=cookie,
csrf=csrf,
)
self.assertEqual(status, 200)
_, sources, _ = self._json("/admin/api/sources", cookie=cookie, csrf=csrf)
blob = json.dumps(sources)
self.assertNotIn("real-tushare-token-abcdef", blob)
self.assertTrue(sources["items"][0]["credential"]["configured"])
self.assertTrue(str(sources["items"][0]["credential"]["last4"]).endswith("cdef") or "****" in str(sources["items"][0]["credential"]["last4"]))
def _admin(self, path, method="GET", body=None):
return self._json(path, method, body, cookie=self.cookie, csrf=self.csrf)
def test_rollback_requires_password_and_confirm(self) -> None:
_, body, cookie_header = self._json(
"/admin/api/login", "POST", {"username": "hub_admin", "password": "StartPass1"}
)
cookie = cookie_header.split(";")[0]
csrf = body["csrf"]
self._json("/admin/api/change-password", "POST", {"current": "StartPass1", "new_password": "NewPass123"}, cookie, csrf)
from urllib.error import HTTPError
def test_session_reports_the_site_account_and_a_csrf_token(self) -> None:
status, body, _ = self._admin("/admin/api/session")
self.assertEqual(status, 200)
self.assertTrue(body["authenticated"])
self.assertTrue(body["is_admin"])
self.assertEqual(body["username"], "admin")
self.assertEqual(body["csrf"], self.csrf)
def test_anonymous_session_probe_returns_the_site_login_url(self) -> None:
with self.assertRaises(HTTPError) as ctx:
self._json("/admin/api/session")
self.assertEqual(ctx.exception.code, 401)
payload = json.loads(ctx.exception.read().decode())
self.assertFalse(payload["authenticated"])
self.assertEqual(payload["login_url"], "http://127.0.0.1:8765/login/")
def test_login_url_ignores_the_browser_host_header(self) -> None:
"""HEL-564: the login link is the fixed intranet address.
Deriving it from Host sent admins to <域名>:8765 under a domain that
only proxies the review site, so the console now sticks to
REVIEW_PUBLIC_URL (default DEFAULT_REVIEW_PUBLIC_URL).
"""
self.hub.settings.review_public_url = ""
req = Request(self.base + "/admin/api/session", headers={"Host": "xiaobaifupan.com"})
with self.assertRaises(HTTPError) as ctx:
urlopen(req, timeout=5)
payload = json.loads(ctx.exception.read().decode())
self.assertEqual(payload["login_url"], f"{DEFAULT_REVIEW_PUBLIC_URL}/login/")
def test_non_admin_site_accounts_are_refused(self) -> None:
self.site_auth.add_session("member-session", StubSiteAuth.MEMBER)
with self.assertRaises(HTTPError) as ctx:
self._json("/admin/api/sources", cookie="xiaobai_session=member-session")
self.assertEqual(ctx.exception.code, 403)
payload = json.loads(ctx.exception.read().decode())
self.assertEqual(payload["error"]["code"], "PERMISSION_DENIED")
self.assertFalse(payload["is_admin"])
def test_writes_require_the_derived_csrf_token(self) -> None:
with self.assertRaises(HTTPError) as ctx:
self._json(
"/admin/api/credentials/tushare",
"POST",
{"tushare_token": "new-token-1234"},
cookie=self.cookie,
)
self.assertEqual(ctx.exception.code, 401)
def test_logout_ends_the_site_session(self) -> None:
status, body, _ = self._admin("/admin/api/logout", "POST", {})
self.assertEqual(status, 200)
self.assertEqual(body["login_url"], "http://127.0.0.1:8765/login/")
self.assertIn(StubSiteAuth.SESSION, self.site_auth.logged_out)
def test_stored_credentials_are_masked_in_the_sources_view(self) -> None:
_, sources, _ = self._admin("/admin/api/sources")
blob = json.dumps(sources)
self.assertNotIn("real-tushare-token-abcdef", blob)
credential = sources["items"][0]["credential"]
self.assertTrue(credential["configured"])
self.assertTrue("****" in str(credential["last4"]) or str(credential["last4"]).endswith("cdef"))
def test_tushare_credential_write_hot_swaps_the_live_adapter(self) -> None:
status, _, _ = self._admin(
"/admin/api/credentials/tushare", "POST", {"tushare_token": "rotated-token-9876"}
)
self.assertEqual(status, 200)
self.assertEqual(self.hub.adapter.token, "rotated-token-9876")
self.assertEqual(self.hub.auth.load_credential("tushare_token"), "rotated-token-9876")
_, sources, _ = self._admin("/admin/api/sources")
self.assertNotIn("rotated-token-9876", json.dumps(sources))
def test_ifind_credential_write_hot_swaps_the_live_adapter(self) -> None:
status, _, _ = self._admin(
"/admin/api/credentials/ifind",
"POST",
{"ifind_refresh_token": "refresh-abcd", "ifind_access_token": "access-efgh"},
)
self.assertEqual(status, 200)
self.assertEqual(self.hub.auth.load_credential("ifind_refresh_token"), "refresh-abcd")
self.assertEqual(self.hub.auth.load_credential("ifind_access_token"), "access-efgh")
def test_rollback_confirms_the_site_account_password(self) -> None:
with self.assertRaises(HTTPError) as ctx:
self._admin(
"/admin/api/rollback",
"POST",
{"dataset": "daily", "trade_date": "20240902", "password": "wrong", "confirm": "daily:20240902"},
cookie,
csrf,
{
"dataset": "daily",
"trade_date": "20240902",
"password": "wrong",
"confirm": "daily:20240902",
},
)
self.assertEqual(ctx.exception.code, 401)
def test_invalid_json_does_not_log_request_body_secrets(self) -> None:
secret = "SuperSecretPass1!"
token = "hub-token-should-not-leak"
raw = json.dumps({"password": secret, "token": token, "username": "hub_admin"}) + "{not-json"
raw = json.dumps({"password": secret, "token": token, "username": "admin"}) + "{not-json"
stream = io.StringIO()
logger = logging.getLogger("datahub")
handler = logging.StreamHandler(stream)
@@ -108,9 +181,13 @@ class AdminTests(unittest.TestCase):
logger.setLevel(logging.DEBUG)
try:
req = Request(
self.base + "/admin/api/login",
self.base + "/admin/api/credentials/tushare",
data=raw.encode("utf-8"),
headers={"Content-Type": "application/json"},
headers={
"Content-Type": "application/json",
"Cookie": self.cookie,
"X-CSRF-Token": self.csrf,
},
method="POST",
)
with self.assertRaises(HTTPError) as ctx:
@@ -13,7 +13,7 @@ from datahub.crypto import SecretVault
from datahub.httpapp import make_handler
from datahub.hub import Hub
from datahub.settings import Settings
from tests.fixtures import TRADE_DATE, fake_transport
from tests.fixtures import TRADE_DATE, StubSiteAuth, fake_transport
class AdminObservabilityApiTests(unittest.TestCase):
@@ -26,31 +26,22 @@ class AdminObservabilityApiTests(unittest.TestCase):
settings = Settings(
encryption_key=SecretVault.generate_key(),
api_token="z" * 32,
admin_password="StartPass1",
tushare_token="real-tushare-token-abcdef",
db_path=Path(self.tmp.name) / "hub.db",
scheduler_enabled=False,
)
self.hub = Hub(settings, adapter=TushareAdapter("real-tushare-token-abcdef", transport=fake_transport))
self.site_auth = StubSiteAuth()
self.hub = Hub(
settings,
adapter=TushareAdapter("real-tushare-token-abcdef", transport=fake_transport),
site_auth=self.site_auth,
)
handler = make_handler(self.hub)
self.server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.base = f"http://127.0.0.1:{self.server.server_address[1]}"
_, body, cookie_header = self._json(
"/admin/api/login", "POST", {"username": "hub_admin", "password": "StartPass1"}
)
cookie = cookie_header.split(";")[0]
csrf = body["csrf"]
self._json(
"/admin/api/change-password",
"POST",
{"current": "StartPass1", "new_password": "NewPass123"},
cookie=cookie,
csrf=csrf,
)
self.cookie = cookie
self.csrf = csrf
self.cookie = f"xiaobai_session={StubSiteAuth.SESSION}"
self.csrf = self.site_auth.csrf_token(StubSiteAuth.SESSION)
def tearDown(self) -> None:
self.server.shutdown()
@@ -159,21 +150,17 @@ class AdminObservabilityApiTests(unittest.TestCase):
finally:
self.hub.db.observability_enabled = True
def test_must_change_password_blocks_new_endpoints_too(self) -> None:
def test_non_admin_site_accounts_cannot_read_the_new_endpoints(self) -> None:
from urllib.error import HTTPError
_, body, cookie_header = self._json(
"/admin/api/login", "POST", {"username": "hub_admin", "password": "NewPass123"}
)
# Freshly logged-in user has already changed password in setUp, so
# this login should not require a change; verify the endpoint is
# reachable with a valid, non-must-change session (regression guard
# against accidentally bypassing the must-change gate for these new
# routes).
cookie = cookie_header.split(";")[0]
csrf = body["csrf"]
status, _, _ = self._json("/admin/api/source-catalog", cookie=cookie, csrf=csrf)
self.assertEqual(status, 200)
self.site_auth.add_session("member-session", StubSiteAuth.MEMBER)
with self.assertRaises(HTTPError) as ctx:
self._json("/admin/api/source-catalog", cookie="xiaobai_session=member-session")
self.assertEqual(ctx.exception.code, 403)
with self.assertRaises(HTTPError) as ctx:
self._json("/admin/api/source-catalog")
self.assertEqual(ctx.exception.code, 401)
if __name__ == "__main__":
-1
View File
@@ -37,7 +37,6 @@ class ApiContractTests(unittest.TestCase):
port=0,
encryption_key=key,
api_token=self.token,
admin_password="StartPass1",
tushare_token="tushare-secret-token-xyz",
db_path=Path(self.tmp.name) / "hub.db",
backup_dir=Path(self.tmp.name) / "backups",
+15 -8
View File
@@ -52,7 +52,6 @@ def make_pipe(transport: GroupTransport, quality_extra: dict | None = None):
settings = Settings(
encryption_key=SecretVault.generate_key(),
api_token="t" * 32,
admin_password="admin-pass",
tushare_token="test-token",
db_path=db.path,
quality=quality,
@@ -361,18 +360,22 @@ class ForceBoundaryEntryTests(unittest.TestCase):
from datahub.scheduler import Scheduler
from datahub.serving import ApiError
from tests.fixtures import StubSiteAuth
vault = SecretVault(self.pipe.settings.encryption_key)
auth = AuthService(self.db, vault, self.pipe.settings.api_token, "StartPass1")
admin = AdminAPI(self.db, self.pipe, Scheduler(self.db, self.pipe), auth)
auth = AuthService(self.db, vault, self.pipe.settings.api_token)
# 危险操作的第二因子现在是主站账号口令(HEL-560),不再有本地管理员密码
site_auth = StubSiteAuth(password="StartPass1")
admin = AdminAPI(self.db, self.pipe, Scheduler(self.db, self.pipe), auth, site_auth=site_auth)
before = publications_map(self.db, TRADE_DATE)
result = admin.backfill("moneyflow", TRADE_DATE, "StartPass1", f"moneyflow:{TRADE_DATE}", "tester")
result = admin.backfill("moneyflow", TRADE_DATE, "StartPass1", f"moneyflow:{TRADE_DATE}", "tester", 1)
self.assertEqual(result["moneyflow"]["state"], "published")
after = publications_map(self.db, TRADE_DATE)
for name in (*GROUP_A, "stocks"):
self.assertNotEqual(after[name], before[name], name)
# bad password / wrong confirm still rejected
with self.assertRaises(ApiError):
admin.backfill("daily", TRADE_DATE, "wrong", f"daily:{TRADE_DATE}", "tester")
admin.backfill("daily", TRADE_DATE, "wrong", f"daily:{TRADE_DATE}", "tester", 1)
def test_admin_backfill_switch_crash_is_failed_precondition(self) -> None:
from datahub.admin_api import AdminAPI
@@ -381,9 +384,13 @@ class ForceBoundaryEntryTests(unittest.TestCase):
from datahub.scheduler import Scheduler
from datahub.serving import ApiError
from tests.fixtures import StubSiteAuth
vault = SecretVault(self.pipe.settings.encryption_key)
auth = AuthService(self.db, vault, self.pipe.settings.api_token, "StartPass1")
admin = AdminAPI(self.db, self.pipe, Scheduler(self.db, self.pipe), auth)
auth = AuthService(self.db, vault, self.pipe.settings.api_token)
# 危险操作的第二因子现在是主站账号口令(HEL-560),不再有本地管理员密码
site_auth = StubSiteAuth(password="StartPass1")
admin = AdminAPI(self.db, self.pipe, Scheduler(self.db, self.pipe), auth, site_auth=site_auth)
before = publications_map(self.db, TRADE_DATE)
def explode() -> None:
@@ -391,7 +398,7 @@ class ForceBoundaryEntryTests(unittest.TestCase):
self.pipe.before_commit = explode
with self.assertRaises(ApiError) as ctx:
admin.backfill("valuation", TRADE_DATE, "StartPass1", f"valuation:{TRADE_DATE}", "tester")
admin.backfill("valuation", TRADE_DATE, "StartPass1", f"valuation:{TRADE_DATE}", "tester", 1)
self.assertEqual(ctx.exception.code, "FAILED_PRECONDITION")
self.assertIn("killed mid-switch", ctx.exception.message)
# previous complete A/B versions keep serving
@@ -0,0 +1,139 @@
from __future__ import annotations
import re
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
INDEX = (ROOT / "admin" / "index.html").read_text(encoding="utf-8")
STYLES = (ROOT / "admin" / "styles.css").read_text(encoding="utf-8")
APP = (ROOT / "admin" / "app.js").read_text(encoding="utf-8")
HTTPAPP = (ROOT / "datahub" / "httpapp.py").read_text(encoding="utf-8")
class ConsoleShellTests(unittest.TestCase):
"""The console shell must carry the site-session gate, not its own login."""
def test_independent_login_and_password_change_are_gone(self) -> None:
for removed in ("login-form", "change-form", "login-view", "change-view", 'value="hub_admin"'):
self.assertNotIn(removed, INDEX, f"{removed} 属于已废弃的独立账号体系")
self.assertNotIn("/admin/api/login", APP)
self.assertNotIn("/admin/api/change-password", APP)
def test_gate_offers_a_way_back_to_the_review_site(self) -> None:
for element in ("gate-view", "gate-title", "gate-desc", "gate-login", "gate-retry"):
self.assertIn(element, INDEX)
self.assertIn("/admin/api/session", APP)
self.assertIn("login_url", APP)
def test_nav_exposes_the_pages_this_console_now_owns(self) -> None:
for page in ("overview", "sources", "models", "members", "lineage"):
self.assertIn(f'data-nav="{page}"', INDEX)
# 路由白名单必须与导航一致,否则点了导航会回落到总览
routed = re.search(r"return \[([^\]]+)\]\.includes\(h\)", APP)
assert routed is not None
for page in ("overview", "sources", "models", "members", "lineage"):
self.assertIn(f"'{page}'", routed.group(1))
class ThemeTokenTests(unittest.TestCase):
"""Day/night is one token set with two value sets — never stacked overrides."""
def test_both_themes_define_the_same_tokens(self) -> None:
night = _token_block(':root,\n:root[data-theme="night"]')
day = _token_block(':root[data-theme="day"]')
self.assertTrue(night)
self.assertEqual(
sorted(night),
sorted(day),
"日间主题必须覆盖同一组变量名,缺一个就会漏出夜间色",
)
def test_no_hardcoded_colours_escape_the_token_set(self) -> None:
# 颜色只要写死在 JS 或组件样式里,切主题就会有一块保持夜间色。
self.assertEqual([], re.findall(r"#[0-9a-fA-F]{6}", APP))
after_tokens = STYLES.split("/* ---------- ambient background", 1)[1]
self.assertEqual([], re.findall(r"#[0-9a-fA-F]{6}", after_tokens))
def test_no_dark_literal_paint_survives_outside_the_token_blocks(self) -> None:
"""A literal dark rgba() would stay dark in day mode — tokens only.
Accent tints are allowed: they are low-opacity washes of the four
status hues and read correctly on either background.
"""
accent = {(34, 211, 238), (52, 211, 153), (251, 191, 36), (248, 113, 113)}
offenders = []
body = STYLES.split("/* ---------- ambient background", 1)[1]
for line in body.splitlines():
for match in re.finditer(r"rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", line):
rgb = tuple(int(match.group(index)) for index in (1, 2, 3))
if rgb in accent or sum(rgb) >= 250:
continue
offenders.append(line.strip()[:80])
self.assertEqual([], offenders, "深色字面值必须收敛成 token,否则日间主题会漏出夜间底色")
def test_theme_choice_survives_a_reload(self) -> None:
self.assertIn("localStorage.getItem('datahub-theme')", APP)
self.assertIn("localStorage.setItem('datahub-theme'", APP)
self.assertIn('data-theme="night"', INDEX)
def test_svg_colours_go_through_style_so_tokens_apply(self) -> None:
# var() 在 SVG 呈现属性里支持不稳,必须写进 style 才吃得到主题变量。
for attribute in ('fill="var(', 'stroke="var(', 'stop-color="var('):
self.assertNotIn(attribute, APP, f"{attribute} 应改写为 style 声明")
class NarrowScreenTests(unittest.TestCase):
def test_narrow_layout_stacks_inputs_and_buttons(self) -> None:
self.assertIn("@media (max-width: 1100px)", STYLES)
narrow = STYLES.split("@media (max-width: 1100px)", 1)[1]
self.assertIn(".field-row { grid-template-columns: minmax(0, 1fr); }", narrow)
# 凭证行与模型行在窄屏都要竖排,按钮才不会和输入框抢同一行
self.assertIn(".cred-line { flex-direction: column;", narrow)
self.assertIn(".model-row { flex-direction: column;", narrow)
class ConsoleEndpointTests(unittest.TestCase):
def test_every_endpoint_the_page_calls_is_routed(self) -> None:
called = {
path.split("?")[0]
for path in re.findall(r"api\('(/admin/api/[^']+)'", APP)
}
self.assertTrue(called)
for path in called:
if path.startswith("/admin/api/sources/"):
continue
self.assertIn(f'"{path}"', HTTPAPP, f"{path} 前端在调,后端没路由")
def test_write_endpoints_are_reached_with_the_csrf_header(self) -> None:
self.assertIn("X-CSRF-Token", APP)
self.assertIn("check_csrf", HTTPAPP)
def _token_block(selector: str) -> list[str]:
start = STYLES.index(selector)
body = STYLES[start:].split("}", 1)[0]
return re.findall(r"(--[a-z0-9-]+):", body)
if __name__ == "__main__":
unittest.main()
class StylesheetIntegrityTests(unittest.TestCase):
"""HEL-560 改造中曾误把样式表尾部整段截断,抽屉/弹层/toast 全部失样,
页面照样能跑单测照样绿这里把"每个仍在用的组件都得有样式"钉死"""
def test_every_component_the_page_renders_still_has_its_own_rules(self) -> None:
for selector in (
".auth-wrap", ".drawer", ".drawer-mask", ".drawer-hd", ".drawer-tab",
".modal-mask", ".modal-box", ".modal-actions", ".opbtn", ".empty-hint",
".toast", ".row-fail", ".row-off", ".spark-end", ".cred-box", ".model-row",
".vend-manual", ".gate-panel", ".dtable", ".invite-code", ".table-foot",
".pbtn", ".field", ".form-hint",
):
self.assertIn(f"{selector} ", STYLES, f"{selector} 的样式丢了")
def test_stylesheet_braces_stay_balanced(self) -> None:
body = STYLES[STYLES.index("/* ================= index.css"):]
self.assertEqual(body.count("{"), body.count("}"))
@@ -20,7 +20,6 @@ class ExtendedEodTests(unittest.TestCase):
port=0,
encryption_key=key,
api_token="k" * 32,
admin_password="StartPass1",
tushare_token="tushare-secret",
db_path=Path(self.tmp.name) / "hub.db",
backup_dir=Path(self.tmp.name) / "backups",
@@ -60,7 +60,6 @@ class _Base(unittest.TestCase):
settings = Settings(
encryption_key=SecretVault.generate_key(),
api_token="z" * 32,
admin_password="StartPass1",
tushare_token="real-tushare-token-abcdef",
db_path=Path(self.tmp.name) / "hub.db",
scheduler_enabled=False,
-1
View File
@@ -53,7 +53,6 @@ def make_pipeline(before_commit=None, clock=None, quality=None) -> tuple[Pipelin
settings = Settings(
encryption_key=SecretVault.generate_key(),
api_token="t" * 32,
admin_password="admin-pass",
tushare_token="test-token",
db_path=db.path,
quality=quality_cfg,
@@ -76,7 +76,6 @@ def make_pipe(transport, quality_extra=None, clock=None):
settings = Settings(
encryption_key=SecretVault.generate_key(),
api_token="t" * 32,
admin_password="admin-pass",
tushare_token="test-token",
db_path=db.path,
quality=quality,
+213
View File
@@ -0,0 +1,213 @@
from __future__ import annotations
import re
import unittest
from http import HTTPStatus
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
REVIEW_ROOT = ROOT.parent
from datahub.serving import ApiError
from datahub.siteauth import SiteBridgeError
from datahub.siteauth import SiteBridgeError
from datahub.siteconsole import SiteConsole
class RecordingBridge:
"""Stands in for the review site so these tests exercise only the mapping."""
def __init__(self, replies: dict[str, dict[str, Any]] | None = None) -> None:
self.replies = replies or {}
self.calls: list[tuple[str, dict[str, Any]]] = []
self.fail_with = ""
def call(self, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
self.calls.append((path, dict(payload or {})))
if self.fail_with:
raise SiteBridgeError(self.fail_with)
return self.replies.get(path, {})
def paths(self) -> list[str]:
return [path for path, _ in self.calls]
STATUS = {
"llm": {
"primary_model_id": "gpt-main",
"fallback_model_id": "",
"models": [
{"id": "gpt-main", "name": "GPT 主力", "model": "gpt-4o", "base_url": "https://api.openai.com/v1", "configured": True, "api_key_last4": "7f21"},
{"id": "gpt-mini", "name": "GPT 轻量", "model": "gpt-4o-mini", "base_url": "https://api.openai.com/v1", "configured": True, "api_key_last4": "7f21"},
{"id": "self-host", "name": "自建 Qwen", "model": "qwen2.5", "base_url": "https://llm.intra.example.com/v1", "configured": False, "api_key_last4": ""},
],
}
}
class ModelPoolTests(unittest.TestCase):
def setUp(self) -> None:
self.bridge = RecordingBridge({"/api/hub-admin/status": STATUS})
self.console = SiteConsole(self.bridge)
def test_models_are_grouped_per_vendor_so_one_vendor_can_hold_many(self) -> None:
payload = self.console.models()
groups = {group["base_url"]: group for group in payload["groups"]}
self.assertEqual(2, len(groups))
openai = groups["https://api.openai.com/v1"]
self.assertEqual("OpenAI", openai["label"])
self.assertEqual(2, len(openai["models"]))
self.assertEqual("7f21", openai["key_last4"])
self.assertTrue(openai["configured"])
def test_unknown_vendors_fall_back_to_their_host_as_a_label(self) -> None:
groups = {group["base_url"]: group for group in self.console.models()["groups"]}
self.assertEqual("llm.intra.example.com", groups["https://llm.intra.example.com/v1"]["label"])
self.assertFalse(groups["https://llm.intra.example.com/v1"]["configured"])
def test_vendor_presets_are_offered_for_the_new_vendor_picker(self) -> None:
vendors = self.console.models()["vendors"]
self.assertIn("OpenAI", [vendor["label"] for vendor in vendors])
self.assertTrue(all(vendor["base_url"].startswith("http") for vendor in vendors))
def test_saving_only_forwards_the_keys_the_caller_actually_sent(self) -> None:
self.console.save_models({"primary_model_id": "gpt-mini"})
path, payload = self.bridge.calls[0]
self.assertEqual("/api/hub-admin/settings/save", path)
self.assertEqual({"primary_model_id": "gpt-mini"}, payload)
def test_saving_nothing_is_refused_rather_than_wiping_the_pool(self) -> None:
with self.assertRaises(ApiError):
self.console.save_models({})
self.assertEqual([], self.bridge.paths())
def test_fetching_a_model_list_needs_a_vendor_endpoint(self) -> None:
with self.assertRaises(ApiError):
self.console.fetch_models({"api_key": "sk-test"})
def test_fetch_passes_the_typed_key_through_for_first_time_vendors(self) -> None:
self.bridge.replies["/api/hub-admin/models/fetch"] = {"models": ["gpt-4o", "gpt-4o-mini"]}
result = self.console.fetch_models({"base_url": "https://api.openai.com/v1", "api_key": "sk-new"})
self.assertEqual(["gpt-4o", "gpt-4o-mini"], result["models"])
self.assertEqual({"base_url": "https://api.openai.com/v1", "api_key": "sk-new"}, self.bridge.calls[0][1])
def test_a_site_outage_becomes_a_console_error_not_a_traceback(self) -> None:
self.bridge.fail_with = "主站不可达"
with self.assertRaises(ApiError) as caught:
self.console.models()
self.assertIn("主站不可达", str(caught.exception))
class MemberAndQuotaTests(unittest.TestCase):
def setUp(self) -> None:
self.bridge = RecordingBridge({"/api/hub-admin/members": {"users": [{"id": 2}], "membership": {"member_daily_limit": 50}}})
self.console = SiteConsole(self.bridge)
def test_saving_a_member_reads_the_roster_back_so_the_page_shows_truth(self) -> None:
payload = self.console.save_member({"user_id": 2, "status": "active", "duration": "3_months"})
self.assertEqual(["/api/hub-admin/membership/save", "/api/hub-admin/members"], self.bridge.paths())
self.assertEqual([{"id": 2}], payload["users"])
def test_quota_is_a_system_setting_not_a_membership_row(self) -> None:
self.console.save_quota({"member_daily_limit": 80})
self.assertEqual("/api/hub-admin/settings/save", self.bridge.paths()[0])
self.assertEqual({"member_daily_limit": 80}, self.bridge.calls[0][1])
def test_quota_below_one_is_rejected_before_it_reaches_the_site(self) -> None:
for bad in (0, -5, "abc"):
with self.subTest(bad=bad):
with self.assertRaises(ApiError):
self.console.save_quota({"member_daily_limit": bad})
self.assertEqual([], self.bridge.paths())
class InviteTests(unittest.TestCase):
def setUp(self) -> None:
self.bridge = RecordingBridge(
{
"/api/hub-admin/invites": {"summary": {"unused": 1}, "codes": [{"code_id": "abc", "code_masked": "XB-9Q2F-••••"}]},
"/api/hub-admin/invites/create": {
"created": [{"code_id": "abc", "code": "XB-9Q2F-7K3M-2P8T"}],
"summary": {"unused": 1},
"codes": [{"code_id": "abc", "code_masked": "XB-9Q2F-••••"}],
},
}
)
self.console = SiteConsole(self.bridge)
def test_plaintext_codes_come_back_only_from_the_create_call(self) -> None:
created = self.console.create_invites({"count": 1}, created_by=1)
self.assertEqual("XB-9Q2F-7K3M-2P8T", created["created"][0]["code"])
# 列表里永远只有掩码,完整码不会再出现第二次
listed = self.console.invites()
self.assertEqual("XB-9Q2F-••••", listed["codes"][0]["code_masked"])
self.assertNotIn("code", listed["codes"][0])
def test_the_operator_is_recorded_as_the_issuer(self) -> None:
self.console.create_invites({"count": 3, "note": "给张总"}, created_by=7)
payload = self.bridge.calls[0][1]
self.assertEqual(7, payload["created_by"])
self.assertEqual(3, payload["count"])
self.assertEqual("给张总", payload["note"])
def test_revoking_uses_the_public_handle_never_the_raw_code(self) -> None:
self.console.revoke_invite({"code_id": "abc"})
self.assertEqual("/api/hub-admin/invites/revoke", self.bridge.paths()[0])
self.assertEqual({"code_id": "abc"}, self.bridge.calls[0][1])
def test_revoking_without_a_target_is_refused(self) -> None:
with self.assertRaises(ApiError):
self.console.revoke_invite({})
self.assertEqual([], self.bridge.paths())
if __name__ == "__main__":
unittest.main()
class BridgeContractTests(unittest.TestCase):
"""Both halves of the bridge must agree on the path spelling.
A typo here fails only at runtime with a confusing 401 (the review site
falls through to its browser-session guard), so it is worth a static check.
"""
def test_every_path_the_console_calls_is_registered_on_the_review_site(self) -> None:
console_paths = set()
for module in ("siteauth.py", "siteconsole.py"):
source = (ROOT / "datahub" / module).read_text(encoding="utf-8")
console_paths.update(re.findall(r'"(/api/hub-admin/[a-z/-]+)"', source))
self.assertTrue(console_paths)
registry = (REVIEW_ROOT / "backend" / "http" / "dispatch.py").read_text(encoding="utf-8")
registered = set(re.findall(r'"(/api/hub-admin/[a-z/-]+)":', registry))
self.assertEqual(
set(),
console_paths - registered,
"控制台在调、主站没注册的桥接路径会静默变成 401",
)
class BridgeErrorMappingTests(unittest.TestCase):
"""主站拒绝入参(Key 不对)不能在中枢这边冒成 500。"""
def _console(self, error: SiteBridgeError) -> SiteConsole:
class Failing:
def call(self, path, payload=None):
raise error
return SiteConsole(Failing())
def test_upstream_rejection_comes_back_as_a_bad_request(self) -> None:
console = self._console(SiteBridgeError("模型列表拉取失败(HTTP 401", 400))
with self.assertRaises(ApiError) as caught:
console.fetch_models({"base_url": "https://api.openai.com/v1", "api_key": "sk-bad"})
self.assertEqual(caught.exception.code, "INVALID_ARGUMENT")
self.assertEqual(caught.exception.status, HTTPStatus.BAD_REQUEST)
def test_unreachable_site_comes_back_as_service_unavailable(self) -> None:
console = self._console(SiteBridgeError("主站不可达:connection refused"))
with self.assertRaises(ApiError) as caught:
console.models()
self.assertEqual(caught.exception.code, "SOURCE_UNAVAILABLE")
self.assertEqual(caught.exception.status, HTTPStatus.SERVICE_UNAVAILABLE)
-1
View File
@@ -20,7 +20,6 @@ class StewardQueryTests(unittest.TestCase):
port=0,
encryption_key=SecretVault.generate_key(),
api_token="k" * 32,
admin_password="StartPass1",
tushare_token="tushare-secret-token-xyz",
db_path=Path(self.tmp.name) / "hub.db",
backup_dir=Path(self.tmp.name) / "backups",
@@ -45,7 +45,6 @@ def make_pipe(transport):
settings = Settings(
encryption_key=SecretVault.generate_key(),
api_token="t" * 32,
admin_password="admin-pass",
tushare_token="test-token",
db_path=db.path,
quality={"max_publish_attempts": 3, "publication_generations": 3},