feat(batch): add live reuse admin verification flow

This commit is contained in:
phamnazage-jpg
2026-05-27 20:23:42 +08:00
parent 02580cda0b
commit ebd86a4256
16 changed files with 1768 additions and 9 deletions

View File

@@ -75,6 +75,7 @@ sub2api-cn-relay-manager/
- [docs/OPENCLAW_EXTERNAL_VALIDATION.md](./docs/OPENCLAW_EXTERNAL_VALIDATION.md) —— OpenClaw 最后一跳真实使用验证
- [docs/PROJECT_STRUCTURE.md](./docs/PROJECT_STRUCTURE.md) —— 当前仓库目录职责说明
- [scripts/README.md](./scripts/README.md) —— 脚本目录分层说明与常用入口
- [deploy/tksea-portal/admin-batch-import.html](./deploy/tksea-portal/admin-batch-import.html) —— 最小 batch-import 管理页
背景/设计文档:

View File

@@ -8,6 +8,9 @@
- `tksea-portal/index.html`
- `https://sub.tksea.top/portal/` 的静态页面源码
- `tksea-portal/admin-batch-import.html`
- `https://sub.tksea.top/portal/admin-batch-import.html` 的最小管理页
- 直接消费 `POST /api/batch-import/runs``GET /api/batch-import/runs/*`
- `tksea-portal/nginx.sub.tksea.top.conf.example`
- `sub.tksea.top` 上 portal 路由与代理示例

View File

