feat(accounts): add provider account admin view

This commit is contained in:
phamnazage-jpg
2026-05-29 15:50:28 +08:00
parent 82f3636521
commit c982c595b8
14 changed files with 1352 additions and 93 deletions

View File

@@ -374,6 +374,7 @@
<a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/accounts.html">帐号资产</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html" class="is-current">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>

View File

@@ -0,0 +1,931 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Provider Accounts Admin</title>
<style>
:root {
--bg: #f3ede4;
--panel: rgba(255, 252, 246, 0.94);
--ink: #201b17;
--muted: #685d54;
--line: rgba(32, 27, 23, 0.12);
--accent: #0b6bcb;
--accent-soft: rgba(11, 107, 203, 0.12);
--success: #126b43;
--success-soft: rgba(18, 107, 67, 0.1);
--warn: #9b6215;
--warn-soft: rgba(155, 98, 21, 0.12);
--danger: #b23131;
--danger-soft: rgba(178, 49, 49, 0.1);
--shadow: 0 26px 72px rgba(47, 38, 29, 0.1);
--radius: 24px;
--radius-sm: 16px;
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
--font-mono: "IBM Plex Mono", "JetBrains Mono", monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
color: var(--ink);
font-family: var(--font-sans);
background:
radial-gradient(circle at top left, rgba(11, 107, 203, 0.16), transparent 26rem),
radial-gradient(circle at bottom right, rgba(18, 107, 67, 0.12), transparent 24rem),
var(--bg);
}
a { color: inherit; }
code, pre {
font-family: var(--font-mono);
font-size: 12px;
}
.shell {
max-width: 1500px;
margin: 0 auto;
padding: 34px 20px 64px;
}
.topnav {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 18px;
}
.topnav a {
text-decoration: none;
padding: 10px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.78);
color: var(--muted);
font-size: 13px;
font-weight: 700;
transition: transform 120ms ease, background 120ms ease;
}
.topnav a:hover { transform: translateY(-1px); background: #fff; }
.topnav a.is-current {
background: var(--ink);
border-color: var(--ink);
color: #fff;
}
.hero {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
margin-bottom: 18px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.hero-card, .panel {
padding: 26px;
}
.hero-card {
position: relative;
overflow: hidden;
}
.hero-card::after {
content: "";
position: absolute;
right: -4rem;
bottom: -4rem;
width: 18rem;
height: 18rem;
border-radius: 999px;
background: linear-gradient(135deg, rgba(11, 107, 203, 0.18), rgba(18, 107, 67, 0.06));
filter: blur(10px);
}
.eyebrow {
display: inline-flex;
align-items: center;
padding: 8px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
h1 {
margin: 18px 0 10px;
font-size: clamp(32px, 4vw, 46px);
line-height: 1.02;
letter-spacing: -0.05em;
}
h2 {
margin: 0 0 8px;
font-size: 24px;
letter-spacing: -0.04em;
}
.hero-copy, .panel-desc {
color: var(--muted);
line-height: 1.75;
font-size: 15px;
}
.hero-points {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 18px 0 0;
padding: 0;
list-style: none;
}
.hero-points li {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.78);
font-size: 13px;
font-weight: 700;
}
.metrics {
display: grid;
gap: 12px;
align-content: start;
}
.metric {
border-radius: 20px;
border: 1px solid var(--line);
background: #fff;
padding: 16px;
}
.metric-label {
color: var(--muted);
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.metric-value {
margin-top: 8px;
font-size: 24px;
font-weight: 800;
letter-spacing: -0.04em;
word-break: break-word;
}
.layout {
display: grid;
grid-template-columns: 440px minmax(0, 1fr);
gap: 18px;
}
.stack {
display: grid;
gap: 18px;
}
.field-grid {
display: grid;
gap: 12px;
}
.field-grid.two {
grid-template-columns: 1fr 1fr;
}
.field-grid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
label {
display: grid;
gap: 7px;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
input, select, textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
font: inherit;
color: var(--ink);
background: #fff;
}
textarea {
min-height: 84px;
resize: vertical;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
button {
border: 0;
cursor: pointer;
border-radius: 999px;
padding: 12px 18px;
font: inherit;
font-weight: 800;
transition: transform 120ms ease, opacity 120ms ease, background 120ms ease;
}
button:hover { transform: translateY(-1px); }
button:disabled { cursor: not-allowed; opacity: 0.6; transform: none; }
.primary { background: var(--ink); color: #fff; }
.secondary { background: var(--accent-soft); color: var(--accent); }
.ghost {
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
}
.danger {
background: var(--danger-soft);
color: var(--danger);
border: 1px solid rgba(178, 49, 49, 0.2);
}
.statusbar {
margin-top: 16px;
min-height: 54px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: #fff;
display: flex;
align-items: center;
color: var(--muted);
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
}
.catalog {
display: grid;
gap: 12px;
}
.row-card {
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.86);
padding: 16px;
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.row-card:hover {
transform: translateY(-1px);
border-color: rgba(11, 107, 203, 0.24);
box-shadow: 0 16px 36px rgba(47, 38, 29, 0.08);
}
.row-card.is-selected {
border-color: rgba(11, 107, 203, 0.34);
background: linear-gradient(180deg, rgba(11, 107, 203, 0.08), rgba(255,255,255,0.96));
}
.row-heading {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: start;
}
.row-title {
font-size: 16px;
font-weight: 800;
letter-spacing: -0.03em;
word-break: break-word;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.badge.active {
background: var(--success-soft);
color: var(--success);
}
.badge.disabled {
background: rgba(120, 113, 108, 0.12);
color: var(--muted);
}
.badge.deprecated {
background: var(--warn-soft);
color: var(--warn);
}
.badge.broken {
background: var(--danger-soft);
color: var(--danger);
}
.meta-list {
display: grid;
gap: 8px;
margin-top: 14px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 16px;
}
.detail-card {
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.84);
padding: 16px;
}
.detail-card strong {
display: block;
margin-bottom: 6px;
font-size: 14px;
}
.detail-card span, .detail-card code {
color: var(--muted);
line-height: 1.6;
word-break: break-word;
}
.empty {
padding: 18px;
border-radius: 18px;
border: 1px dashed var(--line);
color: var(--muted);
background: rgba(255,255,255,0.58);
}
.raw-json {
margin-top: 16px;
background: #161311;
color: #f6efe8;
border-radius: 18px;
padding: 16px;
min-height: 180px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 1200px) {
.hero, .layout, .field-grid.two, .field-grid.three, .detail-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="shell">
<nav class="topnav" aria-label="Admin Navigation">
<a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/accounts.html" class="is-current">帐号资产</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
</nav>
<section class="hero">
<article class="card hero-card">
<div class="eyebrow">Provider Accounts</div>
<h1>把导入结果升级成可读、可筛选、可启停的帐号资产库存</h1>
<p class="hero-copy">
这页直接消费 <code>/api/provider-accounts</code> 与三个启停动作,把每条供应商帐号摊开到
<code>provider / logical_group / route / shadow_group / shadow_host</code> 维度。
当前首版明确只修改插件 SQLite 里的帐号资产状态,不假装已经联动修改宿主 account 记录。
</p>
<ul class="hero-points">
<li>默认 API Base<code>/portal-admin-api</code></li>
<li>列表会先做一次 provider_accounts 回填</li>
<li>人工 disabled / deprecated 不会被列表刷新刷回 active</li>
</ul>
</article>
<aside class="card metrics">
<div class="metric">
<div class="metric-label">API Root</div>
<div class="metric-value" id="metric-api-root">-</div>
</div>
<div class="metric">
<div class="metric-label">Accounts</div>
<div class="metric-value" id="metric-total">0</div>
</div>
<div class="metric">
<div class="metric-label">Active / Disabled</div>
<div class="metric-value" id="metric-live">0 / 0</div>
</div>
<div class="metric">
<div class="metric-label">Deprecated / Broken</div>
<div class="metric-value" id="metric-dead">0 / 0</div>
</div>
</aside>
</section>
<section class="layout">
<div class="stack">
<article class="card panel">
<h2>连接与过滤</h2>
<p class="panel-desc">
这页默认优先走管理员 session也保留 Bearer token 兜底。过滤只影响读取列表,不会修改帐号状态。
</p>
<div class="field-grid">
<label>
API Base
<input id="api-base" type="text" value="/portal-admin-api">
</label>
<label>
Bearer Admin Token可选
<input id="admin-token" type="password" placeholder="未启用 session 时可填">
</label>
</div>
<div class="field-grid two" style="margin-top:12px;">
<label>
管理员用户名
<input id="admin-username" type="text" placeholder="portal-admin">
</label>
<label>
管理员密码
<input id="admin-password" type="password" placeholder="请输入当前实例管理员密码">
</label>
</div>
<div class="actions">
<button class="secondary" id="admin-login-btn" type="button">管理员登录</button>
<button class="ghost" id="admin-logout-btn" type="button">退出会话</button>
<button class="ghost" id="save-config-btn" type="button">保存本地配置</button>
<button class="ghost" id="refresh-btn" type="button">刷新帐号库存</button>
</div>
<div class="statusbar" id="session-status">正在检查管理员会话…</div>
<div class="field-grid three" style="margin-top:18px;">
<label>
host_id
<input id="filter-host-id" type="text" placeholder="例如 remote43">
</label>
<label>
provider_id
<input id="filter-provider-id" type="text" placeholder="例如 gpt-asxs-shadow-lab">
</label>
<label>
logical_group_id
<input id="filter-logical-group-id" type="text" placeholder="例如 gpt-shared">
</label>
</div>
<div class="field-grid three" style="margin-top:12px;">
<label>
route_id
<input id="filter-route-id" type="text" placeholder="例如 asxs-primary">
</label>
<label>
shadow_group_id
<input id="filter-shadow-group-id" type="text" placeholder="例如 9">
</label>
<label>
account_status
<select id="filter-status">
<option value="">全部状态</option>
<option value="active">active</option>
<option value="disabled">disabled</option>
<option value="deprecated">deprecated</option>
<option value="broken">broken</option>
</select>
</label>
</div>
<div class="field-grid two" style="margin-top:12px;">
<label>
搜索
<input id="filter-query" type="text" placeholder="provider / logical_group / host_account / fingerprint">
</label>
<label>
limit
<input id="filter-limit" type="number" min="1" max="500" value="200">
</label>
</div>
<div class="actions">
<button class="primary" id="apply-filters-btn" type="button">应用过滤</button>
<button class="ghost" id="clear-filters-btn" type="button">清空过滤</button>
</div>
<div class="statusbar" id="table-status">帐号库存结果会显示在这里。</div>
</article>
<article class="card panel">
<h2>帐号资产清单</h2>
<p class="panel-desc">
选中一条帐号后,右侧会展示完整归属和当前启停操作。未补齐 route 的帐号不会被隐藏,而是明确显示为“未归属”。
</p>
<div class="catalog" id="accounts-catalog">
<div class="empty">还没有帐号库存数据。</div>
</div>
</article>
</div>
<article class="card panel">
<h2>帐号归属详情</h2>
<p class="panel-desc">
这里回答三个问题:这条帐号属于谁、挂到哪条 route、当前是人工停用还是自动探测异常。所有启停动作都只改插件库存状态。
</p>
<p class="panel-desc">
当前显式使用的动作接口是:
<code>/api/provider-accounts/{account_id}/enable</code>
<code>/api/provider-accounts/{account_id}/disable</code>
<code>/api/provider-accounts/{account_id}/retire</code>
</p>
<div class="field-grid" style="margin-top:12px;">
<label>
状态变更原因
<textarea id="action-reason" placeholder="例如 manual_disable / quota_pause / provider_rotation"></textarea>
</label>
</div>
<div class="actions">
<button class="secondary" id="enable-btn" type="button" disabled>启用帐号</button>
<button class="ghost" id="disable-btn" type="button" disabled>停用帐号</button>
<button class="danger" id="retire-btn" type="button" disabled>标记退役</button>
</div>
<div class="statusbar" id="action-status">请选择左侧一条帐号记录。</div>
<div id="detail-empty" class="empty" style="margin-top:16px;">选择左侧一条帐号后,这里会显示 route / shadow group / logical group 归属详情。</div>
<div id="detail-panel" hidden>
<div class="detail-grid" id="detail-grid"></div>
<pre class="raw-json" id="detail-json">{}</pre>
</div>
</article>
</section>
</main>
<script>
const storageKey = "sub2api-provider-accounts-admin";
const state = {
accounts: [],
selectedAccountID: 0,
};
const apiBaseInput = document.getElementById("api-base");
const adminTokenInput = document.getElementById("admin-token");
const adminUsernameInput = document.getElementById("admin-username");
const adminPasswordInput = document.getElementById("admin-password");
const hostFilterInput = document.getElementById("filter-host-id");
const providerFilterInput = document.getElementById("filter-provider-id");
const logicalGroupFilterInput = document.getElementById("filter-logical-group-id");
const routeFilterInput = document.getElementById("filter-route-id");
const shadowGroupFilterInput = document.getElementById("filter-shadow-group-id");
const statusFilterInput = document.getElementById("filter-status");
const queryFilterInput = document.getElementById("filter-query");
const limitFilterInput = document.getElementById("filter-limit");
const actionReasonInput = document.getElementById("action-reason");
const sessionStatus = document.getElementById("session-status");
const tableStatus = document.getElementById("table-status");
const actionStatus = document.getElementById("action-status");
const accountsCatalog = document.getElementById("accounts-catalog");
const detailEmpty = document.getElementById("detail-empty");
const detailPanel = document.getElementById("detail-panel");
const detailGrid = document.getElementById("detail-grid");
const detailJSON = document.getElementById("detail-json");
const metricApiRoot = document.getElementById("metric-api-root");
const metricTotal = document.getElementById("metric-total");
const metricLive = document.getElementById("metric-live");
const metricDead = document.getElementById("metric-dead");
const enableButton = document.getElementById("enable-btn");
const disableButton = document.getElementById("disable-btn");
const retireButton = document.getElementById("retire-btn");
function readConfig() {
try {
return JSON.parse(localStorage.getItem(storageKey) || "{}");
} catch (error) {
console.warn("failed to parse config", error);
return {};
}
}
function writeConfig() {
const payload = {
apiBase: apiBaseInput.value.trim(),
adminToken: adminTokenInput.value,
adminUsername: adminUsernameInput.value.trim(),
hostID: hostFilterInput.value.trim(),
providerID: providerFilterInput.value.trim(),
logicalGroupID: logicalGroupFilterInput.value.trim(),
routeID: routeFilterInput.value.trim(),
shadowGroupID: shadowGroupFilterInput.value.trim(),
accountStatus: statusFilterInput.value,
query: queryFilterInput.value.trim(),
limit: limitFilterInput.value.trim(),
};
localStorage.setItem(storageKey, JSON.stringify(payload));
setStatus(tableStatus, "已保存本地配置。");
}
function hydrateConfig() {
const config = readConfig();
apiBaseInput.value = config.apiBase || "/portal-admin-api";
adminTokenInput.value = config.adminToken || "";
adminUsernameInput.value = config.adminUsername || "";
hostFilterInput.value = config.hostID || "";
providerFilterInput.value = config.providerID || "";
logicalGroupFilterInput.value = config.logicalGroupID || "";
routeFilterInput.value = config.routeID || "";
shadowGroupFilterInput.value = config.shadowGroupID || "";
statusFilterInput.value = config.accountStatus || "";
queryFilterInput.value = config.query || "";
limitFilterInput.value = config.limit || "200";
}
function apiBase() {
return (apiBaseInput.value.trim() || "/portal-admin-api").replace(/\/+$/, "");
}
function authHeaders() {
const token = adminTokenInput.value.trim();
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function requestJSON(path, options = {}) {
const response = await fetch(`${apiBase()}${path}`, {
credentials: "include",
...options,
headers: {
Accept: "application/json",
...(options.headers || {}),
},
});
const text = await response.text();
const payload = text ? JSON.parse(text) : {};
if (!response.ok) {
const message = payload?.error?.message || payload?.message || response.statusText || "request failed";
throw new Error(`${response.status} ${message}`);
}
return payload;
}
async function refreshSession() {
metricApiRoot.textContent = apiBase();
try {
const payload = await requestJSON("/api/admin/session", { headers: authHeaders() });
if (payload.authenticated) {
setStatus(sessionStatus, `管理员会话已建立:${payload.username || "unknown"}session`, "success");
} else if (payload.login_enabled) {
setStatus(sessionStatus, `当前未登录。可用管理员用户名 ${payload.username || "admin"} 建立 session或继续使用 Bearer token。`, "warn");
} else {
setStatus(sessionStatus, "当前实例未启用管理员登录,只能使用 Bearer token。", "warn");
}
} catch (error) {
setStatus(sessionStatus, `检查管理员会话失败:${error.message}`, "danger");
}
}
async function loginSession() {
const username = adminUsernameInput.value.trim();
const password = adminPasswordInput.value;
if (!username || !password) {
setStatus(sessionStatus, "请先输入管理员用户名和密码。", "warn");
return;
}
try {
const payload = await requestJSON("/api/admin/session/login", {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ username, password }),
});
setStatus(sessionStatus, `管理员会话已建立:${payload.username || username}`, "success");
} catch (error) {
setStatus(sessionStatus, `管理员登录失败:${error.message}`, "danger");
}
}
async function logoutSession() {
try {
await requestJSON("/api/admin/session/logout", {
method: "POST",
headers: authHeaders(),
});
setStatus(sessionStatus, "管理员会话已退出。", "warn");
} catch (error) {
setStatus(sessionStatus, `退出会话失败:${error.message}`, "danger");
}
}
function buildListQuery() {
const params = new URLSearchParams();
if (hostFilterInput.value.trim()) params.set("host_id", hostFilterInput.value.trim());
if (providerFilterInput.value.trim()) params.set("provider_id", providerFilterInput.value.trim());
if (logicalGroupFilterInput.value.trim()) params.set("logical_group_id", logicalGroupFilterInput.value.trim());
if (routeFilterInput.value.trim()) params.set("route_id", routeFilterInput.value.trim());
if (shadowGroupFilterInput.value.trim()) params.set("shadow_group_id", shadowGroupFilterInput.value.trim());
if (statusFilterInput.value) params.set("account_status", statusFilterInput.value);
if (queryFilterInput.value.trim()) params.set("q", queryFilterInput.value.trim());
if (limitFilterInput.value.trim()) params.set("limit", limitFilterInput.value.trim());
const query = params.toString();
return query ? `/api/provider-accounts?${query}` : "/api/provider-accounts";
}
async function loadAccounts() {
setStatus(tableStatus, "正在读取 provider_accounts…");
try {
const payload = await requestJSON(buildListQuery(), { headers: authHeaders() });
state.accounts = Array.isArray(payload.provider_accounts) ? payload.provider_accounts : [];
if (!state.accounts.some((item) => item.id === state.selectedAccountID)) {
state.selectedAccountID = state.accounts[0]?.id || 0;
}
renderMetrics();
renderCatalog();
renderDetail();
setStatus(tableStatus, `已加载 ${state.accounts.length} 条帐号资产记录。`, "success");
} catch (error) {
state.accounts = [];
state.selectedAccountID = 0;
renderMetrics();
renderCatalog();
renderDetail();
setStatus(tableStatus, `读取帐号资产失败:${error.message}`, "danger");
}
}
function renderMetrics() {
metricApiRoot.textContent = apiBase();
metricTotal.textContent = String(state.accounts.length);
const counts = { active: 0, disabled: 0, deprecated: 0, broken: 0 };
state.accounts.forEach((account) => {
if (Object.prototype.hasOwnProperty.call(counts, account.account_status)) {
counts[account.account_status] += 1;
}
});
metricLive.textContent = `${counts.active} / ${counts.disabled}`;
metricDead.textContent = `${counts.deprecated} / ${counts.broken}`;
}
function statusClass(status) {
if (status === "active") return "active";
if (status === "disabled") return "disabled";
if (status === "deprecated") return "deprecated";
return "broken";
}
function renderCatalog() {
if (!state.accounts.length) {
accountsCatalog.innerHTML = '<div class="empty">还没有匹配到帐号资产记录。</div>';
return;
}
accountsCatalog.innerHTML = "";
state.accounts.forEach((account) => {
const card = document.createElement("button");
card.type = "button";
card.className = `row-card${account.id === state.selectedAccountID ? " is-selected" : ""}`;
card.innerHTML = `
<div class="row-heading">
<div>
<div class="row-title">${escapeHTML(account.account_name || account.host_account_id)}</div>
<div class="meta-list">
<span>provider: <code>${escapeHTML(account.provider_id)}</code></span>
<span>host_account_id: <code>${escapeHTML(account.host_account_id)}</code></span>
</div>
</div>
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.account_status)}</span>
</div>
<div class="badge-row">
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.logical_group_id || "未归属 logical_group")}</span>
<span class="badge ${statusClass(account.account_status)}">${escapeHTML(account.route_id || "未归属 route")}</span>
<span class="badge ${statusClass(account.account_status)}">shadow_group: ${escapeHTML(account.shadow_group_id || "-")}</span>
</div>
<div class="meta-list">
<span>route_name: <code>${escapeHTML(account.route_name || "-")}</code></span>
<span>shadow_host_id: <code>${escapeHTML(account.shadow_host_id || account.host_id || "-")}</code></span>
<span>last_probe_status: <code>${escapeHTML(account.last_probe_status || "-")}</code></span>
</div>
`;
card.addEventListener("click", () => {
state.selectedAccountID = account.id;
renderCatalog();
renderDetail();
});
accountsCatalog.appendChild(card);
});
}
function renderDetail() {
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
const hasSelection = Boolean(account);
detailEmpty.hidden = hasSelection;
detailPanel.hidden = !hasSelection;
enableButton.disabled = !hasSelection;
disableButton.disabled = !hasSelection;
retireButton.disabled = !hasSelection;
if (!account) {
detailGrid.innerHTML = "";
detailJSON.textContent = "{}";
setStatus(actionStatus, "请选择左侧一条帐号记录。");
return;
}
const cards = [
["帐号主键", String(account.id)],
["provider_id", account.provider_id],
["provider_name", account.provider_name || "-"],
["host_id", account.host_id],
["host_base_url", account.host_base_url || "-"],
["logical_group_id", account.logical_group_id || "未归属"],
["route_id", account.route_id || "未归属"],
["route_name", account.route_name || "-"],
["shadow_group_id", account.shadow_group_id || "-"],
["shadow_host_id", account.shadow_host_id || "-"],
["upstream_base_url_hint", account.upstream_base_url_hint || "-"],
["host_account_id", account.host_account_id],
["key_fingerprint", account.key_fingerprint],
["account_status", account.account_status],
["last_probe_status", account.last_probe_status || "-"],
["last_probe_at", account.last_probe_at || "-"],
["disabled_reason", account.disabled_reason || "-"],
["updated_at", account.updated_at || "-"],
];
detailGrid.innerHTML = cards.map(([label, value]) => `
<div class="detail-card">
<strong>${escapeHTML(label)}</strong>
<code>${escapeHTML(value)}</code>
</div>
`).join("");
detailJSON.textContent = JSON.stringify(account, null, 2);
setStatus(actionStatus, `当前选中帐号 #${account.id},操作只会修改插件 provider_accounts 库存状态。`);
}
async function updateAccountStatus(action) {
const account = state.accounts.find((item) => item.id === state.selectedAccountID);
if (!account) {
setStatus(actionStatus, "请先选择一条帐号记录。", "warn");
return;
}
const reason = actionReasonInput.value.trim();
if ((action === "disable" || action === "retire") && !reason) {
setStatus(actionStatus, "停用或退役请填写原因,避免后续看不懂为什么改状态。", "warn");
return;
}
try {
const payload = await requestJSON(`/api/provider-accounts/${encodeURIComponent(account.id)}/${action}`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify(reason ? { reason } : {}),
});
const updated = payload.provider_account;
setStatus(actionStatus, `帐号 #${updated.id} 已更新为 ${updated.account_status}${updated.disabled_reason ? `${updated.disabled_reason}` : ""}`, "success");
await loadAccounts();
} catch (error) {
setStatus(actionStatus, `更新帐号状态失败:${error.message}`, "danger");
}
}
function clearFilters() {
hostFilterInput.value = "";
providerFilterInput.value = "";
logicalGroupFilterInput.value = "";
routeFilterInput.value = "";
shadowGroupFilterInput.value = "";
statusFilterInput.value = "";
queryFilterInput.value = "";
limitFilterInput.value = "200";
}
function setStatus(element, message, tone = "") {
element.textContent = message;
element.style.color = tone === "danger"
? "var(--danger)"
: tone === "warn"
? "var(--warn)"
: tone === "success"
? "var(--success)"
: "var(--muted)";
element.style.borderColor = tone === "danger"
? "rgba(178, 49, 49, 0.24)"
: tone === "warn"
? "rgba(155, 98, 21, 0.22)"
: tone === "success"
? "rgba(18, 107, 67, 0.22)"
: "var(--line)";
element.style.background = tone === "danger"
? "rgba(178, 49, 49, 0.08)"
: tone === "warn"
? "rgba(155, 98, 21, 0.08)"
: tone === "success"
? "rgba(18, 107, 67, 0.08)"
: "#fff";
}
function escapeHTML(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
document.getElementById("save-config-btn").addEventListener("click", writeConfig);
document.getElementById("admin-login-btn").addEventListener("click", loginSession);
document.getElementById("admin-logout-btn").addEventListener("click", logoutSession);
document.getElementById("refresh-btn").addEventListener("click", loadAccounts);
document.getElementById("apply-filters-btn").addEventListener("click", loadAccounts);
document.getElementById("clear-filters-btn").addEventListener("click", () => {
clearFilters();
loadAccounts();
});
enableButton.addEventListener("click", () => updateAccountStatus("enable"));
disableButton.addEventListener("click", () => updateAccountStatus("disable"));
retireButton.addEventListener("click", () => updateAccountStatus("retire"));
hydrateConfig();
refreshSession();
loadAccounts();
</script>
</body>
</html>

View File

@@ -252,6 +252,7 @@
<a href="/portal/admin/" class="is-current">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/accounts.html">帐号资产</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>
@@ -290,6 +291,10 @@
<div class="metric-label">Route Health</div>
<div class="metric-value">/route-health</div>
</div>
<div class="metric">
<div class="metric-label">Accounts</div>
<div class="metric-value">/accounts</div>
</div>
<div class="metric">
<div class="metric-label">Batch Import</div>
<div class="metric-value">/batch-import</div>
@@ -364,6 +369,28 @@
</ul>
</article>
<article class="card panel">
<h2>帐号资产</h2>
<p>
这页把导入结果收成插件侧 <code>provider_accounts</code> 库存,直接展示帐号属于哪个
<code>logical_group / route / shadow_group / shadow_host</code>,并提供人工
<code>enable / disable / retire</code> 动作。
</p>
<div class="cta-row">
<a class="cta primary" href="/portal/admin/accounts.html">打开帐号资产页</a>
</div>
<ul class="list">
<li>
<strong>适用动作</strong>
查看帐号库存、筛选 route 归属、执行人工启停与退役。
</li>
<li>
<strong>当前边界</strong>
启停动作当前只修改插件库存状态,不直接改宿主 account 记录。
</li>
</ul>
</article>
<article class="card panel">
<h2>导入供应商帐号</h2>
<p>

View File

@@ -370,6 +370,7 @@
<a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html" class="is-current">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/accounts.html">帐号资产</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>

View File

@@ -361,6 +361,7 @@
<a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html">Route 健康视图</a>
<a href="/portal/admin/accounts.html">帐号资产</a>
<a href="/portal/admin/providers.html" class="is-current">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>

View File

@@ -343,6 +343,7 @@
<a href="/portal/admin/">管理首页</a>
<a href="/portal/admin/logical-groups.html">逻辑分组 / 路由</a>
<a href="/portal/admin/route-health.html" class="is-current">Route 健康视图</a>
<a href="/portal/admin/accounts.html">帐号资产</a>
<a href="/portal/admin/providers.html">新增模型 / 供应商目录</a>
<a href="/portal/admin/batch-import.html">导入供应商帐号</a>
<a href="/portal/" target="_blank" rel="noreferrer">用户 Portal</a>

View File

@@ -12,13 +12,14 @@ import (
)
type ListProviderAccountsRequest struct {
HostID string
ProviderID string
RouteID string
ShadowGroupID string
AccountStatus string
Query string
Limit int
HostID string
ProviderID string
LogicalGroupID string
RouteID string
ShadowGroupID string
AccountStatus string
Query string
Limit int
}
type UpdateProviderAccountStatusRequest struct {
@@ -28,22 +29,26 @@ type UpdateProviderAccountStatusRequest struct {
}
type ProviderAccountInfo struct {
ID int64 `json:"id"`
HostID string `json:"host_id"`
ProviderID string `json:"provider_id"`
ProviderName string `json:"provider_name"`
RouteID string `json:"route_id,omitempty"`
LogicalGroupID string `json:"logical_group_id,omitempty"`
ShadowGroupID string `json:"shadow_group_id,omitempty"`
HostAccountID string `json:"host_account_id"`
KeyFingerprint string `json:"key_fingerprint"`
AccountName string `json:"account_name"`
AccountStatus string `json:"account_status"`
LastProbeStatus string `json:"last_probe_status,omitempty"`
LastProbeAt string `json:"last_probe_at,omitempty"`
DisabledReason string `json:"disabled_reason,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
ID int64 `json:"id"`
HostID string `json:"host_id"`
HostBaseURL string `json:"host_base_url"`
ProviderID string `json:"provider_id"`
ProviderName string `json:"provider_name"`
RouteName string `json:"route_name,omitempty"`
RouteID string `json:"route_id,omitempty"`
LogicalGroupID string `json:"logical_group_id,omitempty"`
ShadowGroupID string `json:"shadow_group_id,omitempty"`
ShadowHostID string `json:"shadow_host_id,omitempty"`
UpstreamBaseURLHint string `json:"upstream_base_url_hint,omitempty"`
HostAccountID string `json:"host_account_id"`
KeyFingerprint string `json:"key_fingerprint"`
AccountName string `json:"account_name"`
AccountStatus string `json:"account_status"`
LastProbeStatus string `json:"last_probe_status,omitempty"`
LastProbeAt string `json:"last_probe_at,omitempty"`
DisabledReason string `json:"disabled_reason,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListProviderAccountsRequest) ([]ProviderAccountInfo, error)) {
@@ -52,13 +57,14 @@ func handleListProviderAccounts(w http.ResponseWriter, r *http.Request, fn func(
return
}
accounts, err := fn(r.Context(), ListProviderAccountsRequest{
HostID: strings.TrimSpace(r.URL.Query().Get("host_id")),
ProviderID: strings.TrimSpace(r.URL.Query().Get("provider_id")),
RouteID: strings.TrimSpace(r.URL.Query().Get("route_id")),
ShadowGroupID: strings.TrimSpace(r.URL.Query().Get("shadow_group_id")),
AccountStatus: strings.TrimSpace(r.URL.Query().Get("account_status")),
Query: strings.TrimSpace(r.URL.Query().Get("q")),
Limit: parsePositiveInt(r.URL.Query().Get("limit")),
HostID: strings.TrimSpace(r.URL.Query().Get("host_id")),
ProviderID: strings.TrimSpace(r.URL.Query().Get("provider_id")),
LogicalGroupID: strings.TrimSpace(r.URL.Query().Get("logical_group_id")),
RouteID: strings.TrimSpace(r.URL.Query().Get("route_id")),
ShadowGroupID: strings.TrimSpace(r.URL.Query().Get("shadow_group_id")),
AccountStatus: strings.TrimSpace(r.URL.Query().Get("account_status")),
Query: strings.TrimSpace(r.URL.Query().Get("q")),
Limit: parsePositiveInt(r.URL.Query().Get("limit")),
})
if err != nil {
writeHTTPError(w, classifyError(err))
@@ -125,13 +131,14 @@ func buildListProviderAccountsAction(sqliteDSN string) func(context.Context, Lis
return nil, err
}
rows, err := store.ProviderAccounts().List(ctx, sqlite.ProviderAccountListFilter{
HostID: req.HostID,
ProviderID: req.ProviderID,
RouteID: req.RouteID,
ShadowGroupID: req.ShadowGroupID,
AccountStatus: req.AccountStatus,
Query: req.Query,
Limit: req.Limit,
HostID: req.HostID,
ProviderID: req.ProviderID,
LogicalGroupID: req.LogicalGroupID,
RouteID: req.RouteID,
ShadowGroupID: req.ShadowGroupID,
AccountStatus: req.AccountStatus,
Query: req.Query,
Limit: req.Limit,
})
if err != nil {
return nil, err
@@ -168,21 +175,25 @@ func buildUpdateProviderAccountStatusAction(sqliteDSN, accountStatus string) fun
func providerAccountViewToInfo(row sqlite.ProviderAccountView) ProviderAccountInfo {
return ProviderAccountInfo{
ID: row.ID,
HostID: row.HostID,
ProviderID: row.ProviderID,
ProviderName: row.ProviderName,
RouteID: row.RouteID,
LogicalGroupID: row.LogicalGroupID,
ShadowGroupID: row.ShadowGroupID,
HostAccountID: row.HostAccountID,
KeyFingerprint: row.KeyFingerprint,
AccountName: row.AccountName,
AccountStatus: row.AccountStatus,
LastProbeStatus: row.LastProbeStatus,
LastProbeAt: row.LastProbeAt,
DisabledReason: row.DisabledReason,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
ID: row.ID,
HostID: row.HostID,
HostBaseURL: row.HostBaseURL,
ProviderID: row.ProviderID,
ProviderName: row.ProviderName,
RouteName: row.RouteName,
RouteID: row.RouteID,
LogicalGroupID: row.LogicalGroupID,
ShadowGroupID: row.ShadowGroupID,
ShadowHostID: row.ShadowHostID,
UpstreamBaseURLHint: row.UpstreamBaseURLHint,
HostAccountID: row.HostAccountID,
KeyFingerprint: row.KeyFingerprint,
AccountName: row.AccountName,
AccountStatus: row.AccountStatus,
LastProbeStatus: row.LastProbeStatus,
LastProbeAt: row.LastProbeAt,
DisabledReason: row.DisabledReason,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
}

View File

@@ -15,23 +15,33 @@ func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
if req.ProviderID != "deepseek-official" {
t.Fatalf("ProviderID = %q, want deepseek-official", req.ProviderID)
}
if req.LogicalGroupID != "gpt-shared" {
t.Fatalf("LogicalGroupID = %q, want gpt-shared", req.LogicalGroupID)
}
if req.AccountStatus != "disabled" {
t.Fatalf("AccountStatus = %q, want disabled", req.AccountStatus)
}
return []ProviderAccountInfo{{
ID: 7,
HostID: "remote43",
ProviderID: "deepseek-official",
ProviderName: "DeepSeek Official",
HostAccountID: "9",
AccountName: "deepseek-01",
AccountStatus: "disabled",
DisabledReason: "manual_disable",
ID: 7,
HostID: "remote43",
HostBaseURL: "https://host.example.com",
ProviderID: "deepseek-official",
ProviderName: "DeepSeek Official",
RouteID: "route-1",
RouteName: "Primary Route",
LogicalGroupID: "gpt-shared",
ShadowGroupID: "group-9",
ShadowHostID: "remote43",
HostAccountID: "9",
AccountName: "deepseek-01",
AccountStatus: "disabled",
DisabledReason: "manual_disable",
UpstreamBaseURLHint: "https://api.deepseek.com",
}}, nil
},
})
request := httptestRequest(t, "GET", "/api/provider-accounts?provider_id=deepseek-official&account_status=disabled", nil, "secret-token")
request := httptestRequest(t, "GET", "/api/provider-accounts?provider_id=deepseek-official&logical_group_id=gpt-shared&account_status=disabled", nil, "secret-token")
response := httptestRecorder(handler, request)
assertStatusCode(t, response, 200)
var payload map[string][]ProviderAccountInfo
@@ -42,6 +52,9 @@ func TestAPIListProviderAccountsReturnsRows(t *testing.T) {
if len(accounts) != 1 || accounts[0].ID != 7 || accounts[0].AccountStatus != "disabled" {
t.Fatalf("provider_accounts = %+v, want one disabled row id=7", accounts)
}
if accounts[0].LogicalGroupID != "gpt-shared" || accounts[0].RouteName != "Primary Route" {
t.Fatalf("provider_accounts relationship fields = %+v", accounts[0])
}
}
func TestAPIDisableProviderAccountUsesPathID(t *testing.T) {
@@ -108,9 +121,36 @@ func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) {
if err != nil {
t.Fatalf("Providers().Create() error = %v", err)
}
if _, err := store.LogicalGroups().Create(ctx, sqlite.LogicalGroup{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
StickyMode: "conversation_preferred",
ConversationTTLSeconds: 7200,
UserModelTTLSeconds: 1800,
FailoverThreshold: 2,
CooldownSeconds: 600,
}); err != nil {
t.Fatalf("LogicalGroups().Create() error = %v", err)
}
if _, err := store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{
RouteID: "route-1",
LogicalGroupID: "gpt-shared",
Name: "Primary Route",
Status: "active",
Priority: 10,
Weight: 100,
ShadowGroupID: "group-9",
ShadowHostID: "remote43",
UpstreamBaseURLHint: "https://api.deepseek.com",
}); err != nil {
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
}
providerAccountID, err := store.ProviderAccounts().Create(ctx, sqlite.ProviderAccount{
HostID: hostRow.ID,
ProviderID: providerRowID,
RouteID: "route-1",
ShadowGroupID: "group-9",
HostAccountID: "9",
KeyFingerprint: "sha256:abc",
AccountName: "deepseek-01",
@@ -129,6 +169,9 @@ func TestNewActionSetProviderAccountListAndStatusFlow(t *testing.T) {
if len(listed) != 1 || listed[0].ID != providerAccountID {
t.Fatalf("ListProviderAccounts() = %+v, want one row for id %d", listed, providerAccountID)
}
if listed[0].LogicalGroupID != "gpt-shared" || listed[0].RouteName != "Primary Route" || listed[0].ShadowHostID != "remote43" {
t.Fatalf("ListProviderAccounts() relationship fields = %+v", listed[0])
}
disabled, err := actions.DisableProviderAccount(ctx, UpdateProviderAccountStatusRequest{
AccountID: providerAccountID,

View File

@@ -2,6 +2,7 @@ package sqlite
import (
"context"
"database/sql"
"fmt"
"strings"
)
@@ -207,6 +208,66 @@ func (r *LogicalGroupRoutesRepo) DeleteByRouteID(ctx context.Context, routeID st
return nil
}
func (r *LogicalGroupRoutesRepo) GetByShadowBinding(ctx context.Context, shadowHostID, shadowGroupID string) (LogicalGroupRoute, error) {
shadowHostID = strings.TrimSpace(shadowHostID)
shadowGroupID = strings.TrimSpace(shadowGroupID)
if shadowHostID == "" {
return LogicalGroupRoute{}, fmt.Errorf("shadow_host_id is required")
}
if shadowGroupID == "" {
return LogicalGroupRoute{}, fmt.Errorf("shadow_group_id is required")
}
rows, err := r.db.QueryContext(
ctx,
`SELECT id, route_id, logical_group_id, name, status, priority, weight, shadow_group_id, shadow_host_id, upstream_base_url_hint, cooldown_until, created_at, updated_at
FROM logical_group_routes
WHERE shadow_host_id = ? AND shadow_group_id = ?
ORDER BY priority ASC, id ASC
LIMIT 2`,
shadowHostID,
shadowGroupID,
)
if err != nil {
return LogicalGroupRoute{}, fmt.Errorf("get logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
}
defer rows.Close()
routes := make([]LogicalGroupRoute, 0, 2)
for rows.Next() {
var route LogicalGroupRoute
if err := rows.Scan(
&route.ID,
&route.RouteID,
&route.LogicalGroupID,
&route.Name,
&route.Status,
&route.Priority,
&route.Weight,
&route.ShadowGroupID,
&route.ShadowHostID,
&route.UpstreamBaseURLHint,
&route.CooldownUntil,
&route.CreatedAt,
&route.UpdatedAt,
); err != nil {
return LogicalGroupRoute{}, fmt.Errorf("scan logical group route by shadow binding: %w", err)
}
routes = append(routes, route)
}
if err := rows.Err(); err != nil {
return LogicalGroupRoute{}, fmt.Errorf("iterate logical group route by shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
}
switch len(routes) {
case 0:
return LogicalGroupRoute{}, sql.ErrNoRows
case 1:
return routes[0], nil
default:
return LogicalGroupRoute{}, fmt.Errorf("multiple logical group routes match shadow binding %q/%q", shadowHostID, shadowGroupID)
}
}
func normalizeLogicalGroupRoute(route LogicalGroupRoute) (LogicalGroupRoute, error) {
route.RouteID = strings.TrimSpace(route.RouteID)
route.LogicalGroupID = strings.TrimSpace(route.LogicalGroupID)

View File

@@ -32,32 +32,37 @@ type ProviderAccount struct {
}
type ProviderAccountListFilter struct {
HostID string
ProviderID string
RouteID string
ShadowGroupID string
AccountStatus string
Query string
Limit int
HostID string
ProviderID string
LogicalGroupID string
RouteID string
ShadowGroupID string
AccountStatus string
Query string
Limit int
}
type ProviderAccountView struct {
ID int64 `json:"id"`
HostID string `json:"host_id"`
ProviderID string `json:"provider_id"`
ProviderName string `json:"provider_name"`
RouteID string `json:"route_id,omitempty"`
LogicalGroupID string `json:"logical_group_id,omitempty"`
ShadowGroupID string `json:"shadow_group_id,omitempty"`
HostAccountID string `json:"host_account_id"`
KeyFingerprint string `json:"key_fingerprint"`
AccountName string `json:"account_name"`
AccountStatus string `json:"account_status"`
LastProbeStatus string `json:"last_probe_status,omitempty"`
LastProbeAt string `json:"last_probe_at,omitempty"`
DisabledReason string `json:"disabled_reason,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
ID int64 `json:"id"`
HostID string `json:"host_id"`
HostBaseURL string `json:"host_base_url"`
ProviderID string `json:"provider_id"`
ProviderName string `json:"provider_name"`
RouteName string `json:"route_name,omitempty"`
RouteID string `json:"route_id,omitempty"`
LogicalGroupID string `json:"logical_group_id,omitempty"`
ShadowGroupID string `json:"shadow_group_id,omitempty"`
ShadowHostID string `json:"shadow_host_id,omitempty"`
UpstreamBaseURLHint string `json:"upstream_base_url_hint,omitempty"`
HostAccountID string `json:"host_account_id"`
KeyFingerprint string `json:"key_fingerprint"`
AccountName string `json:"account_name"`
AccountStatus string `json:"account_status"`
LastProbeStatus string `json:"last_probe_status,omitempty"`
LastProbeAt string `json:"last_probe_at,omitempty"`
DisabledReason string `json:"disabled_reason,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
type ProviderAccountsRepo struct {
@@ -171,11 +176,15 @@ func (r *ProviderAccountsRepo) GetViewByID(ctx context.Context, id int64) (Provi
return r.scanViewOne(ctx, `SELECT
pa.id,
h.host_id,
h.base_url,
p.provider_id,
p.display_name,
COALESCE(lgr.name, ''),
COALESCE(pa.route_id, ''),
COALESCE(lgr.logical_group_id, ''),
COALESCE(pa.shadow_group_id, ''),
COALESCE(lgr.shadow_host_id, ''),
COALESCE(lgr.upstream_base_url_hint, ''),
pa.host_account_id,
pa.key_fingerprint,
pa.account_name,
@@ -252,11 +261,15 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
query := `SELECT
pa.id,
h.host_id,
h.base_url,
p.provider_id,
p.display_name,
COALESCE(lgr.name, ''),
COALESCE(pa.route_id, ''),
COALESCE(lgr.logical_group_id, ''),
COALESCE(pa.shadow_group_id, ''),
COALESCE(lgr.shadow_host_id, ''),
COALESCE(lgr.upstream_base_url_hint, ''),
pa.host_account_id,
pa.key_fingerprint,
pa.account_name,
@@ -280,6 +293,10 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
query += ` AND p.provider_id = ?`
args = append(args, value)
}
if value := strings.TrimSpace(filter.LogicalGroupID); value != "" {
query += ` AND lgr.logical_group_id = ?`
args = append(args, value)
}
if value := strings.TrimSpace(filter.RouteID); value != "" {
query += ` AND pa.route_id = ?`
args = append(args, value)
@@ -299,9 +316,11 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
LOWER(pa.account_name) LIKE ? OR
LOWER(pa.key_fingerprint) LIKE ? OR
LOWER(p.provider_id) LIKE ? OR
LOWER(h.host_id) LIKE ?
LOWER(h.host_id) LIKE ? OR
LOWER(COALESCE(lgr.logical_group_id, '')) LIKE ? OR
LOWER(COALESCE(lgr.name, '')) LIKE ?
)`
args = append(args, like, like, like, like, like)
args = append(args, like, like, like, like, like, like, like)
}
query += ` ORDER BY pa.updated_at DESC, pa.id DESC`
limit := filter.Limit
@@ -323,11 +342,15 @@ func (r *ProviderAccountsRepo) List(ctx context.Context, filter ProviderAccountL
if err := rows.Scan(
&view.ID,
&view.HostID,
&view.HostBaseURL,
&view.ProviderID,
&view.ProviderName,
&view.RouteName,
&view.RouteID,
&view.LogicalGroupID,
&view.ShadowGroupID,
&view.ShadowHostID,
&view.UpstreamBaseURLHint,
&view.HostAccountID,
&view.KeyFingerprint,
&view.AccountName,
@@ -376,11 +399,15 @@ func (r *ProviderAccountsRepo) scanViewOne(ctx context.Context, query string, ar
if err := r.db.QueryRowContext(ctx, query, args...).Scan(
&view.ID,
&view.HostID,
&view.HostBaseURL,
&view.ProviderID,
&view.ProviderName,
&view.RouteName,
&view.RouteID,
&view.LogicalGroupID,
&view.ShadowGroupID,
&view.ShadowHostID,
&view.UpstreamBaseURLHint,
&view.HostAccountID,
&view.KeyFingerprint,
&view.AccountName,

View File

@@ -87,12 +87,13 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
}
rows, err := accountRepo.List(ctx, ProviderAccountListFilter{
HostID: "host-" + sanitizeTestName(t.Name()),
ProviderID: "deepseek-official",
RouteID: "route-1",
ShadowGroupID: "shadow-group-1",
AccountStatus: ProviderAccountStatusBroken,
Query: "deepseek",
HostID: "host-" + sanitizeTestName(t.Name()),
ProviderID: "deepseek-official",
LogicalGroupID: "lg-1",
RouteID: "route-1",
ShadowGroupID: "shadow-group-1",
AccountStatus: ProviderAccountStatusBroken,
Query: "deepseek",
})
if err != nil {
t.Fatalf("ProviderAccounts().List() error = %v", err)
@@ -100,6 +101,9 @@ func TestProviderAccountsRepoCRUDAndFilters(t *testing.T) {
if len(rows) != 1 || rows[0].ID != accountID {
t.Fatalf("ProviderAccounts().List() = %+v, want one row for account_id %d", rows, accountID)
}
if rows[0].LogicalGroupID != "lg-1" || rows[0].RouteName != "Route 1" || rows[0].ShadowHostID != "shadow-host-1" {
t.Fatalf("ProviderAccounts().List() relationship view = %+v", rows[0])
}
if err := accountRepo.UpdateStatusByID(ctx, accountID, ProviderAccountStatusDisabled, "manual_disable"); err != nil {
t.Fatalf("ProviderAccounts().UpdateStatusByID() error = %v", err)
@@ -284,3 +288,90 @@ func TestSyncProviderAccountsFromImportBatchPreservesManualDisabledStatus(t *tes
t.Fatalf("account after resync = %+v, want disabled manual_disable preserved", account)
}
}
func TestSyncProviderAccountsFromImportBatchInfersRouteFromShadowBinding(t *testing.T) {
t.Parallel()
store := openTestDBWithFK(t)
ctx := context.Background()
hostID := createTestHost(t, store)
hostRow, err := store.Hosts().GetByID(ctx, hostID)
if err != nil {
t.Fatalf("Hosts().GetByID() error = %v", err)
}
packID := createTestPack(t, store)
providerID, err := store.Providers().Create(ctx, Provider{
PackID: packID,
ProviderID: "asxs-provider",
DisplayName: "ASXS Provider",
BaseURL: "https://api.asxs.top/v1",
Platform: "openai",
})
if err != nil {
t.Fatalf("Providers().Create() error = %v", err)
}
if _, err := store.LogicalGroups().Create(ctx, LogicalGroup{
LogicalGroupID: "gpt-shared",
DisplayName: "GPT Shared",
Status: "active",
}); err != nil {
t.Fatalf("LogicalGroups().Create() error = %v", err)
}
if _, err := store.LogicalGroupRoutes().Create(ctx, LogicalGroupRoute{
RouteID: "route-shadow-1",
LogicalGroupID: "gpt-shared",
Name: "Shadow Route",
Status: "active",
Priority: 10,
Weight: 100,
ShadowGroupID: "group-1",
ShadowHostID: hostRow.HostID,
}); err != nil {
t.Fatalf("LogicalGroupRoutes().Create() error = %v", err)
}
batchID, err := store.ImportBatches().Create(ctx, ImportBatch{
HostID: hostID,
PackID: packID,
ProviderID: providerID,
Mode: "strict",
BatchStatus: "succeeded",
AccessStatus: "subscription_ready",
})
if err != nil {
t.Fatalf("ImportBatches().Create() error = %v", err)
}
if _, err := store.ImportBatchItems().Create(ctx, ImportBatchItem{
BatchID: batchID,
KeyFingerprint: "sha256:key1",
AccountStatus: "passed",
ProbeSummaryJSON: `{"account_id":"account-1","probe_status":"passed"}`,
}); err != nil {
t.Fatalf("ImportBatchItems().Create() error = %v", err)
}
for _, resource := range []ManagedResource{
{BatchID: batchID, HostID: hostID, ResourceType: "group", HostResourceID: "group-1", ResourceName: "ASXS Group"},
{BatchID: batchID, HostID: hostID, ResourceType: "account", HostResourceID: "account-1", ResourceName: "asxs-01"},
} {
if _, err := store.ManagedResources().Create(ctx, resource); err != nil {
t.Fatalf("ManagedResources().Create(%s) error = %v", resource.ResourceType, err)
}
}
if err := SyncProviderAccountsFromImportBatch(ctx, store, batchID); err != nil {
t.Fatalf("SyncProviderAccountsFromImportBatch() error = %v", err)
}
account, err := store.ProviderAccounts().GetByHostIDAndAccountID(ctx, hostID, "account-1")
if err != nil {
t.Fatalf("ProviderAccounts().GetByHostIDAndAccountID() error = %v", err)
}
if account.RouteID != "route-shadow-1" || account.ShadowGroupID != "group-1" {
t.Fatalf("provider account route binding = %+v, want route-shadow-1/group-1", account)
}
view, err := store.ProviderAccounts().GetViewByID(ctx, account.ID)
if err != nil {
t.Fatalf("ProviderAccounts().GetViewByID() error = %v", err)
}
if view.LogicalGroupID != "gpt-shared" || view.RouteName != "Shadow Route" || view.ShadowHostID != hostRow.HostID {
t.Fatalf("provider account view route binding = %+v", view)
}
}

View File

@@ -2,6 +2,7 @@ package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
@@ -54,12 +55,21 @@ func SyncProviderAccountsFromImportBatch(ctx context.Context, store *DB, batchID
nowText := time.Now().UTC().Format(time.RFC3339)
shadowGroupID := ""
shadowHostID := ""
for _, resource := range resources {
if strings.TrimSpace(resource.ResourceType) == "group" {
shadowGroupID = strings.TrimSpace(resource.HostResourceID)
break
}
}
hostRow, err := store.Hosts().GetByID(ctx, batch.HostID)
if err == nil {
shadowHostID = strings.TrimSpace(hostRow.HostID)
}
matchedRoute, routeErr := resolveProviderAccountRouteBinding(ctx, store, shadowHostID, shadowGroupID)
if routeErr != nil && routeErr != sql.ErrNoRows {
return routeErr
}
accountResources := make([]ManagedResource, 0)
for _, resource := range resources {
@@ -82,6 +92,7 @@ func SyncProviderAccountsFromImportBatch(ctx context.Context, store *DB, batchID
row := ProviderAccount{
HostID: batch.HostID,
ProviderID: batch.ProviderID,
RouteID: matchedRoute.RouteID,
ShadowGroupID: shadowGroupID,
HostAccountID: hostAccountID,
KeyFingerprint: fallbackString(match.KeyFingerprint, "legacy:"+hostAccountID),
@@ -109,6 +120,25 @@ func SyncProviderAccountsFromImportBatch(ctx context.Context, store *DB, batchID
return nil
}
func resolveProviderAccountRouteBinding(ctx context.Context, store *DB, shadowHostID, shadowGroupID string) (LogicalGroupRoute, error) {
if store == nil {
return LogicalGroupRoute{}, fmt.Errorf("store is required")
}
shadowHostID = strings.TrimSpace(shadowHostID)
shadowGroupID = strings.TrimSpace(shadowGroupID)
if shadowHostID == "" || shadowGroupID == "" {
return LogicalGroupRoute{}, sql.ErrNoRows
}
route, err := store.LogicalGroupRoutes().GetByShadowBinding(ctx, shadowHostID, shadowGroupID)
if err != nil {
if err == sql.ErrNoRows {
return LogicalGroupRoute{}, err
}
return LogicalGroupRoute{}, fmt.Errorf("resolve logical route for provider account shadow binding %q/%q: %w", shadowHostID, shadowGroupID, err)
}
return route, nil
}
type legacyBatchAccountProjection struct {
KeyFingerprint string
AccountStatus string

View File

@@ -171,6 +171,7 @@ portal url: https://sub.tksea.top/portal/
portal admin home url: https://sub.tksea.top/portal/admin/
logical groups admin url: https://sub.tksea.top/portal/admin/logical-groups.html
route health admin url: https://sub.tksea.top/portal/admin/route-health.html
accounts admin url: https://sub.tksea.top/portal/admin/accounts.html
provider admin url: https://sub.tksea.top/portal/admin/providers.html
batch import admin url: https://sub.tksea.top/portal/admin/batch-import.html
batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html

View File

@@ -7,6 +7,7 @@ ADMIN_HTML_FILE="$ROOT_DIR/deploy/tksea-portal/admin-batch-import.html"
ADMIN_HOME_FILE="$ROOT_DIR/deploy/tksea-portal/admin/index.html"
ADMIN_LOGICAL_GROUPS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/logical-groups.html"
ADMIN_ROUTE_HEALTH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/route-health.html"
ADMIN_ACCOUNTS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/accounts.html"
ADMIN_PROVIDERS_FILE="$ROOT_DIR/deploy/tksea-portal/admin/providers.html"
ADMIN_BATCH_FILE="$ROOT_DIR/deploy/tksea-portal/admin/batch-import.html"
NGINX_FILE="$ROOT_DIR/deploy/tksea-portal/nginx.sub.tksea.top.conf.example"
@@ -30,6 +31,7 @@ assert_contains_file() {
[[ -f "$ADMIN_HOME_FILE" ]] || fail "missing $ADMIN_HOME_FILE"
[[ -f "$ADMIN_LOGICAL_GROUPS_FILE" ]] || fail "missing $ADMIN_LOGICAL_GROUPS_FILE"
[[ -f "$ADMIN_ROUTE_HEALTH_FILE" ]] || fail "missing $ADMIN_ROUTE_HEALTH_FILE"
[[ -f "$ADMIN_ACCOUNTS_FILE" ]] || fail "missing $ADMIN_ACCOUNTS_FILE"
[[ -f "$ADMIN_PROVIDERS_FILE" ]] || fail "missing $ADMIN_PROVIDERS_FILE"
[[ -f "$ADMIN_BATCH_FILE" ]] || fail "missing $ADMIN_BATCH_FILE"
[[ -f "$NGINX_FILE" ]] || fail "missing $NGINX_FILE"
@@ -58,6 +60,7 @@ assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_HTML_FILE" "matched_account_state"
assert_contains_file "$ADMIN_HTML_FILE" "account_resolution"
@@ -72,17 +75,21 @@ assert_contains_file "$ADMIN_HTML_FILE" "reactivated"
assert_contains_file "$ADMIN_HOME_FILE" "Admin Portal"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_HOME_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_HOME_FILE" "浏览器提交到 CRM"
assert_contains_file "$ADMIN_HOME_FILE" "逻辑分组 / 路由"
assert_contains_file "$ADMIN_HOME_FILE" "Route 健康视图"
assert_contains_file "$ADMIN_HOME_FILE" "帐号资产"
assert_contains_file "$ADMIN_HOME_FILE" "/accounts"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "Logical Group Admin"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_LOGICAL_GROUPS_FILE" "/api/admin/session/login"
@@ -100,6 +107,7 @@ assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "Route Health Admin"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/api/admin/session/login"
@@ -113,10 +121,33 @@ assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "disabled"
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" 'credentials: "include"'
assert_contains_file "$ADMIN_ROUTE_HEALTH_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "Provider Accounts Admin"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/providers.html"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal/admin/batch-import.html"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/admin/session"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/api/provider-accounts"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/enable"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/disable"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/retire"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "logical_group_id"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "route_id"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "shadow_group_id"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "shadow_host_id"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "provider_accounts"
assert_contains_file "$ADMIN_ACCOUNTS_FILE" 'credentials: "include"'
assert_contains_file "$ADMIN_ACCOUNTS_FILE" "/portal-admin-api"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Admin"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "管理员登录"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/logical-groups.html"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/admin/session"
@@ -145,6 +176,7 @@ assert_contains_file "$ADMIN_PROVIDERS_FILE" "modelConflicts"
assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html"
assert_contains_file "$ADMIN_HTML_FILE" "管理员登录"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/route-health.html"
assert_contains_file "$ADMIN_HTML_FILE" "/portal/admin/accounts.html"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/login"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session/logout"
assert_contains_file "$ADMIN_HTML_FILE" "/api/admin/session"
@@ -162,6 +194,7 @@ assert_contains_file "$DEPLOY_SCRIPT" "portal url: https://sub.tksea.top/portal/
assert_contains_file "$DEPLOY_SCRIPT" "portal admin home url: https://sub.tksea.top/portal/admin/"
assert_contains_file "$DEPLOY_SCRIPT" "logical groups admin url: https://sub.tksea.top/portal/admin/logical-groups.html"
assert_contains_file "$DEPLOY_SCRIPT" "route health admin url: https://sub.tksea.top/portal/admin/route-health.html"
assert_contains_file "$DEPLOY_SCRIPT" "accounts admin url: https://sub.tksea.top/portal/admin/accounts.html"
assert_contains_file "$DEPLOY_SCRIPT" "provider admin url: https://sub.tksea.top/portal/admin/providers.html"
assert_contains_file "$DEPLOY_SCRIPT" "batch import admin url: https://sub.tksea.top/portal/admin/batch-import.html"
assert_contains_file "$DEPLOY_SCRIPT" "batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html"