feat(HEL-560): 数据中枢接管数据源/模型池/会员,注册改一次性邀请码

主站
- 新增 m0006 invite_codes 迁移;注册强制邀请码(首个管理员除外),消码与建号
  同一事务,并发提交只有一个能成功
- 新增 /api/hub-admin/* 服务端点(共享 HUB_ADMIN_TOKEN,先于鉴权校验),供数据
  中枢桥接读写会话/密码/模型池/会员/邀请码,并提供供应商模型列表拉取
- 前端:注册表单加邀请码(桌面 login、index.html、移动端);「系统管理」改为
  「数据中枢」入口指向 8766,原模型池与会员管理分区移除,仅留「行情管理」;
  随之清理陈旧 CSS

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

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

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
施工员
2026-09-16 11:44:09 +08:00
co-authored by multica-agent
parent 3203574b6a
commit 3eaa36a8d5
69 changed files with 3678 additions and 1573 deletions
+7 -2
View File
@@ -4,8 +4,13 @@ DATAHUB_ENCRYPTION_KEY=
# Consumer API token for /v1 (32+ random bytes, shown once). Never log this value.
DATAHUB_TOKEN=
# Initial admin password for /admin. Forced change on first login.
DATAHUB_ADMIN_PASSWORD=
# 控制台没有独立账号:/admin 用小白复盘主站的管理员账号登录(HEL-560)。
# 主站的服务端地址(容器内互访)+ 双方共享的桥接令牌,两者缺一控制台无法校验登录。
REVIEW_BASE_URL=http://xiaobai-review:8765
HUB_ADMIN_TOKEN=
# 浏览器可达的主站地址;留空时按请求 Host 推导 http://<host>:8765。
REVIEW_PUBLIC_URL=
# 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,因此在主站登录后直接打开 8766 即可进入;未登录会看到门禁面板并给出主站登录入口。
- 仅管理员可进。每个页面与每个 `/admin/api/*` 接口都在服务端校验会话与 `role=admin`,非管理员一律 403,前端隐藏与否不作为权限依据。
- 校验方式是服务间桥接:中枢把 cookie 交给主站 `/api/hub-admin/session` 换回用户身份,结果缓存数秒。桥接凭 `HUB_ADMIN_TOKEN`(与主站 `.env` 同名变量必须一致),主站在任何处理器之前先校验它。
- CSRF 令牌由会话派生(HMAC),随 `GET /admin/api/session` 下发,写操作必须带 `X-CSRF-Token`
- 回滚、回补等危险操作仍需二次确认密码,校验走主站 `/api/hub-admin/password/check`,中枢不存密码。
- 「退出」会请求主站注销该会话并跳回主站登录页。
需要的环境变量:
| 变量 | 位置 | 说明 |
|---|---|---|
| `HUB_ADMIN_TOKEN` | 主站 + 中枢 | 服务间桥接令牌,两侧必须一致,缺失则控制台无法校验会话 |
| `REVIEW_BASE_URL` | 中枢 | 中枢访问主站的地址(容器内一般是服务名,如 `http://xiaobai-review:8765` |
| `REVIEW_PUBLIC_URL` | 中枢 | 浏览器可达的主站地址,用于门禁的登录跳转;留空则按当前主机名推导 |
## 从主站迁入的两块配置
- **模型池**:按供应商组织(同一 API 地址下可挂多个模型),填好地址与 Key 后可自动拉取 `/models` 勾选纳入;供应商不支持或拉取失败时用卡内「手动录入」兜底。主 / 辅模型分工在「调用编排」里指定。密钥加密存于主站,界面只回显后四位。
- **会员与邀请码**:会员开通 / 续期 / 停用、每日调用额度,以及一次性邀请码的生成、复制、作废。注册必须提交有效邀请码,每个码只能成功注册一次(并发提交也只有一个成功)。列表只显示掩码,完整码仅在生成瞬间与「复制」动作中可得。
数据仍归主站所有(同一个 `review.db`),中枢只是唯一的管理入口;主站页面上原本的模型池与会员管理分区已移除,「数据中枢」按钮指向 8766。
## Docker(独立 compose,不改现网 review 服务)
```bash
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:-}"
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,
+100 -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,9 @@ 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.siteauth import SITE_SESSION_COOKIE, SiteBridgeError
LOGGER = get_logger()
SESSION_COOKIE = "datahub_session"
class HubRequestHandler(BaseHTTPRequestHandler):
@@ -85,37 +84,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 +185,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 +197,73 @@ 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.
In production both services sit on the same host behind different
ports, so the console derives the site URL from the Host header the
browser used; REVIEW_PUBLIC_URL overrides that when they do not.
"""
configured = self.hub.settings.review_public_url
if configured:
return configured.rstrip("/")
host = (self.headers.get("Host") or "").split(":")[0] or "127.0.0.1"
return f"http://{host}:8765"
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 +312,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
+9 -2
View File
@@ -24,7 +24,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 +127,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 "").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
+101 -38
View File
@@ -17,25 +17,39 @@ 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 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,96 @@ 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_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 +167,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},