From 47a67eb663d548c9391984fc5b5fd390a75f51da Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Sat, 6 Jun 2026 10:12:13 +0800 Subject: [PATCH] feat(vnext2): close portal key management ui on real host --- deploy/tksea-portal/index.html | 199 ++++++++++++++---- docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md | 49 ++--- docs/EXECUTION_BOARD.md | 35 ++- scripts/test/test_tksea_portal_assets.sh | 6 +- 4 files changed, 212 insertions(+), 77 deletions(-) diff --git a/deploy/tksea-portal/index.html b/deploy/tksea-portal/index.html index f5b8a8b0..052765d7 100644 --- a/deploy/tksea-portal/index.html +++ b/deploy/tksea-portal/index.html @@ -151,7 +151,7 @@

已有 Key

历史 Key 会优先投影回逻辑分组产品层;你仍可以直接在列表里一键复制,无需先重新创建。

-
GET /api/v1/keys?page=1&page_size=20
+
GET /portal-admin-api/api/keys
@@ -214,7 +214,7 @@
- Access Token + Portal 登录 Token(调试)
@@ -242,6 +242,13 @@
model = 按当前分组对应模型名填写
api_key = 你刚生成的 key
+
+ curl 示例 + +
+ +
+
@@ -335,6 +342,45 @@ }; let toastTimer = null; + function portalSubjectID() { + const user = state.user || {}; + const raw = String(user.id || user.email || user.username || "").trim(); + if (!raw) { + return ""; + } + return user.id ? `portal-user:${raw}` : `portal-email:${raw.toLowerCase()}`; + } + + function selectedLogicalGroupRow() { + const logicalGroupID = String($("group-id").value || state.selectionLogicalGroupID || "").trim(); + state.selectionLogicalGroupID = logicalGroupID; + return logicalGroupStatusRows().find((item) => String(item.logicalGroup.logical_group_id || "") === logicalGroupID) || null; + } + + function selectedPublicModel(row) { + const models = portalLogicalGroupModels(row && row.logicalGroup); + return models[0] || "gpt-5.4"; + } + + function buildCurlExample(plaintextKey, row) { + const model = selectedPublicModel(row); + const apiKey = plaintextKey || ""; + return [ + 'curl https://sub.tksea.top/v1/chat/completions \\\\', + ' -H "Content-Type: application/json" \\\\', + ` -H "Authorization: Bearer ${apiKey}" \\\\`, + " -d '{", + ` \"model\": \"${model}\",`, + ' "messages": [{"role": "user", "content": "ping"}],', + ' "max_tokens": 32', + " }'" + ].join("\n"); + } + + function renderCurlExample(plaintextKey, row) { + $("curl-example").value = buildCurlExample(plaintextKey, row); + } + function $(id) { return document.getElementById(id); } @@ -526,6 +572,35 @@ return payload; } + async function requestControlPlane(path, options = {}) { + const headers = Object.assign({ Accept: "application/json" }, options.headers || {}); + const subjectID = portalSubjectID(); + if (!subjectID) { + throw new Error("当前缺少 portal subject,请先登录"); + } + headers["X-Portal-Subject"] = subjectID; + const res = await fetch("/portal-admin-api/api" + path, { + method: options.method || "GET", + headers, + body: options.body + }); + const text = await res.text(); + let payload; + try { + payload = JSON.parse(text); + } catch { + payload = { raw: text }; + } + if (!res.ok) { + const message = payload.message || payload.raw || ("HTTP " + res.status); + const error = new Error(message); + error.statusCode = res.status; + error.payload = payload; + throw error; + } + return payload; + } + async function requestJSON(path, method, payload, useAuth = true) { return request(path, { method, @@ -1031,15 +1106,15 @@ } function renderSelectionSummary() { - const logicalGroupID = String($("group-id").value || state.selectionLogicalGroupID || "").trim(); - state.selectionLogicalGroupID = logicalGroupID; - const row = logicalGroupStatusRows().find((item) => String(item.logicalGroup.logical_group_id || "") === logicalGroupID); + const row = selectedLogicalGroupRow(); if (!row) { $("selection-summary").innerHTML = "当前还没有可选逻辑分组。"; $("dependency-summary").innerHTML = "当前还没有可用于判断申请状态的逻辑分组。"; $("create-key-btn").disabled = true; + renderCurlExample($("api-key").value.trim(), null); return; } + const logicalGroupID = state.selectionLogicalGroupID; const models = portalLogicalGroupModels(row.logicalGroup); const dependency = dependencyStateForRow(row); const canCreate = !!state.accessToken && dependency.canCreate; @@ -1057,8 +1132,10 @@ '
' + escapeHTML(dependency.badgeText) + '
', '
' + escapeHTML(dependency.lineCountText) + '
', '
' + escapeHTML(dependency.actionHint) + '
', + '
首次成功标准:POST /v1/chat/completions = 200
', (!state.accessToken ? '
当前未登录。登录后才能真正发起 Key 创建请求。
' : '') ].join(""); + renderCurlExample($("api-key").value.trim(), row); } function renderKeys() { @@ -1070,28 +1147,26 @@ } list.innerHTML = items.map((item) => { - const groupID = Number(item.group_id); - const meta = Number.isFinite(groupID) ? knownLegacyGroup(groupID) : null; - const logicalCandidates = meta - ? (state.portalLogicalGroups || []).filter((group) => meta.models.some((model) => portalLogicalGroupModels(group).includes(String(model).trim()))) - : []; - const logicalCandidateText = logicalCandidates.length - ? logicalCandidates.map((group) => group.display_name || group.logical_group_id).join(" / ") - : "未建立逻辑分组映射"; + const row = (state.portalLogicalGroups || []).find((group) => String(group.logical_group_id || "") === String(item.logical_group_id || "")); + const logicalGroupName = row ? (row.display_name || row.logical_group_id) : (item.logical_group_id || "未建立逻辑分组映射"); + const modelText = Array.isArray(item.allowed_models) && item.allowed_models.length ? item.allowed_models.join(", ") : "--"; + const statusClass = String(item.admin_status || "active") === "active" ? "active" : "pending"; return ( '
' + '
' + - '
' + (item.name || "未命名 Key") + '
' + + '
' + escapeHTML(item.display_name || item.key_id || "未命名 Key") + '
' + '
' + - '' + (item.status || "--") + '' + - '' + + '' + escapeHTML(item.admin_status || "--") + '' + + 'quota ' + escapeHTML(item.quota_status || "--") + '' + + '' + '
' + '
' + '
' + - '
Key
' + maskKey(item.key || "") + '
' + - '
申请来源
' + escapeHTML(logicalCandidateText) + '
' + - '
逻辑分组
' + escapeHTML(logicalCandidateText) + '
' + + '
Masked Preview
' + escapeHTML(item.masked_preview || "--") + '
' + + '
逻辑分组
' + escapeHTML(logicalGroupName) + '
' + + '
公开模型
' + escapeHTML(modelText) + '
' + '
创建时间
' + formatDate(item.created_at) + '
' + + '
最近使用
' + formatDate(item.last_used_at) + '
' + '
到期时间
' + formatDate(item.expires_at) + '
' + '
' + '
' @@ -1121,21 +1196,28 @@ state.groups = []; state.subscriptions = []; state.keys = []; + renderCurlExample("", selectedLogicalGroupRow()); renderAll(); return; } try { - const [user, groups, subscriptions, keysPage] = await Promise.all([ + const [user, groups, subscriptions] = await Promise.all([ request("/auth/me"), request("/groups/available"), - request("/subscriptions"), - request("/keys?page=1&page_size=20") + request("/subscriptions") ]); state.user = user || null; state.groups = Array.isArray(groups) ? groups : []; state.subscriptions = Array.isArray(subscriptions) ? subscriptions : []; - state.keys = Array.isArray(keysPage && keysPage.items) ? keysPage.items : []; + try { + const keysPayload = await requestControlPlane("/keys"); + state.keys = Array.isArray(keysPayload && keysPayload.keys) ? keysPayload.keys : []; + } catch (keyErr) { + state.keys = []; + setStatus("key-status", "bad", "CRM key 列表拉取失败: " + keyErr.message); + } + renderCurlExample($("api-key").value.trim(), selectedLogicalGroupRow()); renderAll(); } catch (err) { clearSession(); @@ -1189,30 +1271,36 @@ async function handleCreateKey() { const name = $("key-name").value.trim(); - const logicalGroupID = String($("group-id").value || state.selectionLogicalGroupID || "").trim(); - const row = logicalGroupStatusRows().find((item) => String(item.logicalGroup.logical_group_id || "") === logicalGroupID); + const row = selectedLogicalGroupRow(); if (!row) { setStatus("key-status", "bad", "当前还没有可用于申请测试 Key 的逻辑分组。"); return; } - if (row.enabledCandidates.length !== 1) { - const tip = row.enabledCandidates.length > 1 - ? "当前逻辑分组命中多条申请链路,暂不自动选择,请联系管理员整理归属。" - : "当前逻辑分组尚未命中唯一申请链路,暂不能直接申请测试 Key。"; - setStatus("key-status", "bad", tip); + const logicalGroupID = String(row.logicalGroup.logical_group_id || "").trim(); + if (!name) { + setStatus("key-status", "bad", "Key 名称不能为空。"); + return; + } + const dependency = dependencyStateForRow(row); + if (!dependency.canCreate) { + setStatus("key-status", "bad", dependency.actionHint); return; } - const legacyGroup = row.enabledCandidates[0]; setBusy("create-key-btn", true); try { - const data = await requestJSON("/keys", "POST", { - name, - group_id: Number(legacyGroup.id) - }, true); - state.lastCreatedKey = data.key || ""; + const payload = await requestControlPlane("/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + logical_group_id: logicalGroupID, + display_name: name, + allowed_models: portalLogicalGroupModels(row.logicalGroup) + }) + }); + state.lastCreatedKey = String(payload.plaintext_key || ""); $("api-key").value = state.lastCreatedKey; - setStatus("key-status", "ok", "Key 创建成功。已按逻辑分组“" + (row.logicalGroup.display_name || row.logicalGroup.logical_group_id) + "”的已就绪申请链路发放测试 Key。"); - renderSelectionSummary(); + renderCurlExample(state.lastCreatedKey, row); + setStatus("key-status", "ok", "Key 创建成功。明文只显示这一次,请立即复制;验收目标是用这把 key 完成一次 /v1/chat/completions=200。\nkey_id=" + (payload.key && payload.key.key_id ? payload.key.key_id : "--")); await refreshUserState(); } catch (err) { setStatus("key-status", "bad", "创建 Key 失败: " + err.message); @@ -1226,8 +1314,31 @@ await copyText(value, statusID, label, button); } - - + async function handleResetExistingKey(keyID, button) { + if (!keyID) { + setStatus("key-status", "bad", "缺少 key_id,无法重置。"); + return; + } + button.disabled = true; + try { + const payload = await requestControlPlane(`/keys/${encodeURIComponent(keyID)}/reset`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}" + }); + const row = selectedLogicalGroupRow(); + state.lastCreatedKey = String(payload.plaintext_key || ""); + $("api-key").value = state.lastCreatedKey; + renderCurlExample(state.lastCreatedKey, row); + setStatus("key-status", "ok", "Key 已重置并返回新明文,请立即复制。key_id=" + keyID); + await refreshUserState(); + } catch (err) { + setStatus("key-status", "bad", "重置 Key 失败: " + err.message); + } finally { + button.disabled = false; + } + } + $("create-key-btn").addEventListener("click", handleCreateKey); $("refresh-session-btn").addEventListener("click", refreshUserState); $("logout-btn").addEventListener("click", () => { @@ -1239,17 +1350,19 @@ $("auth-password").addEventListener("keydown", (e) => { if (e.key === "Enter") $("auth-btn").click(); }); $("copy-token-btn").addEventListener("click", (event) => copyField("access-token", "auth-status", "Access Token", event.currentTarget)); $("copy-key-btn").addEventListener("click", (event) => copyField("api-key", "key-status", "API Key", event.currentTarget)); + $("copy-curl-btn").addEventListener("click", (event) => copyField("curl-example", "key-status", "curl 示例", event.currentTarget)); $("keys-list").addEventListener("click", async (event) => { - const button = event.target.closest(".copy-existing-key-btn"); - if (!button) { + const resetButton = event.target.closest(".reset-existing-key-btn"); + if (!resetButton) { return; } - await copyText(button.dataset.key || "", "key-status", "已有 Key", button); + await handleResetExistingKey(resetButton.dataset.keyId || "", resetButton); }); $("group-id").addEventListener("change", renderSelectionSummary); restoreSession(); renderAll(); + renderCurlExample("", null); refreshUserState(); diff --git a/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md b/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md index 07feb1c4..587cb9bc 100644 --- a/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md +++ b/docs/2026-06-05-VNEXT_COMPLETION_CHECKLIST.md @@ -15,21 +15,21 @@ 说明: - vNext.1 已完成代码/文档/发布闭环。 -- vNext.2 当前只完成了 V2-4:key self-service API + 用户首次调用 200 真实线上闭环。 -- vNext.2 的 V2-5(portal key 管理 UI)尚未开始验收,vNext.3(治理/SLO)尚未开始实现。 -- 因此按“全量 vNext goal”口径仍然是未完成;按阶段口径可判定:vNext.1 完成、vNext.2 部分完成(V2-4 已完成)。 +- vNext.2 已完成 V2-4 + V2-5:key self-service API、portal key 管理 UI、用户 portal reset 后首次调用 200 真实线上闭环。 +- vNext.3(治理/SLO)尚未开始实现。 +- 因此按“全量 vNext goal”口径仍然是未完成;按阶段口径可判定:vNext.1 完成、vNext.2 完成、vNext.3 未完成。 ## 二、5 个核心问题 Checklist(全量 vNext 目标) 真相源:`docs/EXECUTION_BOARD.md` -| 问题 | 规划要求 | 当前状态 | 证据 | -| ---------------------------------------------------- | -------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| 1. 宿主协议稳定支持哪些主流大模型 | 必须有真实协议矩阵 + 真实验收脚本 + 当前输出 | vNext.1 已闭环 | `verify_host_protocol_matrix.sh` 与相关 artifact 已存在 | -| 2. 同模型多供应商池化 | 模型池抽象 + 映射 + 真实池化验收 | vNext.1 已闭环 | `model_pool.go`、pool 测试、真实验收脚本已存在 | -| 3. 插件前端承接用户弱能力 | Portal 能承接用户信息、模型、示例、key 信息 | V2-5 待完成 | `PORTAL_KEY_EXPERIENCE.md` 已审核通过,但 UI 闭环尚未完成 | -| 4. 插件生成/申请 key 并交付 base URL/model/curl 示例 | key self-service API + 首次调用 200 闭环 | V2-4 已完成 | `KEY_SELF_SERVICE_API.md`、`verify_user_key_self_service.sh`、`artifacts/user-key-self-service/20260605_195408/99-summary.json` | -| 5. key / 账号暂停、恢复、限额治理 | 三态模型 + 管理页动作 + 真实治理验收 | V3-1 待完成 | `KEY_ACCOUNT_GOVERNANCE.md` 仅设计存在,真实治理实现未开始 | +| 问题 | 规划要求 | 当前状态 | 证据 | +| ---------------------------------------------------- | -------------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1. 宿主协议稳定支持哪些主流大模型 | 必须有真实协议矩阵 + 真实验收脚本 + 当前输出 | vNext.1 已闭环 | `verify_host_protocol_matrix.sh` 与相关 artifact 已存在 | +| 2. 同模型多供应商池化 | 模型池抽象 + 映射 + 真实池化验收 | vNext.1 已闭环 | `model_pool.go`、pool 测试、真实验收脚本已存在 | +| 3. 插件前端承接用户弱能力 | Portal 能承接用户信息、模型、示例、key 信息 | V2-5 已完成 | `PORTAL_KEY_EXPERIENCE.md`、`deploy/tksea-portal/index.html`、`artifacts/portal-ui-v25/20260606_1009/99-summary.json` | +| 4. 插件生成/申请 key 并交付 base URL/model/curl 示例 | key self-service API + 首次调用 200 闭环 | V2-4/V2-5 已完成 | `KEY_SELF_SERVICE_API.md`、`verify_user_key_self_service.sh`、`artifacts/user-key-self-service/20260605_195408/99-summary.json`、`artifacts/portal-ui-v25/20260606_1009/99-summary.json` | +| 5. key / 账号暂停、恢复、限额治理 | 三态模型 + 管理页动作 + 真实治理验收 | V3-1 待完成 | `KEY_ACCOUNT_GOVERNANCE.md` 仅设计存在,真实治理实现未开始 | ## 三、vNext.1 发布范围 Checklist @@ -83,12 +83,12 @@ ### Phase 3(vNext.2) -- Task 3.1 用户信息架构设计:设计已存在 +- Task 3.1 用户信息架构设计:设计已存在并已按设计接线到 portal - Task 3.2 key 发放 API:已实现并上线验证 - Task 3.3 用户首次调用闭环:已完成真实 `chat/completions=200` -- 尚缺:portal key 管理 UI(V2-5) +- Task 3.4 portal key 管理 UI:已完成真实登录、已有 Key、reset、新明文、curl 示例与首呼 200 闭环 -状态:部分完成(V2-4 已闭环,V2-5 未完成) +状态:vNext.2 已闭环 ### Phase 4(vNext.3) @@ -118,7 +118,7 @@ ### vNext.2 尚缺 -- `deploy/tksea-portal/` 中 portal key 管理 UI 的实现与真实前端验收(V2-5) +- 无 ### vNext.3 尚缺 @@ -130,22 +130,13 @@ 1. ✅ vNext.1 全部 5 项发布项已完成代码/文档/发布闭环 2. ✅ V2-4 已完成后端实现、线上部署、真实 user-key 首呼 200 验收 -3. ✅ user-key artifact 已补齐:`artifacts/user-key-self-service/20260605_195408/99-summary.json` -4. ⚠️ V2-5 portal key 管理 UI 未完成 +3. ✅ V2-5 已完成 portal 登录→已有 Key→reset 新明文→curl 示例更新→真实首呼 200 闭环 +4. ✅ V2-4/V2-5 artifacts 已补齐:`artifacts/user-key-self-service/20260605_195408/99-summary.json`、`artifacts/portal-ui-v25/20260606_1009/99-summary.json` 5. ⚠️ V3-1 key/account governance + SLO 未完成 ## 七、最短下一步路径 -### 立即执行:V2-5 - -1. 实现 `deploy/tksea-portal/` 的 key 管理 UI -2. 执行前端门禁: - - `bash ./scripts/test/test_tksea_portal_assets.sh` - - `bash ./scripts/test/verify_frontend_smoke.sh` - - 若涉及显式动作,执行 `bash ./scripts/acceptance/verify_provider_admin_actions.sh` 或对应 portal 验收 -3. 部署 remote43 并做公网页面验收 - -### 然后执行:V3-1 +### 立即执行:V3-1 1. 实现 key/account governance 状态模型 2. 补治理 API / 测试 / 验收脚本 @@ -154,8 +145,8 @@ ## 八、当前判定(唯一有效口径) - 按 vNext.1 发布范围:**完成** -- 按 vNext.2 当前执行项:**部分完成**(V2-4 完成,V2-5 未完成) +- 按 vNext.2 当前执行项:**完成**(V2-4 + V2-5 已真实闭环) - 按全量 vNext 规划:**未完成** - 当前结论: - - V2-4 已真实闭环,可进入提交/推送 - - 继续推进 V2-5(portal key UI)与 V3-1(governance)后,才能宣告全量 goal 完成 + - V2-4 / V2-5 已真实闭环,可提交/推送 + - 继续推进 V3-1(governance)后,才能宣告全量 goal 完成 diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md index b6b2162f..99d7294d 100644 --- a/docs/EXECUTION_BOARD.md +++ b/docs/EXECUTION_BOARD.md @@ -58,9 +58,38 @@ - `POST https://sub.tksea.top/v1/chat/completions` with user key -> `200` - 当前结论: - vNext.2 / V2-4(key self-service API + 用户首次调用 200 闭环)已完成真实线上闭环 - - 仍未完成的 vNext 范围为: - - V2-5 portal key 管理 UI - - V3-1 key/account governance + SLO/治理闭环 + - 后续仍需完成 V2-5 portal key 管理 UI 与 V3-1 governance + +## 2026-06-06 vNext.2 / V2-5 真实闭环 + +- portal key 管理 UI 已完成实现、部署和真实公网验收: + - 关键代码: + - `deploy/tksea-portal/index.html` + - `scripts/test/test_tksea_portal_assets.sh` + - 关键运行时接线: + - 页面使用 `/portal-admin-api/api/keys` + - `X-Portal-Subject` 由登录用户稳定生成(`portal-user:` / `portal-email:`) + - 已有 Key 列表展示 `masked_preview` / `allowed_models` / `quota_status` + - reset 动作回填新明文 key 与 curl 示例 +- 真实线上验收: + - 创建临时 smoke 用户(宿主管理 API)-> `200` + - 预发 CRM key `POST /portal-admin-api/api/keys` -> `201` + - 浏览器真实登录 `https://sub.tksea.top/portal/`: + - 已登录 + - 已激活产品权限 = `1` + - 已有 KEY = `1` + - 列表出现 `ui smoke key`、masked preview `sk-****e2f0` + - 存在 `重置并显示新明文` 按钮 + - 浏览器真实 reset 后: + - 结果区出现新明文 key + - 列表 masked preview 更新为 `sk-****99fa` + - curl 示例自动注入 reset 后新 key + - 使用 reset 后新 key 调用 `POST https://sub.tksea.top/v1/chat/completions` -> `200`,响应 `pong` +- artifact: + - `artifacts/portal-ui-v25/20260606_1009/99-summary.json` +- 当前结论: + - vNext.2 / V2-5(portal key 管理 UI)已完成真实线上闭环 + - 剩余未完成范围:V3-1 key/account governance + SLO/治理闭环 ## 2026-05-22 当前真相 diff --git a/scripts/test/test_tksea_portal_assets.sh b/scripts/test/test_tksea_portal_assets.sh index 0b23006f..c9d896c0 100755 --- a/scripts/test/test_tksea_portal_assets.sh +++ b/scripts/test/test_tksea_portal_assets.sh @@ -49,8 +49,10 @@ assert_contains_file "$HTML_FILE" "localStorage.setItem" assert_contains_file "$HTML_FILE" "/auth/me" assert_contains_file "$HTML_FILE" "/groups/available" assert_contains_file "$HTML_FILE" "/subscriptions" -assert_contains_file "$HTML_FILE" "/keys?page=1&page_size=20" -assert_contains_file "$HTML_FILE" "copy-existing-key-btn" +assert_contains_file "$HTML_FILE" "/portal-admin-api/api/keys" +assert_contains_file "$HTML_FILE" "requestControlPlane(\"/keys\")" +assert_contains_file "$HTML_FILE" "reset-existing-key-btn" +assert_contains_file "$HTML_FILE" "curl-example" assert_contains_file "$HTML_FILE" "已有 Key" assert_contains_file "$HTML_FILE" "showToast" assert_contains_file "$HTML_FILE" "逻辑分组目录"