@@ -0,0 +1,902 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Batch Import 管理台</title>
<style>
:root {
--bg: #f3efe7;
--panel: #fffdf9;
--ink: #1b1815;
--muted: #655d55;
--line: rgba(27, 24, 21, 0.12);
--accent: #0b6bcb;
--accent-soft: rgba(11, 107, 203, 0.12);
--success: #127347;
--success-soft: rgba(18, 115, 71, 0.12);
--warn: #9a6112;
--warn-soft: rgba(154, 97, 18, 0.14);
--danger: #b33030;
--danger-soft: rgba(179, 48, 48, 0.12);
--shadow: 0 18px 50px rgba(52, 42, 32, 0.08);
--radius: 22px;
--radius-sm: 12px;
--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 right, rgba(11, 107, 203, 0.12), transparent 24rem),
radial-gradient(circle at bottom left, rgba(18, 115, 71, 0.1), transparent 22rem),
var(--bg);
}
a { color: inherit; }
.shell {
max-width: 1380px;
margin: 0 auto;
padding: 36px 20px 64px;
}
.hero {
display: grid;
grid-template-columns: 1.3fr 0.7fr;
gap: 18px;
margin-bottom: 18px;
}
.hero-card,
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.hero-card {
padding: 28px;
position: relative;
overflow: hidden;
}
.hero-card::after {
content: "";
position: absolute;
inset: auto -5rem -5rem auto;
width: 14rem;
height: 14rem;
background: linear-gradient(135deg, rgba(11, 107, 203, 0.18), rgba(18, 115, 71, 0.04));
border-radius: 999px;
filter: blur(8px);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.02em;
}
h1 {
margin: 18px 0 10px;
font-size: clamp(30px, 4vw, 44px);
line-height: 1.05;
letter-spacing: -0.04em;
}
.hero-copy {
max-width: 56rem;
color: var(--muted);
font-size: 16px;
line-height: 1.7;
}
.hero-points {
margin: 18px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.hero-points li {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.76);
font-size: 13px;
font-weight: 600;
}
.quick-panel {
padding: 24px;
display: grid;
gap: 14px;
align-content: start;
}
.metric {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: #fff;
}
.metric-label {
color: var(--muted);
font-size: 12px;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.metric-value {
margin-top: 8px;
font-size: 28px;
font-weight: 700;
letter-spacing: -0.03em;
}
.grid {
display: grid;
grid-template-columns: 420px minmax(0, 1fr);
gap: 18px;
}
.panel {
padding: 22px;
}
.panel h2 {
margin: 0 0 8px;
font-size: 22px;
letter-spacing: -0.03em;
}
.panel-desc {
margin: 0 0 18px;
color: var(--muted);
line-height: 1.6;
font-size: 14px;
}
.field-grid {
display: grid;
gap: 12px;
}
.field-grid.two {
grid-template-columns: 1fr 1fr;
}
label {
display: grid;
gap: 7px;
font-size: 13px;
font-weight: 700;
color: var(--muted);
}
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: 192px;
resize: vertical;
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
}
.hint {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
font-weight: 500;
}
.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: 700;
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 {
background: transparent;
color: var(--muted);
border: 1px solid var(--line);
}
.statusbar {
margin-top: 16px;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: #fff;
min-height: 54px;
display: flex;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 14px;
line-height: 1.5;
}
.statusbar[data-tone="success"] { background: var(--success-soft); color: var(--success); border-color: rgba(18,115,71,0.22); }
.statusbar[data-tone="warning"] { background: var(--warn-soft); color: var(--warn); border-color: rgba(154,97,18,0.2); }
.statusbar[data-tone="danger"] { background: var(--danger-soft); color: var(--danger); border-color: rgba(179,48,48,0.18); }
.summary-cards {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.summary-card {
padding: 16px;
border: 1px solid var(--line);
border-radius: 18px;
background: #fff;
}
.summary-card strong {
display: block;
margin-top: 8px;
font-size: 30px;
letter-spacing: -0.04em;
}
.subtle {
color: var(--muted);
font-size: 13px;
line-height: 1.6;
}
.toolbar {
display: grid;
grid-template-columns: 1.1fr 0.9fr 0.9fr auto;
gap: 10px;
margin-bottom: 14px;
align-items: end;
}
.table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: 18px;
background: #fff;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 1080px;
}
th, td {
padding: 13px 14px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: top;
font-size: 14px;
}
th {
background: rgba(27, 24, 21, 0.03);
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
td code {
font-family: var(--font-mono);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
border: 1px solid transparent;
white-space: nowrap;
}
.tone-green { background: var(--success-soft); color: var(--success); border-color: rgba(18,115,71,0.16); }
.tone-yellow { background: var(--warn-soft); color: var(--warn); border-color: rgba(154,97,18,0.14); }
.tone-red { background: var(--danger-soft); color: var(--danger); border-color: rgba(179,48,48,0.14); }
.tone-blue { background: var(--accent-soft); color: var(--accent); border-color: rgba(11,107,203,0.12); }
.tone-cyan { background: rgba(13, 139, 150, 0.11); color: #0d6b72; border-color: rgba(13,139,150,0.16); }
.tone-gray { background: rgba(27, 24, 21, 0.06); color: var(--muted); border-color: rgba(27,24,21,0.12); }
.muted-block {
padding: 12px 14px;
border-radius: 14px;
background: rgba(27, 24, 21, 0.03);
color: var(--muted);
font-size: 13px;
line-height: 1.7;
}
.run-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 10px 0 18px;
}
.run-meta code {
padding: 8px 12px;
border-radius: 999px;
background: rgba(27, 24, 21, 0.05);
border: 1px solid var(--line);
font-family: var(--font-mono);
font-size: 12px;
}
@media (max-width: 1100px) {
.hero, .grid, .toolbar, .summary-cards, .field-grid.two { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<article class="hero-card">
<div class="eyebrow">Batch Import Admin</div>
<h1>供应商批量导入管理页</h1>
<p class="hero-copy">
这个页面只做三件事:发起 batch import、查看 run 摘要、拉取 item 级复用结果。
后端仍然以现有 `POST /api/batch-import/runs` 与 `GET /api/batch-import/runs/*` 为准,
页面不引入额外协议。
</p>
<ul class="hero-points">
<li>直接展示 `matched_account_state`</li>
<li>直接展示 `account_resolution`</li>
<li>复用 / 快速启用 / 替换 一眼可见</li>
</ul>
</article>
<aside class="quick-panel hero-card">
<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">当前 Run</div>
<div class="metric-value" id="metric-run-id">-</div>
</div>
<div class="metric">
<div class="metric-label">最近状态</div>
<div class="metric-value" id="metric-run-state">-</div>
</div>
</aside>
</section>
<section class="grid">
<article class="panel">
<h2>发起导入</h2>
<p class="panel-desc">
用 admin token 直接调用当前控制面的 batch-import API。
`entries` 每行一个供应商帐号:`base_url|api_key|model_a,model_b`。
</p>
<div class="field-grid two">
<label>API Base
<input id="api-base" type="text" placeholder="https://crm.example.com">
</label>
<label>Host ID
<input id="host-id" type="text" placeholder="remote43-current-host">
</label>
</div>
<div class="field-grid two">
<label>Admin Token
<input id="admin-token" type="password" placeholder="secret-token">
</label>
<label>Mode
<select id="mode">
<option value="strict">strict</option>
<option value="partial">partial</option>
</select>
</label>
</div>
<div class="field-grid two">
<label>Access Mode
<select id="access-mode">
<option value="self_service">self_service</option>
<option value="subscription">subscription</option>
</select>
</label>
<label>Confirm Wait Timeout Sec
<input id="confirm-timeout" type="number" min="1" value="10">
</label>
</div>
<div class="field-grid two" id="self-service-fields">
<label>Probe API Key
<input id="probe-api-key" type="text" placeholder="sk-probe">
</label>
<div class="muted-block">
`self_service` 会直接用这把 key 执行 gateway completion 验证。
</div>
</div>
<div class="field-grid two" id="subscription-fields" hidden>
<label>Subscription Users
<input id="subscription-users" type="text" placeholder="user-1,user-2">
<span class="hint">逗号分隔,至少 1 个用户。</span>
</label>
<label>Subscription Days
<input id="subscription-days" type="number" min="1" value="30">
</label>
</div>
<label style="margin-top: 12px;">Entries
<textarea id="entries">https://api.example.com/v1|sk-example-1|kimi-k2.6
https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
<span class="hint">格式:`base_url|api_key|requested_model_1,requested_model_2`,模型为空时可省略第三段。</span>
</label>
<div class="actions">
<button class="primary" id="create-run-btn">创建 Run</button>
<button class="ghost" id="save-config-btn">保存本地配置</button>
<button class="ghost" id="load-sample-btn">恢复示例</button>
</div>
<div class="statusbar" id="statusbar">等待操作。</div>
</article>
<article class="panel">
<h2>Run 结果</h2>
<p class="panel-desc">
创建完成后会自动查询 run 摘要和 item 列表。也可以手动输入 run id 重新拉取。
</p>
<div class="field-grid two">
<label>Run ID
<input id="run-id" type="text" placeholder="run_1779848658025955399">
</label>
<div class="actions" style="align-items: end;">
<button class="secondary" id="refresh-run-btn">刷新 Run</button>
<button class="ghost" id="clear-items-btn">清空结果</button>
</div>
</div>
<div class="run-meta" id="run-meta"></div>
<div class="summary-cards">
<div class="summary-card"><span class="subtle">总条目</span><strong id="sum-total">0</strong></div>
<div class="summary-card"><span class="subtle">完成</span><strong id="sum-completed">0</strong></div>
<div class="summary-card"><span class="subtle">Active</span><strong id="sum-active">0</strong></div>
<div class="summary-card"><span class="subtle">Degraded</span><strong id="sum-degraded">0</strong></div>
<div class="summary-card"><span class="subtle">Broken</span><strong id="sum-broken">0</strong></div>
</div>
<div class="toolbar">
<label>搜索
<input id="filter-query" type="text" placeholder="provider_id / base_url">
</label>
<label>Matched Account State
<select id="filter-matched-state">
<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>
<label>Account Resolution
<select id="filter-account-resolution">
<option value="">全部</option>
<option value="created">created</option>
<option value="reused">reused</option>
<option value="reactivated">reactivated</option>
<option value="replaced">replaced</option>
</select>
</label>
<button class="ghost" id="apply-filter-btn">应用过滤</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Provider</th>
<th>Base URL</th>
<th>Smoke Model</th>
<th>Matched / Resolution</th>
<th>Access</th>
<th>Badges</th>
<th>Advisory</th>
</tr>
</thead>
<tbody id="items-tbody">
<tr><td colspan="7" class="subtle">还没有结果。</td></tr>
</tbody>
</table>
</div>
</article>
</section>
</main>
<script>
const storageKey = "sub2api-crm-batch-import-admin-v1";
const state = {
currentRunID: "",
currentItems: [],
currentRun: null,
};
const statusbar = document.getElementById("statusbar");
const apiBaseInput = document.getElementById("api-base");
const hostIDInput = document.getElementById("host-id");
const adminTokenInput = document.getElementById("admin-token");
const modeInput = document.getElementById("mode");
const accessModeInput = document.getElementById("access-mode");
const confirmTimeoutInput = document.getElementById("confirm-timeout");
const probeAPIKeyInput = document.getElementById("probe-api-key");
const subscriptionUsersInput = document.getElementById("subscription-users");
const subscriptionDaysInput = document.getElementById("subscription-days");
const entriesInput = document.getElementById("entries");
const runIDInput = document.getElementById("run-id");
const selfServiceFields = document.getElementById("self-service-fields");
const subscriptionFields = document.getElementById("subscription-fields");
const metricApiRoot = document.getElementById("metric-api-root");
const metricRunID = document.getElementById("metric-run-id");
const metricRunState = document.getElementById("metric-run-state");
const runMeta = document.getElementById("run-meta");
const itemsTbody = document.getElementById("items-tbody");
const filterQueryInput = document.getElementById("filter-query");
const filterMatchedStateInput = document.getElementById("filter-matched-state");
const filterAccountResolutionInput = document.getElementById("filter-account-resolution");
const summaryTargets = {
total: document.getElementById("sum-total"),
completed: document.getElementById("sum-completed"),
active: document.getElementById("sum-active"),
degraded: document.getElementById("sum-degraded"),
broken: document.getElementById("sum-broken"),
};
function setStatus(message, tone = "") {
statusbar.textContent = message;
if (tone) {
statusbar.dataset.tone = tone;
} else {
delete statusbar.dataset.tone;
}
}
function defaultApiBase() {
return window.location.origin;
}
function saveConfig() {
localStorage.setItem(storageKey, JSON.stringify({
apiBase: apiBaseInput.value.trim(),
hostID: hostIDInput.value.trim(),
adminToken: adminTokenInput.value,
mode: modeInput.value,
accessMode: accessModeInput.value,
confirmTimeoutSec: confirmTimeoutInput.value,
probeAPIKey: probeAPIKeyInput.value.trim(),
subscriptionUsers: subscriptionUsersInput.value.trim(),
subscriptionDays: subscriptionDaysInput.value,
entries: entriesInput.value,
}));
setStatus("本地配置已保存。", "success");
syncHeaderMetrics();
}
function restoreConfig() {
const raw = localStorage.getItem(storageKey);
if (!raw) {
apiBaseInput.value = defaultApiBase();
hostIDInput.value = "";
confirmTimeoutInput.value = "10";
subscriptionDaysInput.value = "30";
return;
}
try {
const payload = JSON.parse(raw);
apiBaseInput.value = payload.apiBase || defaultApiBase();
hostIDInput.value = payload.hostID || "";
adminTokenInput.value = payload.adminToken || "";
modeInput.value = payload.mode || "strict";
accessModeInput.value = payload.accessMode || "self_service";
confirmTimeoutInput.value = payload.confirmTimeoutSec || "10";
probeAPIKeyInput.value = payload.probeAPIKey || "";
subscriptionUsersInput.value = payload.subscriptionUsers || "";
subscriptionDaysInput.value = payload.subscriptionDays || "30";
entriesInput.value = payload.entries || entriesInput.value;
} catch (error) {
apiBaseInput.value = defaultApiBase();
}
}
function loadSampleEntries() {
entriesInput.value = [
"https://api.example.com/v1|sk-example-1|kimi-k2.6",
"https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4",
].join("\\n");
setStatus("示例 entries 已恢复。");
}
function updateAccessModeFields() {
const accessMode = accessModeInput.value;
const subscriptionMode = accessMode === "subscription";
selfServiceFields.hidden = subscriptionMode;
subscriptionFields.hidden = !subscriptionMode;
}
function normalizeApiBase() {
return (apiBaseInput.value.trim() || defaultApiBase()).replace(/\/+$/, "");
}
function authHeaders() {
const token = adminTokenInput.value.trim();
if (!token) {
throw new Error("admin token 不能为空");
}
return {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`,
};
}
function parseEntries() {
return entriesInput.value
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const [baseURL = "", apiKey = "", models = ""] = line.split("|").map((part) => part.trim());
if (!baseURL || !apiKey) {
throw new Error(`${index + 1} 行格式不完整,需要 base_url|api_key|models`);
}
return {
base_url: baseURL,
api_key: apiKey,
requested_models: models ? models.split(",").map((item) => item.trim()).filter(Boolean) : [],
};
});
}
function buildCreatePayload() {
const payload = {
host_id: hostIDInput.value.trim(),
mode: modeInput.value,
access_mode: accessModeInput.value,
confirm_wait_timeout_sec: Number(confirmTimeoutInput.value || 10),
entries: parseEntries(),
};
if (!payload.host_id) {
throw new Error("host_id 不能为空");
}
if (payload.access_mode === "self_service") {
payload.probe_api_key = probeAPIKeyInput.value.trim();
if (!payload.probe_api_key) {
throw new Error("self_service 模式下 probe_api_key 不能为空");
}
} else {
payload.subscription_users = subscriptionUsersInput.value
.split(",")
.map((value) => value.trim())
.filter(Boolean);
payload.subscription_days = Number(subscriptionDaysInput.value || 30);
if (!payload.subscription_users.length) {
throw new Error("subscription 模式下 subscription_users 不能为空");
}
if (!payload.subscription_days) {
throw new Error("subscription 模式下 subscription_days 不能为空");
}
}
return payload;
}
async function requestJSON(path, options = {}) {
const response = await fetch(`${normalizeApiBase()}${path}`, options);
const text = await response.text();
let payload = {};
try {
payload = text ? JSON.parse(text) : {};
} catch (error) {
payload = { raw: text };
}
if (!response.ok) {
const message = payload?.error?.message || payload?.error || payload?.raw || `HTTP ${response.status}`;
throw new Error(message);
}
return payload;
}
function syncHeaderMetrics() {
metricApiRoot.textContent = normalizeApiBase();
metricRunID.textContent = state.currentRunID || "-";
metricRunState.textContent = state.currentRun?.state || "-";
}
function renderRunMeta(run) {
runMeta.innerHTML = "";
const meta = [
`run_id=${run.run_id}`,
`state=${run.state}`,
`mode=${run.mode}`,
`access_mode=${run.access_mode}`,
];
meta.forEach((entry) => {
const code = document.createElement("code");
code.textContent = entry;
runMeta.appendChild(code);
});
}
function toneClass(tone) {
if (!tone) return "tone-gray";
return `tone-${tone}`;
}
function renderBadges(badges) {
if (!Array.isArray(badges) || !badges.length) {
return '<span class="subtle">-</span>';
}
return `<div class="badge-row">${badges.map((badge) => `<span class="badge ${toneClass(badge.tone)}">${escapeHTML(badge.label)}</span>`).join("")}</div>`;
}
function renderItems(items) {
if (!items.length) {
itemsTbody.innerHTML = '<tr><td colspan="7" class="subtle">当前过滤条件下没有条目。</td></tr>';
return;
}
itemsTbody.innerHTML = items.map((item) => {
const advisory = Array.isArray(item.advisory_messages) && item.advisory_messages.length
? item.advisory_messages.map((message) => `<div>${escapeHTML(message)}</div>`).join("")
: '<span class="subtle">-</span>';
return `
<tr>
<td>
<strong>${escapeHTML(item.provider_id)}</strong><br>
<code>${escapeHTML(item.api_key_fingerprint || "-")}</code>
</td>
<td><code>${escapeHTML(item.base_url)}</code></td>
<td>
<div>${escapeHTML(item.resolved_smoke_model || "-")}</div>
<div class="subtle">${escapeHTML((item.canonical_model_families || []).join(", ") || "-")}</div>
</td>
<td>
<div><strong>${escapeHTML(item.matched_account_state || "-")}</strong></div>
<div class="subtle">${escapeHTML(item.account_resolution || "-")}</div>
</td>
<td>
<div>${escapeHTML(item.access_status || "-")}</div>
<div class="subtle">${escapeHTML(item.confirmation_status || "-")} / ${escapeHTML(item.current_stage || "-")}</div>
</td>
<td>${renderBadges(item.badges)}</td>
<td>${advisory}</td>
</tr>
`;
}).join("");
}
function renderRunSummary(run) {
state.currentRun = run;
summaryTargets.total.textContent = String(run.total_items || 0);
summaryTargets.completed.textContent = String(run.completed_items || 0);
summaryTargets.active.textContent = String(run.active_items || 0);
summaryTargets.degraded.textContent = String(run.degraded_items || 0);
summaryTargets.broken.textContent = String(run.broken_items || 0);
renderRunMeta(run);
syncHeaderMetrics();
}
function clearResults() {
state.currentRun = null;
state.currentRunID = "";
state.currentItems = [];
runIDInput.value = "";
renderRunSummary({
run_id: "-",
state: "-",
mode: "-",
access_mode: "-",
total_items: 0,
completed_items: 0,
active_items: 0,
degraded_items: 0,
broken_items: 0,
});
runMeta.innerHTML = "";
itemsTbody.innerHTML = '<tr><td colspan="7" class="subtle">还没有结果。</td></tr>';
setStatus("结果已清空。");
}
async function createRun() {
const button = document.getElementById("create-run-btn");
button.disabled = true;
try {
saveConfig();
setStatus("正在创建 batch import run ...");
const payload = buildCreatePayload();
const created = await requestJSON("/api/batch-import/runs", {
method: "POST",
headers: authHeaders(),
body: JSON.stringify(payload),
});
state.currentRunID = created.run_id;
runIDInput.value = created.run_id;
syncHeaderMetrics();
setStatus(`run 已创建:${created.run_id},正在拉取详情。`, "success");
await refreshRun();
} catch (error) {
setStatus(`创建失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
function buildItemsQuery() {
const params = new URLSearchParams();
if (filterQueryInput.value.trim()) params.set("q", filterQueryInput.value.trim());
if (filterMatchedStateInput.value) params.set("matched_account_state", filterMatchedStateInput.value);
if (filterAccountResolutionInput.value) params.set("account_resolution", filterAccountResolutionInput.value);
return params.toString();
}
async function refreshRun() {
const runID = runIDInput.value.trim();
if (!runID) {
setStatus("请先输入 run_id。", "warning");
return;
}
const button = document.getElementById("refresh-run-btn");
button.disabled = true;
try {
state.currentRunID = runID;
syncHeaderMetrics();
setStatus(`正在刷新 ${runID} ...`);
const runPayload = await requestJSON(`/api/batch-import/runs/${encodeURIComponent(runID)}`, {
headers: authHeaders(),
});
renderRunSummary(runPayload.run);
const query = buildItemsQuery();
const itemsPayload = await requestJSON(`/api/batch-import/runs/${encodeURIComponent(runID)}/items${query ? `?${query}` : ""}`, {
headers: authHeaders(),
});
state.currentItems = itemsPayload.items || [];
renderItems(state.currentItems);
setStatus(`run ${runID} 已刷新,当前显示 ${state.currentItems.length} 条 item。`, "success");
} catch (error) {
setStatus(`刷新失败:${error.message}`, "danger");
} finally {
button.disabled = false;
}
}
function escapeHTML(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
document.getElementById("create-run-btn").addEventListener("click", createRun);
document.getElementById("refresh-run-btn").addEventListener("click", refreshRun);
document.getElementById("save-config-btn").addEventListener("click", saveConfig);
document.getElementById("load-sample-btn").addEventListener("click", loadSampleEntries);
document.getElementById("clear-items-btn").addEventListener("click", clearResults);
document.getElementById("apply-filter-btn").addEventListener("click", refreshRun);
accessModeInput.addEventListener("change", updateAccessModeFields);
restoreConfig();
updateAccessModeFields();
syncHeaderMetrics();
</script>
</body>
</html>

View File

@@ -58,6 +58,11 @@ SUB2API_CRM_ADMIN_TOKEN=change-me-before-production SUB2API_CRM_LISTEN_ADDR=127.
当前正式入口:
- `https://sub.tksea.top/portal/`
- `https://sub.tksea.top/portal/admin-batch-import.html`
- 最小管理页
- 直接消费 `POST /api/batch-import/runs`
- 直接消费 `GET /api/batch-import/runs/{run_id}`
- 直接消费 `GET /api/batch-import/runs/{run_id}/items`
兼容入口:

View File

@@ -20,9 +20,16 @@
- 旧地址 `https://sub.tksea.top/kimi-portal/` 当前保留为 `302` 跳转,避免历史分享链接失效
- 站点资产与 Nginx 路由不再只存在 `/tmp` 临时文件,已收口进仓库:
- `deploy/tksea-portal/index.html`
- `deploy/tksea-portal/admin-batch-import.html`
- `deploy/tksea-portal/nginx.sub.tksea.top.conf.example`
- `scripts/deploy/deploy_tksea_portal.sh`
- 新页面已补齐登录态、用户信息、可绑定分组、活跃订阅、历史 key 列表,以及“新创建 key 对应分组/模型”的即时展示
- 同轮已补最小 batch-import 管理页:
- 地址:`/portal/admin-batch-import.html`
- 直接消费 `POST /api/batch-import/runs`
- 直接消费 `GET /api/batch-import/runs/{run_id}`
- 直接消费 `GET /api/batch-import/runs/{run_id}/items`
- 用于验证 `matched_account_state / account_resolution / provision_reused`
- 线上无副作用验收已确认:
- `GET /portal/` 返回 `200`
- `GET /kimi-portal/` 返回 `302 -> /portal/`
@@ -65,7 +72,7 @@
- 当前主仓不再需要依赖历史临时 pack `openai-cn-pack-kimi-a7m`
- `kimi-a7m` provider manifest 现在也开始承载 `host_overlays` 元数据;本地已把 `sub2api v0.1.129` 的 Kimi A7M runtime overlay 说明与 `.patch` 资产纳入 `packs/openai-cn-pack/overlays/`
- 新增 `go run ./cmd/cli apply-host-overlay` 最小执行器;当前 pack 内命中的 overlay 已可直接生成 patched 宿主构建目录,不再只是 preview/import 阶段的提示信息
- 2026-05-25 已继续把路线 A 推进到运行态层面:
- 2026-05-25 已继续把路线 A 推进到运行态层面:
-`/tmp/sub2api-clean` 的 clean worktree `HEAD` 导出 stock 源码,再用 `go run ./cmd/cli apply-host-overlay --provider-id kimi-a7m --host-version 0.1.129` 生成全新 patched 源码树
- 基于该 patched 源码树重建 `localhost/sub2api:patched-overlay-20260525-clean`,并在独立 Podman 网络里启动新的 Postgres / Redis / App fresh-host
- `artifacts/real-host-acceptance/20260525_local_v0129_kimi_a7m_patched_overlay_image_freshhost_clean/21-summary.json` 已确认:`import_batch_status=succeeded``provider_status=active``latest_access_status=subscription_ready``completion_ok=true``completion_status=200`
@@ -159,6 +166,18 @@
- `scripts/acceptance/import_remote43_provider.sh` 会直探 provider `base_url` 对应的 upstream `/models``/chat/completions`
- 新增 `21-summary.json`,用于把 completion 失败自动分流成 `host_compatibility_gap``upstream_key_quota_issue`
- 2026-05-27 已把 V2 batch-import reuse runtime 真正接到 live action
- `internal/app/batch_runtime.go` 现已接入 `InspectReuse`
- runtime reuse 查询优先命中既有 `import_run_items`,再回退到 legacy `import_batches / import_batch_items / managed_resources / providers`
- 兼容 V2 短指纹与 legacy 完整 sha256 指纹
- live run 现在可真实产出 `matched_account_state / account_resolution / provision_reused`
- 2026-05-27 继续用 `/portal/admin-batch-import.html` 做真实页面操作验证,抓到了一个 live reuse 兼容缺口并已在本地修正:
- real remote43 样本 `https://api.53hk.cn/v1 + sk-4175...d776 + host=remote43-kimi-patched-auto2-18169` 首轮返回 `TOKEN_EXPIRED`,根因是 CRM 中持久化的宿主 bearer 已过期;刷新 host auth 后item 已能恢复到 `access_status=active`
- 旧版 runtime 仍把同一条历史账号判成 `matched_account_state=none / account_resolution=created`,根因是 live runtime 的 normalized `provider_id`(如 `api-53hk-42797c06`)与 legacy pack provider id`minimax-53hk`不一致时legacy reuse fallback 只按 `provider_id` 精确匹配
- 当前已补 `base_url` fallback + `ProviderMatched` 策略信号legacy lookup 会补查相同 `base_url` 的 provider且“同 base_url + 同 key + family covered”现在可以真实收敛到 `reused/reactivated`
- 定向回归已通过:`go test ./internal/app -run 'TestBatchImportHTTP/(create run action reuses matched legacy account|create run action reuses legacy account when pack provider id differs from normalized runtime id)$' -count=1``go test ./internal/batch -run TestDecideReuse -count=1``go test ./internal/store/sqlite -run 'TestProvidersRepoListBy(BaseURL|BaseURLEmpty)$' -count=1`
- remote43 二次复验现已补证:更新后的 CRM 二进制已替换到 `18173` 控制面,真实 rerun `run_1779882868037300268` 已确认 item 从 `account_resolution=created` 收敛为 `account_resolution=reused`,并且 `provision_reused=true``access_status=active`
- 当前剩余的细节是:该 rerun item 的 `matched_account_state` 仍为 `none`说明“reuse 命中后是否补出 active/disabled/deprecated state badge”仍可继续优化但这不影响本轮要验证的 `created -> reused` 结果成立
11. patched CRM external validation 已完成

View File

@@ -72,6 +72,8 @@
- `deploy/tksea-portal/index.html`
- `sub.tksea.top/portal/` 静态页
- `deploy/tksea-portal/admin-batch-import.html`
- `sub.tksea.top/portal/admin-batch-import.html` 最小管理页
- `deploy/tksea-portal/nginx.sub.tksea.top.conf.example`
- 对应 Nginx 路由示例

View File

@@ -37,6 +37,11 @@ func (r batchImportRuntimeRunner) execute(ctx context.Context) (BatchImportRunCr
ItemStore: r.store.ImportRunItems(),
ProbeModels: probe.ProviderModels,
ProbeCapabilities: probe.ProbeCapabilities,
InspectReuse: batchImportReuseInspector{
store: r.store,
hostRow: r.hostRow,
currentRunID: runID,
}.Inspect,
Provisioner: batchImportProvisioner{
store: r.store,
hostRow: r.hostRow,

View File

@@ -0,0 +1,421 @@
package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"sub2api-cn-relay-manager/internal/batch"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/probe"
"sub2api-cn-relay-manager/internal/store/sqlite"
)
type batchImportReuseInspector struct {
store *sqlite.DB
hostRow sqlite.Host
currentRunID string
}
func (i batchImportReuseInspector) Inspect(ctx context.Context, input batch.ReuseLookupInput) (batch.ReuseLookupResult, error) {
if i.store == nil {
return batch.ReuseLookupResult{}, fmt.Errorf("store is required")
}
if i.hostRow.ID <= 0 {
return batch.ReuseLookupResult{}, fmt.Errorf("host row is required")
}
if reuse, ok, err := i.lookupPriorRunItem(ctx, input); err != nil {
return batch.ReuseLookupResult{}, err
} else if ok {
return reuse, nil
}
return i.lookupLegacyImportBatch(ctx, input)
}
func (i batchImportReuseInspector) lookupPriorRunItem(ctx context.Context, input batch.ReuseLookupInput) (batch.ReuseLookupResult, bool, error) {
runs, err := i.store.ImportRuns().List(ctx, 1000)
if err != nil {
return batch.ReuseLookupResult{}, false, err
}
for _, run := range runs {
if strings.TrimSpace(run.HostID) != strings.TrimSpace(i.hostRow.HostID) {
continue
}
if strings.TrimSpace(run.RunID) == strings.TrimSpace(i.currentRunID) {
continue
}
items, err := i.store.ImportRunItems().ListByRunID(ctx, run.RunID)
if err != nil {
return batch.ReuseLookupResult{}, false, err
}
for _, item := range items {
if strings.TrimSpace(item.ProviderID) != strings.TrimSpace(input.ProviderID) {
continue
}
if !apiKeyFingerprintMatches(item.APIKeyFingerprint, input.APIKeyFingerprint) {
continue
}
return i.reuseFromRunItem(ctx, item)
}
}
return batch.ReuseLookupResult{}, false, nil
}
func (i batchImportReuseInspector) reuseFromRunItem(ctx context.Context, item sqlite.ImportRunItem) (batch.ReuseLookupResult, bool, error) {
modelMapping, err := i.loadExistingModelMapping(ctx, item.ProviderID)
if err != nil {
return batch.ReuseLookupResult{}, false, err
}
reusedAccountID := int64(0)
if item.AccountID != nil {
reusedAccountID = *item.AccountID
} else if item.ReusedFromAccountID != nil {
reusedAccountID = *item.ReusedFromAccountID
}
state := strings.TrimSpace(item.MatchedAccountState)
if state == "" {
state = string(batch.MatchedAccountStateNone)
}
return batch.ReuseLookupResult{
ProviderMatched: true,
ExistingProviderID: strings.TrimSpace(item.ProviderID),
ExistingAccessStatus: normalizeRunItemAccessStatus(item.AccessStatus),
ExistingCanonicalFamilys: parseStringArrayJSON(item.CanonicalFamiliesJSON),
MatchedAccountID: reusedAccountID,
MatchedAccountState: batch.MatchedAccountState(state),
ExistingModelMapping: modelMapping,
LegacyBatchID: item.LegacyBatchID,
}, true, nil
}
func (i batchImportReuseInspector) lookupLegacyImportBatch(ctx context.Context, input batch.ReuseLookupInput) (batch.ReuseLookupResult, error) {
providers, err := i.lookupLegacyProviders(ctx, input)
if err != nil {
return batch.ReuseLookupResult{}, err
}
type candidate struct {
provider sqlite.Provider
batch sqlite.ImportBatch
item sqlite.ImportBatchItem
resources []sqlite.ManagedResource
}
var best *candidate
for _, providerRow := range providers {
batches, err := i.store.ImportBatches().ListByProviderIDAndHostID(ctx, providerRow.ID, i.hostRow.ID)
if err != nil {
return batch.ReuseLookupResult{}, err
}
for _, batchRow := range batches {
items, err := i.store.ImportBatchItems().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return batch.ReuseLookupResult{}, err
}
for _, item := range items {
if !apiKeyFingerprintMatches(item.KeyFingerprint, input.APIKeyFingerprint) {
continue
}
resources, err := i.store.ManagedResources().GetByBatchID(ctx, batchRow.ID)
if err != nil {
return batch.ReuseLookupResult{}, err
}
best = &candidate{
provider: providerRow,
batch: batchRow,
item: item,
resources: resources,
}
break
}
if best != nil && best.batch.ID == batchRow.ID {
break
}
}
if best != nil {
break
}
}
if best == nil {
return batch.ReuseLookupResult{}, nil
}
modelMapping, err := providerModelMapping(best.provider)
if err != nil {
return batch.ReuseLookupResult{}, err
}
canonicalFamilies, err := providerCanonicalFamilies(best.provider)
if err != nil {
return batch.ReuseLookupResult{}, err
}
accountHostID, err := accountIDFromProbeSummary(best.item.ProbeSummaryJSON)
if err != nil {
return batch.ReuseLookupResult{}, err
}
return batch.ReuseLookupResult{
ProviderMatched: true,
ExistingProviderID: strings.TrimSpace(best.provider.ProviderID),
ExistingAccessStatus: normalizeLegacyBatchAccessStatus(best.batch.AccessStatus),
ExistingCanonicalFamilys: canonicalFamilies,
MatchedAccountID: resolveManagedAccountNumericID(accountHostID, best.resources),
MatchedAccountState: normalizeLegacyMatchedAccountState(best.item.AccountStatus, best.batch.AccessStatus),
ExistingModelMapping: modelMapping,
LegacyBatchID: int64Ptr(best.batch.ID),
}, nil
}
func (i batchImportReuseInspector) lookupLegacyProviders(ctx context.Context, input batch.ReuseLookupInput) ([]sqlite.Provider, error) {
seen := make(map[int64]struct{})
providers := make([]sqlite.Provider, 0)
appendUnique := func(rows []sqlite.Provider) {
for _, row := range rows {
if _, ok := seen[row.ID]; ok {
continue
}
seen[row.ID] = struct{}{}
providers = append(providers, row)
}
}
if strings.TrimSpace(input.ProviderID) != "" {
rows, err := i.store.Providers().ListByProviderID(ctx, input.ProviderID)
if err != nil {
return nil, err
}
appendUnique(rows)
}
if strings.TrimSpace(input.BaseURL) != "" {
rows, err := i.store.Providers().ListByBaseURL(ctx, strings.TrimSpace(input.BaseURL))
if err != nil {
return nil, err
}
appendUnique(rows)
}
return providers, nil
}
func (i batchImportReuseInspector) loadExistingModelMapping(ctx context.Context, providerID string) (map[string]string, error) {
providers, err := i.store.Providers().ListByProviderID(ctx, providerID)
if err != nil {
return nil, err
}
if len(providers) == 0 {
return nil, nil
}
for idx := len(providers) - 1; idx >= 0; idx-- {
providerRow := providers[idx]
batchRow, err := i.store.ImportBatches().GetLatestByProviderIDAndHostID(ctx, providerRow.ID, i.hostRow.ID)
if err != nil {
if err == sql.ErrNoRows {
continue
}
return nil, err
}
if batchRow.ID <= 0 {
continue
}
return providerModelMapping(providerRow)
}
return nil, nil
}
func providerModelMapping(providerRow sqlite.Provider) (map[string]string, error) {
type channelTemplatePayload struct {
ModelMapping map[string]string `json:"model_mapping"`
}
var manifest pack.ProviderManifest
if strings.TrimSpace(providerRow.ManifestJSON) != "" && strings.TrimSpace(providerRow.ManifestJSON) != "{}" {
if err := json.Unmarshal([]byte(providerRow.ManifestJSON), &manifest); err != nil {
return nil, fmt.Errorf("decode provider manifest for %q: %w", providerRow.ProviderID, err)
}
if len(manifest.ChannelTemplate.ModelMapping) > 0 {
return cloneStringMap(manifest.ChannelTemplate.ModelMapping), nil
}
}
if strings.TrimSpace(providerRow.ChannelTemplateJSON) != "" && strings.TrimSpace(providerRow.ChannelTemplateJSON) != "{}" {
var payload channelTemplatePayload
if err := json.Unmarshal([]byte(providerRow.ChannelTemplateJSON), &payload); err != nil {
return nil, fmt.Errorf("decode provider channel template for %q: %w", providerRow.ProviderID, err)
}
return cloneStringMap(payload.ModelMapping), nil
}
return map[string]string{}, nil
}
func providerCanonicalFamilies(providerRow sqlite.Provider) ([]string, error) {
models := make([]string, 0)
var manifest pack.ProviderManifest
if strings.TrimSpace(providerRow.ManifestJSON) != "" && strings.TrimSpace(providerRow.ManifestJSON) != "{}" {
if err := json.Unmarshal([]byte(providerRow.ManifestJSON), &manifest); err != nil {
return nil, fmt.Errorf("decode provider manifest for %q: %w", providerRow.ProviderID, err)
}
models = append(models, manifest.DefaultModels...)
for _, mapped := range manifest.ChannelTemplate.ModelMapping {
models = append(models, mapped)
}
}
models = append(models, parseStringArrayJSON(providerRow.DefaultModelsJSON)...)
seen := make(map[string]struct{}, len(models))
families := make([]string, 0, len(models))
for _, modelID := range models {
canonical := probe.CanonicalModelFamily(modelID)
if canonical == "" {
continue
}
if _, ok := seen[canonical]; ok {
continue
}
seen[canonical] = struct{}{}
families = append(families, canonical)
}
return families, nil
}
func normalizeRunItemAccessStatus(raw string) batch.AccessStatus {
switch strings.TrimSpace(raw) {
case string(batch.AccessStatusActive):
return batch.AccessStatusActive
case string(batch.AccessStatusDegraded):
return batch.AccessStatusDegraded
case string(batch.AccessStatusBroken):
return batch.AccessStatusBroken
default:
return batch.AccessStatusUnknown
}
}
func normalizeLegacyBatchAccessStatus(raw string) batch.AccessStatus {
switch strings.TrimSpace(raw) {
case "subscription_ready", "self_service_ready", "fully_ready":
return batch.AccessStatusActive
case "degraded":
return batch.AccessStatusDegraded
case "broken":
return batch.AccessStatusBroken
default:
return batch.AccessStatusUnknown
}
}
func normalizeLegacyMatchedAccountState(accountStatus string, batchAccessStatus string) batch.MatchedAccountState {
if normalizeLegacyBatchAccessStatus(batchAccessStatus) == batch.AccessStatusBroken {
return batch.MatchedAccountStateBroken
}
switch strings.TrimSpace(accountStatus) {
case "passed", "warning":
return batch.MatchedAccountStateActive
case "disabled":
return batch.MatchedAccountStateDisabled
case "deprecated":
return batch.MatchedAccountStateDeprecated
case "failed":
return batch.MatchedAccountStateBroken
default:
return batch.MatchedAccountStateNone
}
}
func accountIDFromProbeSummary(summaryJSON string) (string, error) {
if strings.TrimSpace(summaryJSON) == "" {
return "", nil
}
var payload map[string]any
if err := json.Unmarshal([]byte(summaryJSON), &payload); err != nil {
return "", err
}
accountID, _ := payload["account_id"].(string)
return strings.TrimSpace(accountID), nil
}
func resolveManagedAccountNumericID(accountHostID string, resources []sqlite.ManagedResource) int64 {
accountHostID = strings.TrimSpace(accountHostID)
if accountHostID == "" {
return 0
}
if numericID, err := strconv.ParseInt(accountHostID, 10, 64); err == nil && numericID > 0 {
return numericID
}
for _, resource := range resources {
if strings.TrimSpace(resource.ResourceType) != "account" {
continue
}
if strings.TrimSpace(resource.HostResourceID) == accountHostID {
return resource.ID
}
}
return 0
}
func apiKeyFingerprintMatches(stored string, lookup string) bool {
stored = normalizeFingerprint(stored)
lookup = normalizeFingerprint(lookup)
if stored == "" || lookup == "" {
return false
}
if stored == lookup {
return true
}
return strings.HasPrefix(stored, lookup) || strings.HasPrefix(lookup, stored)
}
func normalizeFingerprint(raw string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
return strings.TrimPrefix(trimmed, "sha256:")
}
func parseStringArrayJSON(raw string) []string {
values := []string{}
if strings.TrimSpace(raw) == "" {
return values
}
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return []string{}
}
return values
}
func cloneStringMap(input map[string]string) map[string]string {
if len(input) == 0 {
return map[string]string{}
}
cloned := make(map[string]string, len(input))
for key, value := range input {
cloned[key] = value
}
return cloned
}
func int64Ptr(value int64) *int64 {
if value <= 0 {
return nil
}
cloned := value
return &cloned
}

View File

@@ -2,12 +2,16 @@ package app
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"sub2api-cn-relay-manager/internal/batch"
"sub2api-cn-relay-manager/internal/pack"
"sub2api-cn-relay-manager/internal/store/sqlite"
"sub2api-cn-relay-manager/internal/testutil"
)
@@ -197,6 +201,120 @@ func TestBatchImportHTTP(t *testing.T) {
t.Fatalf("item = %+v, want current_stage=done and access_status=active", items[0])
}
})
t.Run("create run action reuses matched legacy account", func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(newBatchImportActionStubServer(t))
defer server.Close()
dsn := testutil.SQLiteTestDSN(t, "reuse-state.db", true)
store := testutil.OpenSQLiteStore(t, dsn)
defer closeAppTestStore(t, store)
hostPK := mustCreateBatchImportActionHost(t, store, server.URL)
packPK, providerPK := mustSeedLegacyBatchImportProvider(t, store, server.URL)
legacyBatchID := mustCreateLegacyReusableBatch(t, store, hostPK, packPK, providerPK, "entry-key", "account_1")
action := buildCreateBatchImportRunAction(dsn)
result, err := action(context.Background(), CreateBatchImportRunRequest{
HostID: "host-1",
Mode: "strict",
AccessMode: "self_service",
ConfirmWaitTimeoutSec: 1,
ProbeAPIKey: "gateway-key",
Entries: []BatchImportEntryRequest{{
BaseURL: server.URL,
APIKey: "entry-key",
RequestedModels: []string{"kimi-k2.6"},
}},
})
if err != nil {
t.Fatalf("buildCreateBatchImportRunAction() reuse error = %v", err)
}
if strings.TrimSpace(result.RunID) == "" {
t.Fatalf("result.RunID = %q, want non-empty", result.RunID)
}
items, err := store.ImportRunItems().ListByRunID(context.Background(), result.RunID)
if err != nil {
t.Fatalf("ImportRunItems().ListByRunID() reuse error = %v", err)
}
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
item := items[0]
if !item.ProvisionReused || item.AccountResolution != "reused" || item.MatchedAccountState != "active" {
t.Fatalf("reuse item = %+v, want provision_reused + reused + active", item)
}
if item.LegacyBatchID == nil || *item.LegacyBatchID != legacyBatchID {
t.Fatalf("LegacyBatchID = %v, want %d", item.LegacyBatchID, legacyBatchID)
}
if item.CurrentStage != "done" || item.AccessStatus != "active" {
t.Fatalf("reuse item final state = %+v, want done/active", item)
}
batches, err := store.ImportBatches().ListByProviderIDAndHostID(context.Background(), providerPK, hostPK)
if err != nil {
t.Fatalf("ImportBatches().ListByProviderIDAndHostID() error = %v", err)
}
if len(batches) != 1 {
t.Fatalf("len(batches) = %d, want 1 legacy batch only", len(batches))
}
})
t.Run("create run action reuses legacy account when pack provider id differs from normalized runtime id", func(t *testing.T) {
t.Parallel()
server := httptest.NewServer(newBatchImportActionStubServer(t))
defer server.Close()
dsn := testutil.SQLiteTestDSN(t, "reuse-baseurl-fallback.db", true)
store := testutil.OpenSQLiteStore(t, dsn)
defer closeAppTestStore(t, store)
hostPK := mustCreateBatchImportActionHost(t, store, server.URL)
packPK, providerPK := mustSeedLegacyBatchImportProviderWithID(t, store, server.URL, "legacy-pack-provider")
legacyBatchID := mustCreateLegacyReusableBatch(t, store, hostPK, packPK, providerPK, "entry-key", "101")
action := buildCreateBatchImportRunAction(dsn)
result, err := action(context.Background(), CreateBatchImportRunRequest{
HostID: "host-1",
Mode: "strict",
AccessMode: "self_service",
ConfirmWaitTimeoutSec: 1,
ProbeAPIKey: "gateway-key",
Entries: []BatchImportEntryRequest{{
BaseURL: server.URL,
APIKey: "entry-key",
RequestedModels: []string{"kimi-k2.6"},
}},
})
if err != nil {
t.Fatalf("buildCreateBatchImportRunAction() base_url fallback reuse error = %v", err)
}
if strings.TrimSpace(result.RunID) == "" {
t.Fatalf("result.RunID = %q, want non-empty", result.RunID)
}
items, err := store.ImportRunItems().ListByRunID(context.Background(), result.RunID)
if err != nil {
t.Fatalf("ImportRunItems().ListByRunID() base_url fallback error = %v", err)
}
if len(items) != 1 {
t.Fatalf("len(items) = %d, want 1", len(items))
}
item := items[0]
if !item.ProvisionReused || item.AccountResolution != "reused" || item.MatchedAccountState != "active" {
t.Fatalf("reuse item = %+v, want provision_reused + reused + active", item)
}
if item.LegacyBatchID == nil || *item.LegacyBatchID != legacyBatchID {
t.Fatalf("LegacyBatchID = %v, want %d", item.LegacyBatchID, legacyBatchID)
}
if item.CurrentStage != "done" || item.AccessStatus != "active" {
t.Fatalf("reuse item final state = %+v, want done/active", item)
}
})
}
func TestBatchImportWrapperFunctions(t *testing.T) {
@@ -363,6 +481,21 @@ func newBatchImportActionStubServer(t *testing.T) http.Handler {
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []map[string]any{{"id": "kimi-k2.6", "display_name": "Kimi K2.6", "type": "chat"}}}})
})
mux.HandleFunc("/api/v1/admin/accounts/101/test", func(w http.ResponseWriter, r *http.Request) {
if !requireBatchImportActionAdminToken(t, w, r) {
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("event: result\n"))
_, _ = w.Write([]byte("data: {\"status\":\"passed\",\"message\":\"smoke passed\",\"ok\":true}\n\n"))
})
mux.HandleFunc("/api/v1/admin/accounts/101/models", func(w http.ResponseWriter, r *http.Request) {
if !requireBatchImportActionAdminToken(t, w, r) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []map[string]any{{"id": "kimi-k2.6", "display_name": "Kimi K2.6", "type": "chat"}}}})
})
mux.HandleFunc("/api/v1/admin/subscriptions/assign", func(w http.ResponseWriter, r *http.Request) {
if !requireBatchImportActionAdminToken(t, w, r) {
return
@@ -425,3 +558,142 @@ func requireBatchImportActionAdminToken(t *testing.T, w http.ResponseWriter, r *
}
return true
}
func mustCreateBatchImportActionHost(t *testing.T, store *sqlite.DB, baseURL string) int64 {
t.Helper()
hostPK, err := store.Hosts().Create(context.Background(), sqlite.Host{
HostID: "host-1",
BaseURL: baseURL,
HostVersion: "0.1.126",
CapabilityProbeJSON: "{}",
AuthType: "apikey",
AuthToken: "host-token",
})
if err != nil {
t.Fatalf("Hosts().Create() error = %v", err)
}
return hostPK
}
func mustSeedLegacyBatchImportProvider(t *testing.T, store *sqlite.DB, baseURL string) (int64, int64) {
t.Helper()
return mustSeedLegacyBatchImportProviderWithID(t, store, baseURL, batch.NormalizeProviderID(baseURL))
}
func mustSeedLegacyBatchImportProviderWithID(t *testing.T, store *sqlite.DB, baseURL, providerID string) (int64, int64) {
t.Helper()
packPK, err := store.Packs().Create(context.Background(), sqlite.Pack{
PackID: "seed-pack",
Version: "1.0.0",
Checksum: "seed-pack@1.0.0",
Vendor: "test",
TargetHost: "sub2api",
ManifestJSON: `{"pack_id":"seed-pack","version":"1.0.0","target_host":"sub2api"}`,
})
if err != nil {
t.Fatalf("Packs().Create() error = %v", err)
}
providerManifest := pack.ProviderManifest{
ProviderID: strings.TrimSpace(providerID),
DisplayName: "Legacy Reuse Provider",
BaseURL: baseURL,
Platform: "openai",
AccountType: "apikey",
DefaultModels: []string{"kimi-k2.6"},
SmokeTestModel: "kimi-k2.6",
GroupTemplate: pack.GroupTemplate{
Name: "legacy-group",
RateMultiplier: 1,
},
ChannelTemplate: pack.ChannelTemplate{
Name: "legacy-channel",
ModelMapping: map[string]string{"kimi-k2.6": "kimi-k2.6"},
},
PlanTemplate: pack.PlanTemplate{
Name: "legacy-plan",
Price: 1,
ValidityDays: 30,
ValidityUnit: "day",
},
Import: pack.ImportOptions{
SupportsMultiKey: true,
SupportsStrict: true,
SupportsPartial: true,
},
}
manifestJSON, err := json.Marshal(providerManifest)
if err != nil {
t.Fatalf("json.Marshal(providerManifest) error = %v", err)
}
defaultModelsJSON, err := json.Marshal(providerManifest.DefaultModels)
if err != nil {
t.Fatalf("json.Marshal(defaultModels) error = %v", err)
}
channelTemplateJSON, err := json.Marshal(providerManifest.ChannelTemplate)
if err != nil {
t.Fatalf("json.Marshal(channelTemplate) error = %v", err)
}
providerPK, err := store.Providers().Create(context.Background(), sqlite.Provider{
PackID: packPK,
ProviderID: providerManifest.ProviderID,
DisplayName: providerManifest.DisplayName,
BaseURL: providerManifest.BaseURL,
Platform: providerManifest.Platform,
AccountType: providerManifest.AccountType,
DefaultModelsJSON: string(defaultModelsJSON),
SmokeTestModel: providerManifest.SmokeTestModel,
ChannelTemplateJSON: string(channelTemplateJSON),
ManifestJSON: string(manifestJSON),
})
if err != nil {
t.Fatalf("Providers().Create() error = %v", err)
}
return packPK, providerPK
}
func mustCreateLegacyReusableBatch(t *testing.T, store *sqlite.DB, hostPK int64, packPK int64, providerPK int64, apiKey string, accountID string) int64 {
t.Helper()
batchID, err := store.ImportBatches().Create(context.Background(), sqlite.ImportBatch{
HostID: hostPK,
PackID: packPK,
ProviderID: providerPK,
Mode: "strict",
BatchStatus: "succeeded",
AccessStatus: "self_service_ready",
})
if err != nil {
t.Fatalf("ImportBatches().Create() error = %v", err)
}
if _, err := store.ImportBatchItems().Create(context.Background(), sqlite.ImportBatchItem{
BatchID: batchID,
KeyFingerprint: fullSHA256Fingerprint(apiKey),
AccountStatus: "passed",
ProbeSummaryJSON: fmt.Sprintf(`{"account_id":"%s"}`, accountID),
}); err != nil {
t.Fatalf("ImportBatchItems().Create() error = %v", err)
}
if _, err := store.ManagedResources().Create(context.Background(), sqlite.ManagedResource{
BatchID: batchID,
HostID: hostPK,
ResourceType: "account",
HostResourceID: accountID,
ResourceName: "legacy-account",
}); err != nil {
t.Fatalf("ManagedResources().Create(account) error = %v", err)
}
return batchID
}
func fullSHA256Fingerprint(value string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(value)))
return fmt.Sprintf("sha256:%x", sum[:])
}

