feat(admin): persist provider drafts in crm
This commit is contained in:
@@ -75,6 +75,8 @@ 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/index.html](./deploy/tksea-portal/admin/index.html) —— 管理入口首页
|
||||
- [deploy/tksea-portal/admin/providers.html](./deploy/tksea-portal/admin/providers.html) —— provider 目录 / preview-import / import / manifest 草稿页(含服务端草稿保存)
|
||||
- [deploy/tksea-portal/admin-batch-import.html](./deploy/tksea-portal/admin-batch-import.html) —— 最小 batch-import 管理页
|
||||
|
||||
背景/设计文档:
|
||||
|
||||
@@ -8,11 +8,22 @@
|
||||
|
||||
- `tksea-portal/index.html`
|
||||
- `https://sub.tksea.top/portal/` 的静态页面源码
|
||||
- `tksea-portal/admin/index.html`
|
||||
- `https://sub.tksea.top/portal/admin/` 的管理首页
|
||||
- 统一收纳“新增模型 / 供应商目录”和“导入供应商帐号”入口
|
||||
- `tksea-portal/admin/providers.html`
|
||||
- `https://sub.tksea.top/portal/admin/providers.html`
|
||||
- 用现有 CRM API 做 pack/provider 浏览、preview-import、import,以及 provider manifest 草稿生成
|
||||
- 当前也可直接调用服务端 `provider_drafts` API,把 manifest 草稿持久化到 CRM SQLite,并支持更新 / 删除
|
||||
- `tksea-portal/admin/batch-import.html`
|
||||
- `https://sub.tksea.top/portal/admin/batch-import.html`
|
||||
- 结构化入口地址,当前跳转到 legacy `admin-batch-import.html`
|
||||
- `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 路由与代理示例
|
||||
- 当前同时包含 `/portal-proxy/` 宿主用户态代理与 `/portal-admin-api/` CRM 管理态代理
|
||||
|
||||
它和 `scripts/` 的边界如下:
|
||||
|
||||
|
||||
@@ -42,6 +42,33 @@
|
||||
margin: 0 auto;
|
||||
padding: 36px 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, color 120ms ease;
|
||||
}
|
||||
.topnav a:hover {
|
||||
transform: translateY(-1px);
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
.topnav a.is-current {
|
||||
background: var(--ink);
|
||||
color: #fff;
|
||||
border-color: var(--ink);
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 0.7fr;
|
||||
@@ -343,6 +370,13 @@
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<nav class="topnav" aria-label="Admin Navigation">
|
||||
<a href="/portal/admin/">管理首页</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>
|
||||
</nav>
|
||||
|
||||
<section class="hero">
|
||||
<article class="hero-card">
|
||||
<div class="eyebrow">Batch Import Admin</div>
|
||||
@@ -350,7 +384,7 @@
|
||||
<p class="hero-copy">
|
||||
这个页面只做三件事:发起 batch import、查看 run 摘要、拉取 item 级复用结果。
|
||||
后端仍然以现有 `POST /api/batch-import/runs` 与 `GET /api/batch-import/runs/*` 为准,
|
||||
页面不引入额外协议。
|
||||
页面不引入额外协议。默认通过同域 `portal-admin-api` 访问 CRM。
|
||||
</p>
|
||||
<ul class="hero-points">
|
||||
<li>直接展示 `matched_account_state`</li>
|
||||
@@ -384,7 +418,7 @@
|
||||
|
||||
<div class="field-grid two">
|
||||
<label>API Base
|
||||
<input id="api-base" type="text" placeholder="https://crm.example.com">
|
||||
<input id="api-base" type="text" placeholder="https://sub.tksea.top/portal-admin-api">
|
||||
</label>
|
||||
<label>Host ID
|
||||
<input id="host-id" type="text" placeholder="remote43-current-host">
|
||||
@@ -573,7 +607,7 @@ https://api.other.com/v1|sk-example-2|deepseek-chat,gpt-5.4</textarea>
|
||||
}
|
||||
|
||||
function defaultApiBase() {
|
||||
return window.location.origin;
|
||||
return `${window.location.origin}/portal-admin-api`;
|
||||
}
|
||||
|
||||
function saveConfig() {
|
||||
|
||||
39
deploy/tksea-portal/admin/batch-import.html
Normal file
39
deploy/tksea-portal/admin/batch-import.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Batch Import Admin Redirect</title>
|
||||
<meta http-equiv="refresh" content="0; url=/portal/admin-batch-import.html">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-family: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
background: linear-gradient(180deg, #f8f3ea 0%, #efe5d7 100%);
|
||||
color: #1f1a16;
|
||||
}
|
||||
.card {
|
||||
width: min(34rem, calc(100vw - 2rem));
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(31, 26, 22, 0.12);
|
||||
background: rgba(255,252,246,0.92);
|
||||
box-shadow: 0 20px 60px rgba(46, 37, 28, 0.08);
|
||||
}
|
||||
a {
|
||||
color: #0c6cc9;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<h1>正在跳转到导入供应商帐号页面</h1>
|
||||
<p>如果浏览器没有自动跳转,请手动打开:</p>
|
||||
<p><a href="/portal/admin-batch-import.html">/portal/admin-batch-import.html</a></p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
356
deploy/tksea-portal/admin/index.html
Normal file
356
deploy/tksea-portal/admin/index.html
Normal file
@@ -0,0 +1,356 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin Portal</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f4efe6;
|
||||
--panel: rgba(255, 252, 246, 0.92);
|
||||
--ink: #1f1a16;
|
||||
--muted: #665c53;
|
||||
--line: rgba(31, 26, 22, 0.12);
|
||||
--accent: #0c6cc9;
|
||||
--accent-soft: rgba(12, 108, 201, 0.12);
|
||||
--success: #136f46;
|
||||
--success-soft: rgba(19, 111, 70, 0.1);
|
||||
--warn: #9f6417;
|
||||
--warn-soft: rgba(159, 100, 23, 0.12);
|
||||
--shadow: 0 24px 70px rgba(46, 37, 28, 0.1);
|
||||
--radius: 24px;
|
||||
--radius-sm: 16px;
|
||||
--font-sans: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(12, 108, 201, 0.18), transparent 24rem),
|
||||
radial-gradient(circle at bottom right, rgba(19, 111, 70, 0.12), transparent 28rem),
|
||||
linear-gradient(180deg, #f8f3ea 0%, #f1e9dd 100%);
|
||||
}
|
||||
a { color: inherit; }
|
||||
.shell {
|
||||
max-width: 1280px;
|
||||
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.74);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.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 {
|
||||
padding: 30px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hero-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -4rem;
|
||||
bottom: -4rem;
|
||||
width: 16rem;
|
||||
height: 16rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, rgba(12, 108, 201, 0.2), rgba(19, 111, 70, 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(34px, 5vw, 50px);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
.hero-copy {
|
||||
max-width: 56rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.75;
|
||||
font-size: 16px;
|
||||
}
|
||||
.hero-points {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 0;
|
||||
margin: 18px 0 0;
|
||||
list-style: none;
|
||||
}
|
||||
.hero-points li {
|
||||
padding: 9px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.74);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.stack {
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
.metric {
|
||||
border-radius: 20px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
.metric-label {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.metric-value {
|
||||
margin-top: 8px;
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.panel {
|
||||
padding: 24px;
|
||||
}
|
||||
.panel h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.panel p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.cta {
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 10rem;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
font-weight: 800;
|
||||
border: 1px solid var(--line);
|
||||
transition: transform 120ms ease, background 120ms ease, color 120ms ease;
|
||||
}
|
||||
.cta:hover { transform: translateY(-1px); }
|
||||
.cta.primary { background: var(--ink); color: #fff; border-color: var(--ink); }
|
||||
.cta.secondary { background: var(--accent-soft); color: var(--accent); }
|
||||
.list {
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.list li {
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255,255,255,0.8);
|
||||
}
|
||||
.list strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.status-card {
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
.status-card strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.status-available {
|
||||
background: linear-gradient(180deg, #fff 0%, rgba(19, 111, 70, 0.08) 100%);
|
||||
}
|
||||
.status-caution {
|
||||
background: linear-gradient(180deg, #fff 0%, rgba(159, 100, 23, 0.08) 100%);
|
||||
}
|
||||
.status-note {
|
||||
background: linear-gradient(180deg, #fff 0%, rgba(12, 108, 201, 0.08) 100%);
|
||||
}
|
||||
code {
|
||||
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.hero, .grid, .status-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<nav class="topnav" aria-label="Admin Navigation">
|
||||
<a href="/portal/admin/" 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">Admin Portal</div>
|
||||
<h1>把新增模型与导入帐号收进同一套入口</h1>
|
||||
<p class="hero-copy">
|
||||
这个入口不再把“新增模型供应商”和“导入供应商帐号”拆散在不同地址。当前版本统一从
|
||||
<code>/portal/admin/</code> 进入:一边看 pack/provider 目录、做 preview/import,一边继续保留
|
||||
item 级 <code>reused / reactivated / replaced</code> 的 batch-import 结果面板。
|
||||
</p>
|
||||
<ul class="hero-points">
|
||||
<li>默认同域走 <code>/portal-admin-api/</code></li>
|
||||
<li>静态页与 CRM API 解耦</li>
|
||||
<li>保留旧地址兼容,不打断现有操作</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<aside class="card stack">
|
||||
<div class="metric">
|
||||
<div class="metric-label">统一入口</div>
|
||||
<div class="metric-value">/portal/admin/</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Provider 目录</div>
|
||||
<div class="metric-value">/providers</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">Batch Import</div>
|
||||
<div class="metric-value">/batch-import</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<article class="card panel">
|
||||
<h2>新增模型 / 供应商目录</h2>
|
||||
<p>
|
||||
这页负责浏览已安装 pack、选择 provider、调用 <code>preview-import</code> /
|
||||
<code>import</code>,同时提供 provider manifest 草稿生成器。当前版本不直接在浏览器里写入 pack 仓库,
|
||||
但已经把“新增模型”的准备动作收进同一条操作链。
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="cta primary" href="/portal/admin/providers.html">打开供应商页</a>
|
||||
<a class="cta secondary" href="/portal/admin/providers.html#manifest-draft">跳到 manifest 草稿</a>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li>
|
||||
<strong>适用动作</strong>
|
||||
查看 pack 与 provider、输入 keys 做 preview/import、生成 provider 草稿 JSON。
|
||||
</li>
|
||||
<li>
|
||||
<strong>默认 API Base</strong>
|
||||
<code>https://sub.tksea.top/portal-admin-api</code>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="card panel">
|
||||
<h2>导入供应商帐号</h2>
|
||||
<p>
|
||||
这页继续负责 live batch-import:创建 run、拉取 run summary、查看 item 级别的
|
||||
<code>matched_account_state</code> 与 <code>account_resolution</code>。
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="cta primary" href="/portal/admin/batch-import.html">打开导入页</a>
|
||||
<a class="cta secondary" href="/portal/admin-batch-import.html">旧地址兼容入口</a>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<li>
|
||||
<strong>适用动作</strong>
|
||||
批量导入第三方 key,验证 <code>reused / created / reactivated / replaced</code>。
|
||||
</li>
|
||||
<li>
|
||||
<strong>默认 API Base</strong>
|
||||
<code>https://sub.tksea.top/portal-admin-api</code>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="status-grid">
|
||||
<article class="status-card status-available">
|
||||
<div class="metric-label">可立即使用</div>
|
||||
<strong>Provider 浏览 + 导入</strong>
|
||||
<p>依赖现有 <code>/api/packs</code>、<code>/api/providers/*</code>、<code>/api/batch-import/*</code> 即可完成。</p>
|
||||
</article>
|
||||
<article class="status-card status-note">
|
||||
<div class="metric-label">当前边界</div>
|
||||
<strong>浏览器不直接写 pack 仓库</strong>
|
||||
<p>新增 provider 模板的最终落盘仍通过仓库提交完成,页面当前先覆盖目录、草稿与导入操作。</p>
|
||||
</article>
|
||||
<article class="status-card status-caution">
|
||||
<div class="metric-label">安全前提</div>
|
||||
<strong>仍需 Admin Token</strong>
|
||||
<p>CRM 的 API 权限仍由 Bearer token 控制,同域反代只解决浏览器可达性,不降低鉴权门槛。</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
1177
deploy/tksea-portal/admin/providers.html
Normal file
1177
deploy/tksea-portal/admin/providers.html
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,17 @@
|
||||
# - /portal/ 是新的通用多模型接入中心地址
|
||||
# - /kimi-portal 与 /kimi-portal/ 保留 302 跳转,避免旧链接失效
|
||||
# - /portal-proxy/ 是页面调用宿主用户态 API 的同域代理
|
||||
# - /portal-admin-api/ 是页面调用 CRM 管理 API 的同域代理
|
||||
# - /kimi/ 与 /kimi-v1/ 继续保留,兼容旧的 Kimi 专用客户端配置
|
||||
|
||||
location = /portal {
|
||||
return 302 /portal/;
|
||||
}
|
||||
|
||||
location = /portal/admin {
|
||||
return 302 /portal/admin/;
|
||||
}
|
||||
|
||||
location = /kimi-portal {
|
||||
return 302 /portal/;
|
||||
}
|
||||
@@ -30,6 +35,15 @@ location /portal-proxy/ {
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /portal-admin-api/ {
|
||||
proxy_pass http://127.0.0.1:18173/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /kimi-portal/ {
|
||||
return 302 /portal/;
|
||||
}
|
||||
|
||||
@@ -58,12 +58,27 @@ SUB2API_CRM_ADMIN_TOKEN=change-me-before-production SUB2API_CRM_LISTEN_ADDR=127.
|
||||
当前正式入口:
|
||||
|
||||
- `https://sub.tksea.top/portal/`
|
||||
- `https://sub.tksea.top/portal/admin/`
|
||||
- 管理首页
|
||||
- 统一提供“新增模型 / 供应商目录”和“导入供应商帐号”入口
|
||||
- `https://sub.tksea.top/portal/admin/providers.html`
|
||||
- provider 目录与 preview/import 管理页
|
||||
- 当前已支持通过 `provider_drafts` API 把 provider manifest 草稿持久化到 CRM SQLite,并直接更新 / 删除
|
||||
- `https://sub.tksea.top/portal/admin/batch-import.html`
|
||||
- 结构化 batch-import 入口,当前跳到 legacy 最小管理页
|
||||
- `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`
|
||||
|
||||
管理态同域代理:
|
||||
|
||||
- `https://sub.tksea.top/portal-admin-api/`
|
||||
- 反代到 CRM
|
||||
- 浏览器侧仍需 Bearer admin token
|
||||
- 作用是让静态 admin 页面不必直接访问 remote43 的内网 `18173`
|
||||
|
||||
兼容入口:
|
||||
|
||||
- `https://sub.tksea.top/kimi-portal/`
|
||||
|
||||
@@ -30,6 +30,20 @@
|
||||
- 直接消费 `GET /api/batch-import/runs/{run_id}`
|
||||
- 直接消费 `GET /api/batch-import/runs/{run_id}/items`
|
||||
- 用于验证 `matched_account_state / account_resolution / provision_reused`
|
||||
- 2026-05-27 已继续把管理入口收成统一 `/portal/admin/` 体系:
|
||||
- `https://sub.tksea.top/portal/admin/`:管理首页
|
||||
- `https://sub.tksea.top/portal/admin/providers.html`:provider 目录 / preview-import / import / manifest 草稿页
|
||||
- `https://sub.tksea.top/portal/admin/batch-import.html`:结构化 batch-import 入口,当前跳转到 legacy `admin-batch-import.html`
|
||||
- Nginx 示例与 deploy 脚本已补同域 CRM 反代 `https://sub.tksea.top/portal-admin-api/`
|
||||
- 目的不是绕过鉴权,而是让浏览器可直接操作 remote43 CRM,同时继续由 Bearer admin token 控制权限
|
||||
- 2026-05-27 已继续把 provider manifest 草稿从“只存在浏览器”补成真正的服务端能力:
|
||||
- 新增 `POST /api/provider-drafts`
|
||||
- 新增 `GET /api/provider-drafts`
|
||||
- 新增 `GET /api/provider-drafts/{draft_id}`
|
||||
- 新增 `PUT /api/provider-drafts/{draft_id}`
|
||||
- 新增 `DELETE /api/provider-drafts/{draft_id}`
|
||||
- 数据当前落到 CRM SQLite `provider_drafts` 表
|
||||
- `providers.html` 已可直接“保存到服务端”、回看历史草稿、以及更新 / 删除已保存草稿
|
||||
- 线上无副作用验收已确认:
|
||||
- `GET /portal/` 返回 `200`
|
||||
- `GET /kimi-portal/` 返回 `302 -> /portal/`
|
||||
|
||||
@@ -72,10 +72,17 @@
|
||||
|
||||
- `deploy/tksea-portal/index.html`
|
||||
- `sub.tksea.top/portal/` 静态页
|
||||
- `deploy/tksea-portal/admin/index.html`
|
||||
- `sub.tksea.top/portal/admin/` 管理首页
|
||||
- `deploy/tksea-portal/admin/providers.html`
|
||||
- `sub.tksea.top/portal/admin/providers.html` provider 目录、导入页与服务端草稿入口
|
||||
- `deploy/tksea-portal/admin/batch-import.html`
|
||||
- `sub.tksea.top/portal/admin/batch-import.html` 结构化 batch-import 入口
|
||||
- `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 路由示例
|
||||
- 同时包含 `/portal-proxy/` 和 `/portal-admin-api/` 两组反代
|
||||
|
||||
这层的规则是:
|
||||
|
||||
|
||||
@@ -161,6 +161,144 @@ func TestAPIPreviewProviderReturnsSummary(t *testing.T) {
|
||||
assertJSONContains(t, response.Body().Bytes(), "accepted_keys_count", float64(2))
|
||||
}
|
||||
|
||||
func TestAPICreateProviderDraftReturnsCreated(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
CreateProviderDraft: func(_ context.Context, req CreateProviderDraftRequest) (ProviderDraftInfo, error) {
|
||||
if req.ProviderID != "openai-zhongzhuan" {
|
||||
t.Fatalf("ProviderID = %q, want openai-zhongzhuan", req.ProviderID)
|
||||
}
|
||||
return ProviderDraftInfo{
|
||||
DraftID: "draft_001",
|
||||
PackID: req.PackID,
|
||||
ProviderID: req.ProviderID,
|
||||
DisplayName: req.DisplayName,
|
||||
Platform: req.Platform,
|
||||
SmokeTestModel: req.SmokeTestModel,
|
||||
SupportedModels: []string{
|
||||
"gpt-5.4",
|
||||
},
|
||||
Manifest: map[string]any{"provider_id": req.ProviderID},
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
request := httptestRequest(t, http.MethodPost, "/api/provider-drafts", map[string]any{
|
||||
"pack_id": "openai-cn-pack",
|
||||
"provider_id": "openai-zhongzhuan",
|
||||
"display_name": "OpenAI 中转",
|
||||
"platform": "openai",
|
||||
"smoke_test_model": "gpt-5.4",
|
||||
"supported_models": []string{"gpt-5.4"},
|
||||
}, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusCreated)
|
||||
assertJSONContains(t, response.Body().Bytes(), "draft.draft_id", "draft_001")
|
||||
assertJSONContains(t, response.Body().Bytes(), "draft.provider_id", "openai-zhongzhuan")
|
||||
}
|
||||
|
||||
func TestAPIListProviderDraftsReturnsCollection(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ListProviderDrafts: func(_ context.Context, req ListProviderDraftsRequest) ([]ProviderDraftInfo, error) {
|
||||
if req.PackID != "openai-cn-pack" {
|
||||
t.Fatalf("PackID = %q, want openai-cn-pack", req.PackID)
|
||||
}
|
||||
return []ProviderDraftInfo{{
|
||||
DraftID: "draft_001",
|
||||
PackID: req.PackID,
|
||||
ProviderID: "minimax-53hk",
|
||||
DisplayName: "MiniMax 53HK",
|
||||
Platform: "openai",
|
||||
Manifest: map[string]any{"provider_id": "minimax-53hk"},
|
||||
SourceHostID: "remote43-current-host",
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
request := httptestRequest(t, http.MethodGet, "/api/provider-drafts?pack_id=openai-cn-pack", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
drafts, ok := payload["provider_drafts"].([]any)
|
||||
if !ok || len(drafts) != 1 {
|
||||
t.Fatalf("provider_drafts = %#v, want one item", payload["provider_drafts"])
|
||||
}
|
||||
item, ok := drafts[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("draft[0] = %#v, want object", drafts[0])
|
||||
}
|
||||
if got := item["provider_id"]; got != "minimax-53hk" {
|
||||
t.Fatalf("provider_id = %#v, want minimax-53hk", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGetProviderDraftReturnsItem(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
GetProviderDraft: func(_ context.Context, draftID string) (ProviderDraftInfo, error) {
|
||||
if draftID != "draft_001" {
|
||||
t.Fatalf("draftID = %q, want draft_001", draftID)
|
||||
}
|
||||
return ProviderDraftInfo{
|
||||
DraftID: draftID,
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "deepseek-chat-official",
|
||||
DisplayName: "DeepSeek Official",
|
||||
Platform: "openai",
|
||||
Manifest: map[string]any{"provider_id": "deepseek-chat-official"},
|
||||
SourceHostID: "remote43-current-host",
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
request := httptestRequest(t, http.MethodGet, "/api/provider-drafts/draft_001", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
assertJSONContains(t, response.Body().Bytes(), "draft.provider_id", "deepseek-chat-official")
|
||||
}
|
||||
|
||||
func TestAPIUpdateProviderDraftReturnsUpdatedItem(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
UpdateProviderDraft: func(_ context.Context, req UpdateProviderDraftRequest) (ProviderDraftInfo, error) {
|
||||
if req.DraftID != "draft_001" {
|
||||
t.Fatalf("DraftID = %q, want draft_001", req.DraftID)
|
||||
}
|
||||
return ProviderDraftInfo{
|
||||
DraftID: req.DraftID,
|
||||
PackID: req.PackID,
|
||||
ProviderID: req.ProviderID,
|
||||
DisplayName: req.DisplayName,
|
||||
Platform: req.Platform,
|
||||
BaseURL: req.BaseURL,
|
||||
Manifest: map[string]any{"provider_id": req.ProviderID},
|
||||
SourceHostID: req.SourceHostID,
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
request := httptestRequest(t, http.MethodPut, "/api/provider-drafts/draft_001", map[string]any{
|
||||
"pack_id": "openai-cn-pack",
|
||||
"provider_id": "openai-zhongzhuan",
|
||||
"display_name": "OpenAI 中转 Updated",
|
||||
"platform": "openai",
|
||||
"base_url": "https://api.example.com/v1",
|
||||
}, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
assertJSONContains(t, response.Body().Bytes(), "draft.display_name", "OpenAI 中转 Updated")
|
||||
}
|
||||
|
||||
func TestAPIDeleteProviderDraftReturnsNoContent(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
DeleteProviderDraft: func(_ context.Context, draftID string) error {
|
||||
if draftID != "draft_001" {
|
||||
t.Fatalf("draftID = %q, want draft_001", draftID)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
request := httptestRequest(t, http.MethodDelete, "/api/provider-drafts/draft_001", nil, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func TestAPIImportProviderReturnsConflictWithBatchStatus(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ImportProvider: func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/batch"
|
||||
"sub2api-cn-relay-manager/internal/host/sub2api"
|
||||
@@ -26,6 +27,11 @@ type ActionSet struct {
|
||||
GetBatchImportRun func(context.Context, string) (batch.RunSummaryProjection, error)
|
||||
ListBatchImportRunItems func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error)
|
||||
GetBatchImportRunItem func(context.Context, GetBatchImportRunItemRequest) (batch.ItemDetailProjection, error)
|
||||
CreateProviderDraft func(context.Context, CreateProviderDraftRequest) (ProviderDraftInfo, error)
|
||||
ListProviderDrafts func(context.Context, ListProviderDraftsRequest) ([]ProviderDraftInfo, error)
|
||||
GetProviderDraft func(context.Context, string) (ProviderDraftInfo, error)
|
||||
UpdateProviderDraft func(context.Context, UpdateProviderDraftRequest) (ProviderDraftInfo, error)
|
||||
DeleteProviderDraft func(context.Context, string) error
|
||||
InstallPack func(context.Context, InstallPackRequest) (provision.PackInstallResult, error)
|
||||
BatchDetail func(context.Context, BatchDetailRequest) (provision.BatchDetailResult, error)
|
||||
GetProviderStatus func(context.Context, ProviderQueryRequest) (provision.ProviderSnapshot, error)
|
||||
@@ -98,6 +104,47 @@ type PackProviderInfo struct {
|
||||
HostOverlays int `json:"host_overlays,omitempty"`
|
||||
}
|
||||
|
||||
type CreateProviderDraftRequest struct {
|
||||
DraftID string `json:"draft_id,omitempty"`
|
||||
PackID string `json:"pack_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Platform string `json:"platform"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
SmokeTestModel string `json:"smoke_test_model,omitempty"`
|
||||
SupportedModels []string `json:"supported_models,omitempty"`
|
||||
Manifest json.RawMessage `json:"manifest,omitempty"`
|
||||
SourceHostID string `json:"source_host_id,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type ListProviderDraftsRequest struct {
|
||||
PackID string
|
||||
ProviderID string
|
||||
Query string
|
||||
}
|
||||
|
||||
type UpdateProviderDraftRequest struct {
|
||||
DraftID string `json:"-"`
|
||||
CreateProviderDraftRequest
|
||||
}
|
||||
|
||||
type ProviderDraftInfo struct {
|
||||
DraftID string `json:"draft_id"`
|
||||
PackID string `json:"pack_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Platform string `json:"platform"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
SmokeTestModel string `json:"smoke_test_model,omitempty"`
|
||||
SupportedModels []string `json:"supported_models,omitempty"`
|
||||
Manifest any `json:"manifest,omitempty"`
|
||||
SourceHostID string `json:"source_host_id,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type AssignAccessSubscriptionsRequest struct {
|
||||
HostID string `json:"host_id,omitempty"`
|
||||
PackPath string `json:"pack_path"`
|
||||
@@ -226,6 +273,21 @@ func NewAPIHandler(adminToken string, actions ActionSet) http.Handler {
|
||||
mux.Handle("GET /api/batch-import/runs/{run_id}/items/{item_id}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetBatchImportRunItem(w, r, actions.GetBatchImportRunItem)
|
||||
})))
|
||||
mux.Handle("POST /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleCreateProviderDraft(w, r, actions.CreateProviderDraft)
|
||||
})))
|
||||
mux.Handle("GET /api/provider-drafts", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListProviderDrafts(w, r, actions.ListProviderDrafts)
|
||||
})))
|
||||
mux.Handle("GET /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetProviderDraft(w, r, actions.GetProviderDraft)
|
||||
})))
|
||||
mux.Handle("PUT /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleUpdateProviderDraft(w, r, actions.UpdateProviderDraft)
|
||||
})))
|
||||
mux.Handle("DELETE /api/provider-drafts/{draftID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleDeleteProviderDraft(w, r, actions.DeleteProviderDraft)
|
||||
})))
|
||||
mux.Handle("GET /api/import-batches/{batchID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleBatchDetail(w, r, actions.BatchDetail)
|
||||
})))
|
||||
@@ -297,6 +359,102 @@ func healthz(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}
|
||||
|
||||
func handleCreateProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, CreateProviderDraftRequest) (ProviderDraftInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "create-provider-draft action is not configured"})
|
||||
return
|
||||
}
|
||||
var req CreateProviderDraftRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
draft, err := fn(r.Context(), req)
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"draft": draft})
|
||||
}
|
||||
|
||||
func handleListProviderDrafts(w http.ResponseWriter, r *http.Request, fn func(context.Context, ListProviderDraftsRequest) ([]ProviderDraftInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-provider-drafts action is not configured"})
|
||||
return
|
||||
}
|
||||
drafts, err := fn(r.Context(), ListProviderDraftsRequest{
|
||||
PackID: strings.TrimSpace(r.URL.Query().Get("pack_id")),
|
||||
ProviderID: strings.TrimSpace(r.URL.Query().Get("provider_id")),
|
||||
Query: strings.TrimSpace(r.URL.Query().Get("q")),
|
||||
})
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
if drafts == nil {
|
||||
drafts = []ProviderDraftInfo{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"provider_drafts": drafts})
|
||||
}
|
||||
|
||||
func handleGetProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) (ProviderDraftInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "get-provider-draft action is not configured"})
|
||||
return
|
||||
}
|
||||
draftID := strings.TrimSpace(r.PathValue("draftID"))
|
||||
if draftID == "" {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "draft_id is required"})
|
||||
return
|
||||
}
|
||||
draft, err := fn(r.Context(), draftID)
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"draft": draft})
|
||||
}
|
||||
|
||||
func handleUpdateProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, UpdateProviderDraftRequest) (ProviderDraftInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "update-provider-draft action is not configured"})
|
||||
return
|
||||
}
|
||||
var req UpdateProviderDraftRequest
|
||||
if err := decodeJSON(r, &req.CreateProviderDraftRequest); err != nil {
|
||||
writeHTTPError(w, err)
|
||||
return
|
||||
}
|
||||
req.DraftID = strings.TrimSpace(r.PathValue("draftID"))
|
||||
if req.DraftID == "" {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "draft_id is required"})
|
||||
return
|
||||
}
|
||||
draft, err := fn(r.Context(), req)
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"draft": draft})
|
||||
}
|
||||
|
||||
func handleDeleteProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) error) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "delete-provider-draft action is not configured"})
|
||||
return
|
||||
}
|
||||
draftID := strings.TrimSpace(r.PathValue("draftID"))
|
||||
if draftID == "" {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusBadRequest, Code: "bad_request", Message: "draft_id is required"})
|
||||
return
|
||||
}
|
||||
if err := fn(r.Context(), draftID); err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func requireAdminToken(token string, next http.Handler) http.Handler {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -912,6 +1070,123 @@ func NewActionSet(sqliteDSN string) ActionSet {
|
||||
GetBatchImportRun: buildGetBatchImportRunAction(sqliteDSN),
|
||||
ListBatchImportRunItems: buildListBatchImportRunItemsAction(sqliteDSN),
|
||||
GetBatchImportRunItem: buildGetBatchImportRunItemAction(sqliteDSN),
|
||||
CreateProviderDraft: func(ctx context.Context, req CreateProviderDraftRequest) (ProviderDraftInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
draftID := strings.TrimSpace(req.DraftID)
|
||||
if draftID == "" {
|
||||
draftID = fmt.Sprintf("draft_%d", time.Now().UnixNano())
|
||||
}
|
||||
manifestJSON, manifestValue, supportedModels, err := normalizeProviderDraftPayload(req)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
|
||||
draftRow := sqlite.ProviderDraft{
|
||||
DraftID: draftID,
|
||||
PackID: strings.TrimSpace(req.PackID),
|
||||
ProviderID: strings.TrimSpace(req.ProviderID),
|
||||
DisplayName: strings.TrimSpace(req.DisplayName),
|
||||
Platform: strings.TrimSpace(req.Platform),
|
||||
BaseURL: strings.TrimSpace(req.BaseURL),
|
||||
SmokeTestModel: strings.TrimSpace(req.SmokeTestModel),
|
||||
SupportedModelsJSON: encodeStringList(supportedModels),
|
||||
ManifestJSON: manifestJSON,
|
||||
SourceHostID: strings.TrimSpace(req.SourceHostID),
|
||||
Notes: strings.TrimSpace(req.Notes),
|
||||
}
|
||||
if _, err := store.ProviderDrafts().Create(ctx, draftRow); err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
persisted, err := store.ProviderDrafts().GetByDraftID(ctx, draftID)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
return providerDraftRecordToInfo(persisted, manifestValue, supportedModels)
|
||||
},
|
||||
ListProviderDrafts: func(ctx context.Context, req ListProviderDraftsRequest) ([]ProviderDraftInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
rows, err := store.ProviderDrafts().List(ctx, sqlite.ListProviderDraftsFilter{
|
||||
PackID: req.PackID,
|
||||
ProviderID: req.ProviderID,
|
||||
Query: req.Query,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]ProviderDraftInfo, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
info, err := providerDraftRecordToInfoFromStored(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, info)
|
||||
}
|
||||
return result, nil
|
||||
},
|
||||
GetProviderDraft: func(ctx context.Context, draftID string) (ProviderDraftInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
row, err := store.ProviderDrafts().GetByDraftID(ctx, draftID)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
return providerDraftRecordToInfoFromStored(row)
|
||||
},
|
||||
UpdateProviderDraft: func(ctx context.Context, req UpdateProviderDraftRequest) (ProviderDraftInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
manifestJSON, _, supportedModels, err := normalizeProviderDraftPayload(req.CreateProviderDraftRequest)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
|
||||
if err := store.ProviderDrafts().UpdateByDraftID(ctx, sqlite.ProviderDraft{
|
||||
DraftID: strings.TrimSpace(req.DraftID),
|
||||
PackID: strings.TrimSpace(req.PackID),
|
||||
ProviderID: strings.TrimSpace(req.ProviderID),
|
||||
DisplayName: strings.TrimSpace(req.DisplayName),
|
||||
Platform: strings.TrimSpace(req.Platform),
|
||||
BaseURL: strings.TrimSpace(req.BaseURL),
|
||||
SmokeTestModel: strings.TrimSpace(req.SmokeTestModel),
|
||||
SupportedModelsJSON: encodeStringList(supportedModels),
|
||||
ManifestJSON: manifestJSON,
|
||||
SourceHostID: strings.TrimSpace(req.SourceHostID),
|
||||
Notes: strings.TrimSpace(req.Notes),
|
||||
}); err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
row, err := store.ProviderDrafts().GetByDraftID(ctx, req.DraftID)
|
||||
if err != nil {
|
||||
return ProviderDraftInfo{}, err
|
||||
}
|
||||
return providerDraftRecordToInfoFromStored(row)
|
||||
},
|
||||
DeleteProviderDraft: func(ctx context.Context, draftID string) error {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer store.Close()
|
||||
return store.ProviderDrafts().DeleteByDraftID(ctx, draftID)
|
||||
},
|
||||
InstallPack: func(ctx context.Context, req InstallPackRequest) (provision.PackInstallResult, error) {
|
||||
loadedPack, err := pack.LoadPath(req.PackPath)
|
||||
if err != nil {
|
||||
@@ -1650,6 +1925,92 @@ func packRecordToInfo(pack sqlite.Pack) PackInfo {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProviderDraftPayload(req CreateProviderDraftRequest) (string, any, []string, error) {
|
||||
supportedModels := normalizeStringList(req.SupportedModels)
|
||||
if len(req.Manifest) > 0 {
|
||||
var manifestValue any
|
||||
if err := json.Unmarshal(req.Manifest, &manifestValue); err != nil {
|
||||
return "", nil, nil, fmt.Errorf("decode manifest: %w", err)
|
||||
}
|
||||
manifestJSON := strings.TrimSpace(string(req.Manifest))
|
||||
if manifestJSON == "" {
|
||||
manifestJSON = "{}"
|
||||
}
|
||||
return manifestJSON, manifestValue, supportedModels, nil
|
||||
}
|
||||
|
||||
manifestValue := map[string]any{
|
||||
"provider_id": strings.TrimSpace(req.ProviderID),
|
||||
"display_name": strings.TrimSpace(req.DisplayName),
|
||||
"platform": strings.TrimSpace(req.Platform),
|
||||
"base_url": strings.TrimSpace(req.BaseURL),
|
||||
"smoke_test_model": strings.TrimSpace(req.SmokeTestModel),
|
||||
"supported_models": supportedModels,
|
||||
}
|
||||
manifestJSONBytes, err := json.Marshal(manifestValue)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("marshal manifest: %w", err)
|
||||
}
|
||||
return string(manifestJSONBytes), manifestValue, supportedModels, nil
|
||||
}
|
||||
|
||||
func providerDraftRecordToInfo(row sqlite.ProviderDraft, manifestValue any, supportedModels []string) (ProviderDraftInfo, error) {
|
||||
if manifestValue == nil {
|
||||
manifestValue = map[string]any{}
|
||||
}
|
||||
return ProviderDraftInfo{
|
||||
DraftID: row.DraftID,
|
||||
PackID: row.PackID,
|
||||
ProviderID: row.ProviderID,
|
||||
DisplayName: row.DisplayName,
|
||||
Platform: row.Platform,
|
||||
BaseURL: row.BaseURL,
|
||||
SmokeTestModel: row.SmokeTestModel,
|
||||
SupportedModels: append([]string(nil), supportedModels...),
|
||||
Manifest: manifestValue,
|
||||
SourceHostID: row.SourceHostID,
|
||||
Notes: row.Notes,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func providerDraftRecordToInfoFromStored(row sqlite.ProviderDraft) (ProviderDraftInfo, error) {
|
||||
var manifestValue any
|
||||
if strings.TrimSpace(row.ManifestJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(row.ManifestJSON), &manifestValue); err != nil {
|
||||
return ProviderDraftInfo{}, fmt.Errorf("decode stored provider draft manifest: %w", err)
|
||||
}
|
||||
}
|
||||
supportedModels := []string{}
|
||||
if strings.TrimSpace(row.SupportedModelsJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(row.SupportedModelsJSON), &supportedModels); err != nil {
|
||||
return ProviderDraftInfo{}, fmt.Errorf("decode stored provider draft supported_models: %w", err)
|
||||
}
|
||||
}
|
||||
return providerDraftRecordToInfo(row, manifestValue, supportedModels)
|
||||
}
|
||||
|
||||
func encodeStringList(values []string) string {
|
||||
encoded, err := json.Marshal(normalizeStringList(values))
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func normalizeStringList(values []string) []string {
|
||||
normalized := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func deriveAccessStatus(gw sub2api.GatewayAccessResult) string {
|
||||
if provision.GatewayAccessReady(gw) {
|
||||
return provision.AccessStatusSubscriptionReady
|
||||
|
||||
19
internal/store/migrations/0009_provider_drafts.sql
Normal file
19
internal/store/migrations/0009_provider_drafts.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE provider_drafts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
draft_id TEXT NOT NULL UNIQUE,
|
||||
pack_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
platform TEXT NOT NULL DEFAULT '',
|
||||
base_url TEXT NOT NULL DEFAULT '',
|
||||
smoke_test_model TEXT NOT NULL DEFAULT '',
|
||||
supported_models_json TEXT NOT NULL DEFAULT '[]',
|
||||
manifest_json TEXT NOT NULL DEFAULT '{}',
|
||||
source_host_id TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX idx_provider_drafts_pack_id ON provider_drafts(pack_id);
|
||||
CREATE INDEX idx_provider_drafts_provider_id ON provider_drafts(provider_id);
|
||||
@@ -23,6 +23,7 @@ type Queries struct {
|
||||
Hosts *HostsRepo
|
||||
Packs *PacksRepo
|
||||
Providers *ProvidersRepo
|
||||
ProviderDrafts *ProviderDraftsRepo
|
||||
ImportBatches *ImportBatchesRepo
|
||||
ImportBatchItems *ImportBatchItemsRepo
|
||||
ImportRuns *ImportRunsRepo
|
||||
@@ -91,6 +92,10 @@ func (db *DB) Providers() *ProvidersRepo {
|
||||
return db.queries.Providers
|
||||
}
|
||||
|
||||
func (db *DB) ProviderDrafts() *ProviderDraftsRepo {
|
||||
return db.queries.ProviderDrafts
|
||||
}
|
||||
|
||||
func (db *DB) ImportBatches() *ImportBatchesRepo {
|
||||
return db.queries.ImportBatches
|
||||
}
|
||||
@@ -159,6 +164,7 @@ func newQueries(db execQuerier) *Queries {
|
||||
Hosts: newHostsRepo(db),
|
||||
Packs: newPacksRepo(db),
|
||||
Providers: newProvidersRepo(db),
|
||||
ProviderDrafts: newProviderDraftsRepo(db),
|
||||
ImportBatches: newImportBatchesRepo(db),
|
||||
ImportBatchItems: newImportBatchItemsRepo(db),
|
||||
ImportRuns: newImportRunsRepo(db),
|
||||
|
||||
259
internal/store/sqlite/provider_drafts_repo.go
Normal file
259
internal/store/sqlite/provider_drafts_repo.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ProviderDraft struct {
|
||||
ID int64
|
||||
DraftID string
|
||||
PackID string
|
||||
ProviderID string
|
||||
DisplayName string
|
||||
Platform string
|
||||
BaseURL string
|
||||
SmokeTestModel string
|
||||
SupportedModelsJSON string
|
||||
ManifestJSON string
|
||||
SourceHostID string
|
||||
Notes string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type ListProviderDraftsFilter struct {
|
||||
PackID string
|
||||
ProviderID string
|
||||
Query string
|
||||
}
|
||||
|
||||
type ProviderDraftsRepo struct {
|
||||
db execQuerier
|
||||
}
|
||||
|
||||
func newProviderDraftsRepo(db execQuerier) *ProviderDraftsRepo {
|
||||
return &ProviderDraftsRepo{db: db}
|
||||
}
|
||||
|
||||
func (r *ProviderDraftsRepo) Create(ctx context.Context, draft ProviderDraft) (int64, error) {
|
||||
draftID := strings.TrimSpace(draft.DraftID)
|
||||
packID := strings.TrimSpace(draft.PackID)
|
||||
providerID := strings.TrimSpace(draft.ProviderID)
|
||||
displayName := strings.TrimSpace(draft.DisplayName)
|
||||
platform := strings.TrimSpace(draft.Platform)
|
||||
if draft.ManifestJSON = strings.TrimSpace(draft.ManifestJSON); draft.ManifestJSON == "" {
|
||||
draft.ManifestJSON = "{}"
|
||||
}
|
||||
if draft.SupportedModelsJSON = strings.TrimSpace(draft.SupportedModelsJSON); draft.SupportedModelsJSON == "" {
|
||||
draft.SupportedModelsJSON = "[]"
|
||||
}
|
||||
|
||||
switch {
|
||||
case draftID == "":
|
||||
return 0, fmt.Errorf("draft_id is required")
|
||||
case packID == "":
|
||||
return 0, fmt.Errorf("pack_id is required")
|
||||
case providerID == "":
|
||||
return 0, fmt.Errorf("provider_id is required")
|
||||
case displayName == "":
|
||||
return 0, fmt.Errorf("display_name is required")
|
||||
case platform == "":
|
||||
return 0, fmt.Errorf("platform is required")
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`INSERT INTO provider_drafts (draft_id, pack_id, provider_id, display_name, platform, base_url, smoke_test_model, supported_models_json, manifest_json, source_host_id, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
draftID,
|
||||
packID,
|
||||
providerID,
|
||||
displayName,
|
||||
platform,
|
||||
strings.TrimSpace(draft.BaseURL),
|
||||
strings.TrimSpace(draft.SmokeTestModel),
|
||||
draft.SupportedModelsJSON,
|
||||
draft.ManifestJSON,
|
||||
strings.TrimSpace(draft.SourceHostID),
|
||||
strings.TrimSpace(draft.Notes),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert provider draft %q: %w", draftID, err)
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read inserted provider draft id for %q: %w", draftID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (r *ProviderDraftsRepo) UpdateByDraftID(ctx context.Context, draft ProviderDraft) error {
|
||||
draftID := strings.TrimSpace(draft.DraftID)
|
||||
packID := strings.TrimSpace(draft.PackID)
|
||||
providerID := strings.TrimSpace(draft.ProviderID)
|
||||
displayName := strings.TrimSpace(draft.DisplayName)
|
||||
platform := strings.TrimSpace(draft.Platform)
|
||||
if draft.ManifestJSON = strings.TrimSpace(draft.ManifestJSON); draft.ManifestJSON == "" {
|
||||
draft.ManifestJSON = "{}"
|
||||
}
|
||||
if draft.SupportedModelsJSON = strings.TrimSpace(draft.SupportedModelsJSON); draft.SupportedModelsJSON == "" {
|
||||
draft.SupportedModelsJSON = "[]"
|
||||
}
|
||||
|
||||
switch {
|
||||
case draftID == "":
|
||||
return fmt.Errorf("draft_id is required")
|
||||
case packID == "":
|
||||
return fmt.Errorf("pack_id is required")
|
||||
case providerID == "":
|
||||
return fmt.Errorf("provider_id is required")
|
||||
case displayName == "":
|
||||
return fmt.Errorf("display_name is required")
|
||||
case platform == "":
|
||||
return fmt.Errorf("platform is required")
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(
|
||||
ctx,
|
||||
`UPDATE provider_drafts
|
||||
SET pack_id = ?, provider_id = ?, display_name = ?, platform = ?, base_url = ?, smoke_test_model = ?, supported_models_json = ?, manifest_json = ?, source_host_id = ?, notes = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE draft_id = ?`,
|
||||
packID,
|
||||
providerID,
|
||||
displayName,
|
||||
platform,
|
||||
strings.TrimSpace(draft.BaseURL),
|
||||
strings.TrimSpace(draft.SmokeTestModel),
|
||||
draft.SupportedModelsJSON,
|
||||
draft.ManifestJSON,
|
||||
strings.TrimSpace(draft.SourceHostID),
|
||||
strings.TrimSpace(draft.Notes),
|
||||
draftID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update provider draft %q: %w", draftID, err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read updated provider draft rows for %q: %w", draftID, err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("provider draft %q not found", draftID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProviderDraftsRepo) GetByDraftID(ctx context.Context, draftID string) (ProviderDraft, error) {
|
||||
draftID = strings.TrimSpace(draftID)
|
||||
if draftID == "" {
|
||||
return ProviderDraft{}, fmt.Errorf("draft_id is required")
|
||||
}
|
||||
|
||||
var draft ProviderDraft
|
||||
if err := r.db.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT id, draft_id, pack_id, provider_id, display_name, platform, base_url, smoke_test_model, supported_models_json, manifest_json, source_host_id, notes, created_at, updated_at
|
||||
FROM provider_drafts WHERE draft_id = ?`,
|
||||
draftID,
|
||||
).Scan(
|
||||
&draft.ID,
|
||||
&draft.DraftID,
|
||||
&draft.PackID,
|
||||
&draft.ProviderID,
|
||||
&draft.DisplayName,
|
||||
&draft.Platform,
|
||||
&draft.BaseURL,
|
||||
&draft.SmokeTestModel,
|
||||
&draft.SupportedModelsJSON,
|
||||
&draft.ManifestJSON,
|
||||
&draft.SourceHostID,
|
||||
&draft.Notes,
|
||||
&draft.CreatedAt,
|
||||
&draft.UpdatedAt,
|
||||
); err != nil {
|
||||
return ProviderDraft{}, err
|
||||
}
|
||||
return draft, nil
|
||||
}
|
||||
|
||||
func (r *ProviderDraftsRepo) List(ctx context.Context, filter ListProviderDraftsFilter) ([]ProviderDraft, error) {
|
||||
query := `SELECT id, draft_id, pack_id, provider_id, display_name, platform, base_url, smoke_test_model, supported_models_json, manifest_json, source_host_id, notes, created_at, updated_at
|
||||
FROM provider_drafts`
|
||||
where := make([]string, 0, 3)
|
||||
args := make([]any, 0, 3)
|
||||
|
||||
if packID := strings.TrimSpace(filter.PackID); packID != "" {
|
||||
where = append(where, "pack_id = ?")
|
||||
args = append(args, packID)
|
||||
}
|
||||
if providerID := strings.TrimSpace(filter.ProviderID); providerID != "" {
|
||||
where = append(where, "provider_id = ?")
|
||||
args = append(args, providerID)
|
||||
}
|
||||
if rawQuery := strings.TrimSpace(filter.Query); rawQuery != "" {
|
||||
like := "%" + rawQuery + "%"
|
||||
where = append(where, "(draft_id LIKE ? OR provider_id LIKE ? OR display_name LIKE ?)")
|
||||
args = append(args, like, like, like)
|
||||
}
|
||||
if len(where) > 0 {
|
||||
query += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
query += " ORDER BY id DESC"
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list provider drafts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
drafts := make([]ProviderDraft, 0)
|
||||
for rows.Next() {
|
||||
var draft ProviderDraft
|
||||
if err := rows.Scan(
|
||||
&draft.ID,
|
||||
&draft.DraftID,
|
||||
&draft.PackID,
|
||||
&draft.ProviderID,
|
||||
&draft.DisplayName,
|
||||
&draft.Platform,
|
||||
&draft.BaseURL,
|
||||
&draft.SmokeTestModel,
|
||||
&draft.SupportedModelsJSON,
|
||||
&draft.ManifestJSON,
|
||||
&draft.SourceHostID,
|
||||
&draft.Notes,
|
||||
&draft.CreatedAt,
|
||||
&draft.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan provider draft: %w", err)
|
||||
}
|
||||
drafts = append(drafts, draft)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate provider drafts: %w", err)
|
||||
}
|
||||
return drafts, nil
|
||||
}
|
||||
|
||||
func (r *ProviderDraftsRepo) DeleteByDraftID(ctx context.Context, draftID string) error {
|
||||
draftID = strings.TrimSpace(draftID)
|
||||
if draftID == "" {
|
||||
return fmt.Errorf("draft_id is required")
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM provider_drafts WHERE draft_id = ?`, draftID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete provider draft %q: %w", draftID, err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read deleted provider draft rows for %q: %w", draftID, err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return fmt.Errorf("provider draft %q not found", draftID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
177
internal/store/sqlite/provider_drafts_repo_test.go
Normal file
177
internal/store/sqlite/provider_drafts_repo_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProviderDraftsRepoCreateGetAndList(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
|
||||
id, err := store.ProviderDrafts().Create(context.Background(), ProviderDraft{
|
||||
DraftID: "draft_001",
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "openai-zhongzhuan",
|
||||
DisplayName: "OpenAI 中转",
|
||||
Platform: "openai",
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
SmokeTestModel: "gpt-5.4",
|
||||
SupportedModelsJSON: `["gpt-5.4","gpt-5.4-mini"]`,
|
||||
ManifestJSON: `{"provider_id":"openai-zhongzhuan"}`,
|
||||
SourceHostID: "remote43-current-host",
|
||||
Notes: "first draft",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if id <= 0 {
|
||||
t.Fatalf("Create() id = %d, want positive", id)
|
||||
}
|
||||
|
||||
got, err := store.ProviderDrafts().GetByDraftID(context.Background(), "draft_001")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByDraftID() error = %v", err)
|
||||
}
|
||||
if got.ProviderID != "openai-zhongzhuan" || got.DisplayName != "OpenAI 中转" {
|
||||
t.Fatalf("GetByDraftID() = %+v, want provider draft payload", got)
|
||||
}
|
||||
|
||||
drafts, err := store.ProviderDrafts().List(context.Background(), ListProviderDraftsFilter{PackID: "openai-cn-pack"})
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if len(drafts) != 1 {
|
||||
t.Fatalf("List() len = %d, want 1", len(drafts))
|
||||
}
|
||||
if drafts[0].DraftID != "draft_001" {
|
||||
t.Fatalf("List() draft_id = %q, want draft_001", drafts[0].DraftID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDraftsRepoListSupportsQuery(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
|
||||
_, err := store.ProviderDrafts().Create(context.Background(), ProviderDraft{
|
||||
DraftID: "draft_alpha",
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "minimax-53hk",
|
||||
DisplayName: "MiniMax 53HK",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
_, err = store.ProviderDrafts().Create(context.Background(), ProviderDraft{
|
||||
DraftID: "draft_beta",
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "deepseek-chat-official",
|
||||
DisplayName: "DeepSeek Official",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() second error = %v", err)
|
||||
}
|
||||
|
||||
drafts, err := store.ProviderDrafts().List(context.Background(), ListProviderDraftsFilter{Query: "MiniMax"})
|
||||
if err != nil {
|
||||
t.Fatalf("List() query error = %v", err)
|
||||
}
|
||||
if len(drafts) != 1 || drafts[0].ProviderID != "minimax-53hk" {
|
||||
t.Fatalf("List() query = %+v, want only minimax-53hk", drafts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDraftsRepoUpdateByDraftID(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
|
||||
_, err := store.ProviderDrafts().Create(context.Background(), ProviderDraft{
|
||||
DraftID: "draft_update",
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "minimax-53hk",
|
||||
DisplayName: "MiniMax 53HK",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
if err := store.ProviderDrafts().UpdateByDraftID(context.Background(), ProviderDraft{
|
||||
DraftID: "draft_update",
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "minimax-53hk",
|
||||
DisplayName: "MiniMax 53HK Updated",
|
||||
Platform: "openai",
|
||||
BaseURL: "https://api.53hk.cn/v1",
|
||||
SmokeTestModel: "MiniMax-M2.7-highspeed",
|
||||
SupportedModelsJSON: `["MiniMax-M2.7-highspeed"]`,
|
||||
ManifestJSON: `{"provider_id":"minimax-53hk","display_name":"MiniMax 53HK Updated"}`,
|
||||
SourceHostID: "remote43-current-host",
|
||||
Notes: "updated",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateByDraftID() error = %v", err)
|
||||
}
|
||||
|
||||
got, err := store.ProviderDrafts().GetByDraftID(context.Background(), "draft_update")
|
||||
if err != nil {
|
||||
t.Fatalf("GetByDraftID() error = %v", err)
|
||||
}
|
||||
if got.DisplayName != "MiniMax 53HK Updated" || got.BaseURL != "https://api.53hk.cn/v1" {
|
||||
t.Fatalf("updated draft = %+v, want updated fields", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDraftsRepoDeleteByDraftID(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
|
||||
_, err := store.ProviderDrafts().Create(context.Background(), ProviderDraft{
|
||||
DraftID: "draft_delete",
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "deepseek-chat-official",
|
||||
DisplayName: "DeepSeek Official",
|
||||
Platform: "openai",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
|
||||
if err := store.ProviderDrafts().DeleteByDraftID(context.Background(), "draft_delete"); err != nil {
|
||||
t.Fatalf("DeleteByDraftID() error = %v", err)
|
||||
}
|
||||
_, err = store.ProviderDrafts().GetByDraftID(context.Background(), "draft_delete")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("GetByDraftID() after delete error = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDraftsRepoValidationErrors(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
draft ProviderDraft
|
||||
}{
|
||||
{"empty draft_id", ProviderDraft{PackID: "pack", ProviderID: "p", DisplayName: "n", Platform: "openai"}},
|
||||
{"empty pack_id", ProviderDraft{DraftID: "draft", ProviderID: "p", DisplayName: "n", Platform: "openai"}},
|
||||
{"empty provider_id", ProviderDraft{DraftID: "draft", PackID: "pack", DisplayName: "n", Platform: "openai"}},
|
||||
{"empty display_name", ProviderDraft{DraftID: "draft", PackID: "pack", ProviderID: "p", Platform: "openai"}},
|
||||
{"empty platform", ProviderDraft{DraftID: "draft", PackID: "pack", ProviderID: "p", DisplayName: "n"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := store.ProviderDrafts().Create(context.Background(), tt.draft)
|
||||
if err == nil {
|
||||
t.Fatal("Create() error = nil, want validation error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderDraftsRepoGetByDraftIDNotFound(t *testing.T) {
|
||||
store := openTestDB(t)
|
||||
_, err := store.ProviderDrafts().GetByDraftID(context.Background(), "missing")
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("GetByDraftID() error = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +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}"
|
||||
REMOTE_CRM_PORT="${REMOTE_CRM_PORT:-18173}"
|
||||
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}"
|
||||
@@ -71,6 +72,10 @@ block = textwrap.dedent("""\
|
||||
return 302 /portal/;
|
||||
}
|
||||
|
||||
location = /portal/admin {
|
||||
return 302 /portal/admin/;
|
||||
}
|
||||
|
||||
location = /kimi-portal {
|
||||
return 302 /portal/;
|
||||
}
|
||||
@@ -91,6 +96,15 @@ block = textwrap.dedent("""\
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /portal-admin-api/ {
|
||||
proxy_pass http://127.0.0.1:${REMOTE_CRM_PORT}/;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
proxy_http_version 1.1;
|
||||
}
|
||||
|
||||
location /kimi-portal/ {
|
||||
return 302 /portal/;
|
||||
}
|
||||
@@ -154,6 +168,9 @@ EOF
|
||||
tksea portal deployed
|
||||
remote: ${REMOTE}
|
||||
portal url: https://sub.tksea.top/portal/
|
||||
portal admin home url: https://sub.tksea.top/portal/admin/
|
||||
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
|
||||
legacy url: https://sub.tksea.top/kimi-portal/
|
||||
portal dir: ${REMOTE_PORTAL_DIR}
|
||||
|
||||
@@ -4,6 +4,9 @@ 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"
|
||||
ADMIN_HOME_FILE="$ROOT_DIR/deploy/tksea-portal/admin/index.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"
|
||||
DEPLOY_SCRIPT="$ROOT_DIR/scripts/deploy/deploy_tksea_portal.sh"
|
||||
|
||||
@@ -22,6 +25,9 @@ assert_contains_file() {
|
||||
|
||||
[[ -f "$HTML_FILE" ]] || fail "missing $HTML_FILE"
|
||||
[[ -f "$ADMIN_HTML_FILE" ]] || fail "missing $ADMIN_HTML_FILE"
|
||||
[[ -f "$ADMIN_HOME_FILE" ]] || fail "missing $ADMIN_HOME_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"
|
||||
[[ -f "$DEPLOY_SCRIPT" ]] || fail "missing $DEPLOY_SCRIPT"
|
||||
|
||||
@@ -44,6 +50,10 @@ 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" "/portal/admin/"
|
||||
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-api"
|
||||
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"
|
||||
@@ -54,15 +64,43 @@ assert_contains_file "$ADMIN_HTML_FILE" "base_url|api_key|requested_model_1,requ
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "reused"
|
||||
assert_contains_file "$ADMIN_HTML_FILE" "reactivated"
|
||||
|
||||
assert_contains_file "$ADMIN_HOME_FILE" "Admin Portal"
|
||||
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" "浏览器不直接写 pack 仓库"
|
||||
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Admin"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/packs"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/hosts"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/providers/"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/preview-import"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/import"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/provider-drafts"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "保存到服务端"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "更新草稿"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "删除草稿"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "服务端草稿"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Manifest 草稿"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal-admin-api"
|
||||
|
||||
assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html"
|
||||
|
||||
assert_contains_file "$NGINX_FILE" "location = /portal"
|
||||
assert_contains_file "$NGINX_FILE" "location = /portal/admin"
|
||||
assert_contains_file "$NGINX_FILE" "location = /kimi-portal"
|
||||
assert_contains_file "$NGINX_FILE" "location /portal/"
|
||||
assert_contains_file "$NGINX_FILE" "location /portal-proxy/"
|
||||
assert_contains_file "$NGINX_FILE" "location /portal-admin-api/"
|
||||
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" "portal admin home url: https://sub.tksea.top/portal/admin/"
|
||||
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"
|
||||
assert_contains_file "$DEPLOY_SCRIPT" "REMOTE_PORTAL_DIR"
|
||||
assert_contains_file "$DEPLOY_SCRIPT" "REMOTE_CRM_PORT"
|
||||
assert_contains_file "$DEPLOY_SCRIPT" "LOCAL_PORTAL_DIR"
|
||||
assert_contains_file "$DEPLOY_SCRIPT" "patch_tksea_portal_nginx.py"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user