feat(vnext2): close portal key management ui on real host
Some checks are pending
CI / Build & Test (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Security Scan (push) Waiting to run
CI / Docker Build (push) Waiting to run
CI / Release (push) Blocked by required conditions

This commit is contained in:
phamnazage-jpg
2026-06-06 10:12:13 +08:00
parent 5b59ad7490
commit 47a67eb663
4 changed files with 212 additions and 77 deletions

View File

@@ -151,7 +151,7 @@
<h2>已有 Key</h2>
<p>历史 Key 会优先投影回逻辑分组产品层;你仍可以直接在列表里一键复制,无需先重新创建。</p>
</div>
<div class="tiny mono">GET /api/v1/keys?page=1&page_size=20</div>
<div class="tiny mono">GET /portal-admin-api/api/keys</div>
</div>
<div id="keys-list" class="list"></div>
</article>
@@ -214,7 +214,7 @@
</div>
<div class="result-card">
<div class="result-box">
<strong>Access Token</strong>
<strong>Portal 登录 Token(调试)</strong>
<textarea id="access-token" readonly></textarea>
<div class="button-row">
<button id="copy-token-btn" class="ghost inline">复制 Access Token</button>
@@ -242,6 +242,13 @@
<div class="mono">model = 按当前分组对应模型名填写</div>
<div class="mono">api_key = 你刚生成的 key</div>
</div>
<div class="result-box">
<strong>curl 示例</strong>
<textarea id="curl-example" readonly></textarea>
<div class="button-row">
<button id="copy-curl-btn" class="ghost inline">复制 curl 示例</button>
</div>
</div>
</div>
<p class="footer-note">如果某个逻辑分组显示“待补开通”,说明你的账号还没有对应的申请 Key 依赖链路。此时可以先注册并登录,再联系管理员补开通,无需重新创建账号。</p>
<p class="footer-note">当前页面已经把“目录、权限、订阅、Key”统一投影到逻辑分组产品层宿主兼容分组只作为过渡实现细节保留在后端发放流程里不再作为普通用户主视角。</p>
@@ -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 || "<YOUR_API_KEY>";
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 @@
'<div><strong>' + escapeHTML(dependency.badgeText) + '</strong></div>',
'<div>' + escapeHTML(dependency.lineCountText) + '</div>',
'<div>' + escapeHTML(dependency.actionHint) + '</div>',
'<div>首次成功标准:<span class="mono">POST /v1/chat/completions = 200</span></div>',
(!state.accessToken ? '<div>当前未登录。登录后才能真正发起 Key 创建请求。</div>' : '')
].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 (
'<article class="key-item">' +
'<div class="key-top">' +
'<div class="key-name">' + (item.name || "未命名 Key") + '</div>' +
'<div class="key-name">' + escapeHTML(item.display_name || item.key_id || "未命名 Key") + '</div>' +
'<div class="section-actions">' +
'<span class="badge ' + (String(item.status || "") === "active" ? "active" : "pending") + '">' + (item.status || "--") + '</span>' +
'<button class="ghost inline copy-existing-key-btn" data-key="' + (item.key || "") + '">复制 Key</button>' +
'<span class="badge ' + statusClass + '">' + escapeHTML(item.admin_status || "--") + '</span>' +
'<span class="badge">quota ' + escapeHTML(item.quota_status || "--") + '</span>' +
'<button class="ghost inline reset-existing-key-btn" data-key-id="' + escapeHTML(item.key_id || "") + '">重置并显示新明文</button>' +
'</div>' +
'</div>' +
'<div class="key-meta">' +
'<div><span class="kv-label">Key</span><div class="mono">' + maskKey(item.key || "") + '</div></div>' +
'<div><span class="kv-label">申请来源</span><div>' + escapeHTML(logicalCandidateText) + '</div></div>' +
'<div><span class="kv-label">逻辑分组</span><div>' + escapeHTML(logicalCandidateText) + '</div></div>' +
'<div><span class="kv-label">Masked Preview</span><div class="mono">' + escapeHTML(item.masked_preview || "--") + '</div></div>' +
'<div><span class="kv-label">逻辑分组</span><div>' + escapeHTML(logicalGroupName) + '</div></div>' +
'<div><span class="kv-label">公开模型</span><div class="mono">' + escapeHTML(modelText) + '</div></div>' +
'<div><span class="kv-label">创建时间</span><div>' + formatDate(item.created_at) + '</div></div>' +
'<div><span class="kv-label">最近使用</span><div>' + formatDate(item.last_used_at) + '</div></div>' +
'<div><span class="kv-label">到期时间</span><div>' + formatDate(item.expires_at) + '</div></div>' +
'</div>' +
'</article>'
@@ -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();
</script>
<script src="/portal/portal.js"></script>

View File

@@ -15,21 +15,21 @@
说明:
- vNext.1 已完成代码/文档/发布闭环。
- vNext.2 当前只完成 V2-4key self-service API + 用户首次调用 200 真实线上闭环。
- vNext.2 的 V2-5portal key 管理 UI尚未开始验收vNext.3(治理/SLO尚未开始实现。
- 因此按“全量 vNext goal”口径仍然是未完成按阶段口径可判定vNext.1 完成、vNext.2 部分完成V2-4 已完成
- vNext.2 完成 V2-4 + V2-5key 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 3vNext.2
- Task 3.1 用户信息架构设计:设计已存在
- Task 3.1 用户信息架构设计:设计已存在并已按设计接线到 portal
- Task 3.2 key 发放 API已实现并上线验证
- Task 3.3 用户首次调用闭环:已完成真实 `chat/completions=200`
- 尚缺:portal key 管理 UIV2-5
- Task 3.4 portal key 管理 UI:已完成真实登录、已有 Key、reset、新明文、curl 示例与首呼 200 闭环
状态:部分完成V2-4 已闭环V2-5 未完成)
状态:vNext.2 已闭环
### Phase 4vNext.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-5portal key UI V3-1governance才能宣告全量 goal 完成
- V2-4 / V2-5 已真实闭环,可提交/推送
- 继续推进 V3-1governance才能宣告全量 goal 完成

View File

@@ -58,9 +58,38 @@
- `POST https://sub.tksea.top/v1/chat/completions` with user key -> `200`
- 当前结论:
- vNext.2 / V2-4key 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:<id>` / `portal-email:<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-5portal key 管理 UI已完成真实线上闭环
- 剩余未完成范围V3-1 key/account governance + SLO/治理闭环
## 2026-05-22 当前真相

View File

@@ -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" "逻辑分组目录"