View File

@@ -7,6 +7,7 @@ import (
)
type ReuseInput struct {
ProviderMatched bool
ProviderID string
CanonicalModelFamilies []string
MatchedAccountID int64
@@ -36,7 +37,7 @@ func DecideReuse(input ReuseInput) ReuseDecision {
decision.MatchedAccountState = MatchedAccountStateNone
}
if !sameProvider(input.ProviderID, input.ExistingProviderID) || !decision.FamilyCovered {
if !providerMatched(input) || !decision.FamilyCovered {
return decision
}
@@ -93,3 +94,10 @@ func canonicalFamiliesCovered(requested []string, existing []string) bool {
func sameProvider(left, right string) bool {
return strings.TrimSpace(left) != "" && strings.TrimSpace(left) == strings.TrimSpace(right)
}
func providerMatched(input ReuseInput) bool {
if input.ProviderMatched {
return true
}
return sameProvider(input.ProviderID, input.ExistingProviderID)
}

View File

@@ -9,6 +9,7 @@ func TestDecideReuse(t *testing.T) {
t.Parallel()
decision := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-deepseek-12345678",
CanonicalModelFamilies: []string{"kimi-k2.6"},
MatchedAccountID: 101,
@@ -38,6 +39,7 @@ func TestDecideReuse(t *testing.T) {
t.Parallel()
decision := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-kimi-12345678",
CanonicalModelFamilies: []string{"kimi-k2.6"},
MatchedAccountID: 202,
@@ -61,6 +63,7 @@ func TestDecideReuse(t *testing.T) {
t.Parallel()
brokenProvider := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-deepseek-12345678",
CanonicalModelFamilies: []string{"deepseek-v4-pro"},
MatchedAccountState: MatchedAccountStateActive,
@@ -76,6 +79,7 @@ func TestDecideReuse(t *testing.T) {
}
brokenAccount := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-deepseek-12345678",
CanonicalModelFamilies: []string{"deepseek-v4-pro"},
MatchedAccountState: MatchedAccountStateBroken,
@@ -95,6 +99,7 @@ func TestDecideReuse(t *testing.T) {
t.Parallel()
decision := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-kimi-12345678",
CanonicalModelFamilies: []string{"kimi-k2.6"},
MatchedAccountState: MatchedAccountStateActive,
@@ -110,4 +115,26 @@ func TestDecideReuse(t *testing.T) {
t.Fatal("FamilyCovered = false, want true")
}
})
t.Run("base url matched legacy provider is reused even when provider ids differ", func(t *testing.T) {
t.Parallel()
decision := DecideReuse(ReuseInput{
ProviderMatched: true,
ProviderID: "api-53hk-42797c06",
CanonicalModelFamilies: []string{"minimax-m2.7-highspeed"},
MatchedAccountID: 101,
MatchedAccountState: MatchedAccountStateActive,
ExistingProviderID: "minimax-53hk",
ExistingAccessStatus: AccessStatusActive,
ExistingCanonicalFamilys: []string{"minimax-m2.5-highspeed", "minimax-m2.7-highspeed"},
})
if !decision.ProvisionReused {
t.Fatal("ProvisionReused = false, want true")
}
if decision.AccountResolution != AccountResolutionReused {
t.Fatalf("AccountResolution = %q, want %q", decision.AccountResolution, AccountResolutionReused)
}
})
}

View File

@@ -53,12 +53,14 @@ type ReuseLookupInput struct {
}
type ReuseLookupResult struct {
ProviderMatched bool
ExistingProviderID string
ExistingAccessStatus AccessStatus
ExistingCanonicalFamilys []string
MatchedAccountID int64
MatchedAccountState MatchedAccountState
ExistingModelMapping map[string]string
LegacyBatchID *int64
}
type ProvisionRequest struct {
@@ -201,6 +203,7 @@ func (s BatchImportService) StartRun(ctx context.Context, req BatchImportRunRequ
}
reuseDecision := DecideReuse(ReuseInput{
ProviderMatched: reuseLookup.ProviderMatched,
ProviderID: providerID,
CanonicalModelFamilies: canonicalFamilies,
MatchedAccountID: reuseLookup.MatchedAccountID,
@@ -231,6 +234,8 @@ func (s BatchImportService) StartRun(ctx context.Context, req BatchImportRunRequ
ProvisionReused: reuseDecision.ProvisionReused,
ReusedFromProviderID: reuseDecision.ReusedFromProviderID,
ReusedFromAccountID: int64PtrIfSet(reuseDecision.ReusedFromAccountID),
LegacyBatchID: reuseLookup.LegacyBatchID,
LegacyProviderID: strings.TrimSpace(reuseLookup.ExistingProviderID),
}
if reuseDecision.ProvisionReused {

View File

@@ -112,6 +112,47 @@ func (r *ProvidersRepo) ListByProviderID(ctx context.Context, providerID string)
return providers, nil
}
func (r *ProvidersRepo) ListByBaseURL(ctx context.Context, baseURL string) ([]Provider, error) {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return nil, fmt.Errorf("base_url is required")
}
rows, err := r.db.QueryContext(ctx, `SELECT id, pack_id, provider_id, display_name, base_url, platform, account_type, default_models_json, smoke_test_model, group_template_json, channel_template_json, plan_template_json, import_options_json, manifest_json FROM providers WHERE base_url = ? ORDER BY id`, baseURL)
if err != nil {
return nil, fmt.Errorf("query providers by base_url %q: %w", baseURL, err)
}
defer rows.Close()
providers := make([]Provider, 0)
for rows.Next() {
var provider Provider
if err := rows.Scan(
&provider.ID,
&provider.PackID,
&provider.ProviderID,
&provider.DisplayName,
&provider.BaseURL,
&provider.Platform,
&provider.AccountType,
&provider.DefaultModelsJSON,
&provider.SmokeTestModel,
&provider.GroupTemplateJSON,
&provider.ChannelTemplateJSON,
&provider.PlanTemplateJSON,
&provider.ImportOptionsJSON,
&provider.ManifestJSON,
); err != nil {
return nil, fmt.Errorf("scan provider by base_url %q: %w", baseURL, err)
}
providers = append(providers, provider)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate providers by base_url %q: %w", baseURL, err)
}
return providers, nil
}
func (r *ProvidersRepo) GetByPackIDAndProviderID(ctx context.Context, packID int64, providerID string) (Provider, error) {
if packID <= 0 {
return Provider{}, fmt.Errorf("pack_id is required")

View File

@@ -55,6 +55,24 @@ func TestProvidersRepoListByProviderID(t *testing.T) {
}
}
func TestProvidersRepoListByBaseURL(t *testing.T) {
store := openTestDB(t)
packID1 := createTestPackWithSuffix(t, store, "base-a")
packID2 := createTestPackWithSuffix(t, store, "base-b")
store.Providers().Create(context.Background(), Provider{PackID: packID1, ProviderID: "minimax-53hk", DisplayName: "MM1", BaseURL: "https://api.53hk.cn/v1", Platform: "openai"})
store.Providers().Create(context.Background(), Provider{PackID: packID2, ProviderID: "api-53hk-42797c06", DisplayName: "MM2", BaseURL: "https://api.53hk.cn/v1", Platform: "openai"})
providers, err := store.Providers().ListByBaseURL(context.Background(), "https://api.53hk.cn/v1")
if err != nil {
t.Fatalf("ListByBaseURL() error = %v", err)
}
if len(providers) != 2 {
t.Fatalf("ListByBaseURL() count = %d, want 2", len(providers))
}
}
func TestProvidersRepoListByPackID(t *testing.T) {
store := openTestDB(t)
packID := createTestPack(t, store)
@@ -105,6 +123,18 @@ func TestProvidersRepoListByProviderIDEmpty(t *testing.T) {
}
}
func TestProvidersRepoListByBaseURLEmpty(t *testing.T) {
store := openTestDB(t)
providers, err := store.Providers().ListByBaseURL(context.Background(), "https://missing.example.com/v1")
if err != nil {
t.Fatalf("ListByBaseURL() error = %v", err)
}
if len(providers) != 0 {
t.Fatalf("ListByBaseURL() count = %d, want 0", len(providers))
}
}
func TestProvidersRepoUpsertCreatesNew(t *testing.T) {
store := openTestDB(t)
packID := createTestPack(t, store)

View File

@@ -7,7 +7,7 @@ REMOTE="${REMOTE:-ubuntu@43.155.133.187}"
REMOTE_PORTAL_DIR="${REMOTE_PORTAL_DIR:-/var/www/sub2api-portal}"
REMOTE_NGINX_SITE="${REMOTE_NGINX_SITE:-/etc/nginx/sites-available/tksea}"
REMOTE_HOST_PORT="${REMOTE_HOST_PORT:-18169}"
LOCAL_PORTAL_INDEX="${LOCAL_PORTAL_INDEX:-$ROOT_DIR/deploy/tksea-portal/index.html}"
LOCAL_PORTAL_DIR="${LOCAL_PORTAL_DIR:-$ROOT_DIR/deploy/tksea-portal}"
REMOTE_STAGE_DIR="${REMOTE_STAGE_DIR:-/tmp/sub2api-portal-deploy}"
DRY_RUN="${DRY_RUN:-0}"
@@ -43,18 +43,20 @@ main() {
require_cmd ssh
require_cmd scp
[[ -f "$LOCAL_PORTAL_INDEX" ]] || die "missing portal index: $LOCAL_PORTAL_INDEX"
[[ -d "$LOCAL_PORTAL_DIR" ]] || die "missing portal dir: $LOCAL_PORTAL_DIR"
[[ -f "$LOCAL_PORTAL_DIR/index.html" ]] || die "missing portal index: $LOCAL_PORTAL_DIR/index.html"
if [[ "$DRY_RUN" != "1" ]]; then
[[ -f "$KEY" ]] || die "missing ssh key: $KEY"
fi
local tmpdir patch_file index_copy
local tmpdir patch_file portal_stage_dir
tmpdir="$(mktemp -d)"
trap "rm -rf $(printf '%q' "$tmpdir")" EXIT
patch_file="$tmpdir/patch_tksea_portal_nginx.py"
index_copy="$tmpdir/index.html"
portal_stage_dir="$tmpdir/portal"
cp "$LOCAL_PORTAL_INDEX" "$index_copy"
mkdir -p "$portal_stage_dir"
cp -R "$LOCAL_PORTAL_DIR/." "$portal_stage_dir/"
cat > "$patch_file" <<EOF
from pathlib import Path
@@ -144,14 +146,15 @@ path.write_text(text)
EOF
ssh_remote "mkdir -p $(printf '%q' "$REMOTE_STAGE_DIR")"
scp_remote "$index_copy" "$REMOTE:$REMOTE_STAGE_DIR/index.html"
scp_remote -r "$portal_stage_dir" "$REMOTE:$REMOTE_STAGE_DIR/"
scp_remote "$patch_file" "$REMOTE:$REMOTE_STAGE_DIR/patch_tksea_portal_nginx.py"
ssh_remote "sudo install -d -m 755 $(printf '%q' "$REMOTE_PORTAL_DIR") && sudo cp $(printf '%q' "$REMOTE_STAGE_DIR/index.html") $(printf '%q' "$REMOTE_PORTAL_DIR/index.html") && sudo python3 $(printf '%q' "$REMOTE_STAGE_DIR/patch_tksea_portal_nginx.py") && sudo nginx -t && sudo systemctl reload nginx"
ssh_remote "sudo install -d -m 755 $(printf '%q' "$REMOTE_PORTAL_DIR") && sudo cp -R $(printf '%q' "$REMOTE_STAGE_DIR/portal/.") $(printf '%q' "$REMOTE_PORTAL_DIR/") && sudo python3 $(printf '%q' "$REMOTE_STAGE_DIR/patch_tksea_portal_nginx.py") && sudo nginx -t && sudo systemctl reload nginx"
cat <<EOF
tksea portal deployed
remote: ${REMOTE}
portal url: https://sub.tksea.top/portal/
batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html
legacy url: https://sub.tksea.top/kimi-portal/
portal dir: ${REMOTE_PORTAL_DIR}
nginx site: ${REMOTE_NGINX_SITE}

View File

@@ -3,6 +3,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
HTML_FILE="$ROOT_DIR/deploy/tksea-portal/index.html"
ADMIN_HTML_FILE="$ROOT_DIR/deploy/tksea-portal/admin-batch-import.html"
NGINX_FILE="$ROOT_DIR/deploy/tksea-portal/nginx.sub.tksea.top.conf.example"
DEPLOY_SCRIPT="$ROOT_DIR/scripts/deploy/deploy_tksea_portal.sh"
@@ -20,6 +21,7 @@ assert_contains_file() {
}
[[ -f "$HTML_FILE" ]] || fail "missing $HTML_FILE"
[[ -f "$ADMIN_HTML_FILE" ]] || fail "missing $ADMIN_HTML_FILE"
[[ -f "$NGINX_FILE" ]] || fail "missing $NGINX_FILE"
[[ -f "$DEPLOY_SCRIPT" ]] || fail "missing $DEPLOY_SCRIPT"
@@ -41,6 +43,17 @@ assert_contains_file "$HTML_FILE" "gpt-5.4"
assert_contains_file "$HTML_FILE" "MiniMax-M2.7-highspeed"
assert_contains_file "$HTML_FILE" "deepseek-chat"
assert_contains_file "$ADMIN_HTML_FILE" "Batch Import Admin"
assert_contains_file "$ADMIN_HTML_FILE" "matched_account_state"
assert_contains_file "$ADMIN_HTML_FILE" "account_resolution"
assert_contains_file "$ADMIN_HTML_FILE" "/api/batch-import/runs"
assert_contains_file "$ADMIN_HTML_FILE" "/api/batch-import/runs/"
assert_contains_file "$ADMIN_HTML_FILE" '/items${query ?'
assert_contains_file "$ADMIN_HTML_FILE" "Authorization"
assert_contains_file "$ADMIN_HTML_FILE" "base_url|api_key|requested_model_1,requested_model_2"
assert_contains_file "$ADMIN_HTML_FILE" "reused"
assert_contains_file "$ADMIN_HTML_FILE" "reactivated"
assert_contains_file "$NGINX_FILE" "location = /portal"
assert_contains_file "$NGINX_FILE" "location = /kimi-portal"
assert_contains_file "$NGINX_FILE" "location /portal/"
@@ -48,7 +61,9 @@ assert_contains_file "$NGINX_FILE" "location /portal-proxy/"
assert_contains_file "$NGINX_FILE" "location /kimi-portal-proxy/"
assert_contains_file "$DEPLOY_SCRIPT" "portal url: https://sub.tksea.top/portal/"
assert_contains_file "$DEPLOY_SCRIPT" "batch import admin url: https://sub.tksea.top/portal/admin-batch-import.html"
assert_contains_file "$DEPLOY_SCRIPT" "REMOTE_PORTAL_DIR"
assert_contains_file "$DEPLOY_SCRIPT" "LOCAL_PORTAL_DIR"
assert_contains_file "$DEPLOY_SCRIPT" "patch_tksea_portal_nginx.py"
echo "PASS: tksea portal assets look consistent"