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:
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
+784
-159
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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,
|
||||
|
||||
@@ -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:-}"
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"))
|
||||
@@ -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 "自定义供应商"
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user