feat(admin): publish provider drafts into pack repo
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
- `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,并支持更新 / 删除
|
||||
- 当前也可直接调用服务端 `provider_drafts` API,把 manifest 草稿持久化到 CRM SQLite,并支持更新 / 删除 / 发布到 pack 仓库
|
||||
- `tksea-portal/admin/batch-import.html`
|
||||
- `https://sub.tksea.top/portal/admin/batch-import.html`
|
||||
- 结构化入口地址,当前跳转到 legacy `admin-batch-import.html`
|
||||
|
||||
@@ -292,8 +292,8 @@
|
||||
<h2>新增模型 / 供应商目录</h2>
|
||||
<p>
|
||||
这页负责浏览已安装 pack、选择 provider、调用 <code>preview-import</code> /
|
||||
<code>import</code>,同时提供 provider manifest 草稿生成器。当前版本不直接在浏览器里写入 pack 仓库,
|
||||
但已经把“新增模型”的准备动作收进同一条操作链。
|
||||
<code>import</code>,同时提供 provider manifest 草稿生成与发布。当前版本已经支持先保存草稿,再经由 CRM
|
||||
服务端写入 pack/provider 文件并自动提交到仓库。
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="cta primary" href="/portal/admin/providers.html">打开供应商页</a>
|
||||
@@ -302,7 +302,7 @@
|
||||
<ul class="list">
|
||||
<li>
|
||||
<strong>适用动作</strong>
|
||||
查看 pack 与 provider、输入 keys 做 preview/import、生成 provider 草稿 JSON。
|
||||
查看 pack 与 provider、输入 keys 做 preview/import、生成 provider 草稿,并一键发布到仓库。
|
||||
</li>
|
||||
<li>
|
||||
<strong>默认 API Base</strong>
|
||||
@@ -342,8 +342,8 @@
|
||||
</article>
|
||||
<article class="status-card status-note">
|
||||
<div class="metric-label">当前边界</div>
|
||||
<strong>浏览器不直接写 pack 仓库</strong>
|
||||
<p>新增 provider 模板的最终落盘仍通过仓库提交完成,页面当前先覆盖目录、草稿与导入操作。</p>
|
||||
<strong>浏览器提交到 CRM,再由 CRM 写仓库</strong>
|
||||
<p>页面不会直接拼 Git 命令;所有写 pack/provider 与提交仓库的动作,都统一走 CRM 服务端的发布接口。</p>
|
||||
</article>
|
||||
<article class="status-card status-caution">
|
||||
<div class="metric-label">安全前提</div>
|
||||
|
||||
@@ -371,12 +371,12 @@
|
||||
<p class="hero-copy">
|
||||
这页把“新增模型供应商”和“导入供应商帐号”的前置动作收口在一起。当前版本会先列出
|
||||
pack 里已经存在的 provider,允许直接做 <code>preview-import</code> 与 <code>import</code>。
|
||||
如果你要新增 provider 模板,本页也会生成一份 manifest 草稿,方便再落回仓库提交。
|
||||
如果你要新增 provider 模板,本页也支持把草稿保存到 CRM,再一键发布成 pack/provider 文件并自动提交到仓库。
|
||||
</p>
|
||||
<ul class="hero-points">
|
||||
<li>默认 API Base:<code>/portal-admin-api</code></li>
|
||||
<li>支持同域 Bearer admin token</li>
|
||||
<li>支持 provider manifest 草稿生成</li>
|
||||
<li>支持 provider 草稿发布到 pack 仓库</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
@@ -499,8 +499,8 @@
|
||||
<article class="card panel" id="manifest-draft">
|
||||
<h2>Provider Manifest 草稿</h2>
|
||||
<p class="panel-desc">
|
||||
这部分不会直接写仓库,只生成一个可复制的 JSON 草稿,解决“新增模型页面完全缺失”的问题。
|
||||
真正落盘仍通过 pack 仓库提交完成。
|
||||
这部分既能生成与保存 provider 草稿,也能从已保存草稿一键发布到 pack/provider 文件并提交到仓库。
|
||||
页面本身不直接拼 Git 命令,所有写仓库动作都经由 CRM 服务端完成。
|
||||
</p>
|
||||
|
||||
<div class="field-grid two">
|
||||
@@ -530,10 +530,16 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>发布 Commit Message
|
||||
<input id="draft-commit-message" type="text" placeholder="feat(pack): publish provider draft openai-zhongzhuan">
|
||||
<span class="hint">留空时会按 provider_id 自动生成标准 commit message。</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="secondary" id="generate-draft-btn">生成草稿</button>
|
||||
<button class="primary" id="save-draft-btn">保存到服务端</button>
|
||||
<button class="secondary" id="update-draft-btn">更新草稿</button>
|
||||
<button class="primary" id="publish-draft-btn">发布到仓库</button>
|
||||
<button class="ghost" id="delete-draft-btn">删除草稿</button>
|
||||
<button class="ghost" id="copy-draft-btn">复制 JSON</button>
|
||||
<button class="ghost" id="refresh-drafts-btn">刷新草稿列表</button>
|
||||
@@ -615,6 +621,7 @@
|
||||
const draftSmokeModelInput = document.getElementById("draft-smoke-model");
|
||||
const draftBaseURLInput = document.getElementById("draft-base-url");
|
||||
const draftModelsInput = document.getElementById("draft-models");
|
||||
const draftCommitMessageInput = document.getElementById("draft-commit-message");
|
||||
|
||||
function defaultApiBase() {
|
||||
return `${window.location.origin}/portal-admin-api`;
|
||||
@@ -688,6 +695,7 @@
|
||||
draftSmokeModel: draftSmokeModelInput.value.trim(),
|
||||
draftBaseURL: draftBaseURLInput.value.trim(),
|
||||
draftModels: draftModelsInput.value.trim(),
|
||||
draftCommitMessage: draftCommitMessageInput.value.trim(),
|
||||
}));
|
||||
syncMetrics();
|
||||
setStatus(catalogStatus, "本地配置已保存。", "success");
|
||||
@@ -720,6 +728,7 @@
|
||||
draftSmokeModelInput.value = payload.draftSmokeModel || "";
|
||||
draftBaseURLInput.value = payload.draftBaseURL || "";
|
||||
draftModelsInput.value = payload.draftModels || "";
|
||||
draftCommitMessageInput.value = payload.draftCommitMessage || "";
|
||||
} catch (error) {
|
||||
apiBaseInput.value = defaultApiBase();
|
||||
}
|
||||
@@ -816,6 +825,9 @@
|
||||
draftSmokeModelInput.value = draft.smoke_test_model || "";
|
||||
draftBaseURLInput.value = draft.base_url || "";
|
||||
draftModelsInput.value = Array.isArray(draft.supported_models) ? draft.supported_models.join(",") : "";
|
||||
if (!draftCommitMessageInput.value.trim()) {
|
||||
draftCommitMessageInput.value = `feat(pack): publish provider draft ${draft.provider_id || ""}`.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraftFormSelection() {
|
||||
@@ -1144,6 +1156,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function publishDraftToRepo() {
|
||||
const button = document.getElementById("publish-draft-btn");
|
||||
button.disabled = true;
|
||||
try {
|
||||
if (!state.currentDraftID) {
|
||||
throw new Error("请先从服务端草稿列表选择一条草稿");
|
||||
}
|
||||
const result = await requestJSON(`/api/provider-drafts/${encodeURIComponent(state.currentDraftID)}/publish`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({
|
||||
commit_message: draftCommitMessageInput.value.trim(),
|
||||
}),
|
||||
});
|
||||
importResult.textContent = JSON.stringify(result.publish || result, null, 2);
|
||||
setStatus(
|
||||
draftStatus,
|
||||
`已发布到仓库:${result.publish?.provider_path || "-"} · ${result.publish?.pack_version_before || "-"} -> ${result.publish?.pack_version_after || "-"} · ${result.publish?.commit_sha || "-"}`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus(draftStatus, `发布失败:${error.message}`, "danger");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
@@ -1160,6 +1199,7 @@
|
||||
document.getElementById("generate-draft-btn").addEventListener("click", generateDraft);
|
||||
document.getElementById("save-draft-btn").addEventListener("click", saveDraftToServer);
|
||||
document.getElementById("update-draft-btn").addEventListener("click", updateDraftOnServer);
|
||||
document.getElementById("publish-draft-btn").addEventListener("click", publishDraftToRepo);
|
||||
document.getElementById("delete-draft-btn").addEventListener("click", deleteDraftOnServer);
|
||||
document.getElementById("copy-draft-btn").addEventListener("click", copyDraft);
|
||||
document.getElementById("refresh-drafts-btn").addEventListener("click", loadServerDrafts);
|
||||
|
||||
@@ -45,6 +45,7 @@ SUB2API_CRM_ADMIN_TOKEN=change-me-before-production SUB2API_CRM_LISTEN_ADDR=127.
|
||||
| `SUB2API_CRM_ADMIN_TOKEN` | 控制面 Bearer token | `crm-admin-token` |
|
||||
| `SUB2API_CRM_LISTEN_ADDR` | 监听地址 | `:18081` |
|
||||
| `SUB2API_CRM_SQLITE_DSN` | SQLite DSN | `file:/tmp/sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000` |
|
||||
| `SUB2API_CRM_REPO_ROOT` | provider 草稿发布到 pack/provider 文件时使用的仓库根目录 | `/home/ubuntu/sub2api-cn-relay-manager-git-20260528-bundle` |
|
||||
|
||||
## 公网 Portal 资产
|
||||
|
||||
@@ -63,7 +64,7 @@ SUB2API_CRM_ADMIN_TOKEN=change-me-before-production SUB2API_CRM_LISTEN_ADDR=127.
|
||||
- 统一提供“新增模型 / 供应商目录”和“导入供应商帐号”入口
|
||||
- `https://sub.tksea.top/portal/admin/providers.html`
|
||||
- provider 目录与 preview/import 管理页
|
||||
- 当前已支持通过 `provider_drafts` API 把 provider manifest 草稿持久化到 CRM SQLite,并直接更新 / 删除
|
||||
- 当前已支持通过 `provider_drafts` API 把 provider manifest 草稿持久化到 CRM SQLite,并直接更新 / 删除 / 发布到 pack 仓库
|
||||
- `https://sub.tksea.top/portal/admin/batch-import.html`
|
||||
- 结构化 batch-import 入口,当前跳到 legacy 最小管理页
|
||||
- `https://sub.tksea.top/portal/admin-batch-import.html`
|
||||
@@ -79,6 +80,26 @@ SUB2API_CRM_ADMIN_TOKEN=change-me-before-production SUB2API_CRM_LISTEN_ADDR=127.
|
||||
- 浏览器侧仍需 Bearer admin token
|
||||
- 作用是让静态 admin 页面不必直接访问 remote43 的内网 `18173`
|
||||
|
||||
当前 provider 草稿发布相关 API:
|
||||
|
||||
- `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}`
|
||||
- `POST /api/provider-drafts/{draft_id}/publish`
|
||||
|
||||
`publish` 的运行前提:
|
||||
|
||||
- CRM 进程必须配置 `SUB2API_CRM_REPO_ROOT`
|
||||
- 该目录必须是真实 Git 仓库,而不是普通文件夹
|
||||
- 当前实现会原子完成:
|
||||
- 生成或更新 `packs/<pack_id>/providers/<provider_id>.json`
|
||||
- bump `pack.json` patch 版本
|
||||
- 更新 `checksums.txt`
|
||||
- 校验整个 pack
|
||||
- `git add` + `git commit`
|
||||
|
||||
兼容入口:
|
||||
|
||||
- `https://sub.tksea.top/kimi-portal/`
|
||||
|
||||
@@ -44,6 +44,21 @@
|
||||
- 新增 `DELETE /api/provider-drafts/{draft_id}`
|
||||
- 数据当前落到 CRM SQLite `provider_drafts` 表
|
||||
- `providers.html` 已可直接“保存到服务端”、回看历史草稿、以及更新 / 删除已保存草稿
|
||||
- 2026-05-28 已把“草稿一键生成 pack/provider 文件并提交到仓库”的发布链路补齐:
|
||||
- 新增 `POST /api/provider-drafts/{draft_id}/publish`
|
||||
- 发布动作会把草稿 canonicalize 成完整 `pack.ProviderManifest`
|
||||
- 服务端会原子执行:写 `providers/<provider_id>.json`、bump `pack.json` patch 版本、更新 `checksums.txt`、重跑整包校验、`git add` + `git commit`
|
||||
- 运行前提新增:`SUB2API_CRM_REPO_ROOT` 必须指向**真实 Git 仓库**
|
||||
- remote43 原本的 `/home/ubuntu/sub2api-cn-relay-manager` 只是普通目录,不带 `.git`;当前已改为指向基于本机 `git bundle` 拉起的真实 checkout:`/home/ubuntu/sub2api-cn-relay-manager-git-20260528-bundle`
|
||||
- 公网 `providers.html` 已新增“发布到仓库”按钮与 commit message 输入框
|
||||
- remote43 公网真验已通过:
|
||||
- `draft_id=draft_remote43_publish_smoke_1779924243`
|
||||
- `provider_id=smoke-publish-1779924243`
|
||||
- `provider_path=packs/openai-cn-pack/providers/smoke-publish-1779924243.json`
|
||||
- `pack_version=1.1.5 -> 1.1.6`
|
||||
- `publish_mode=created`
|
||||
- `commit_sha=d8d647e`
|
||||
- 远端 repo `HEAD` 与 API 返回 `commit_sha` 一致,说明 create -> publish -> git commit 已完整闭环
|
||||
- 线上无副作用验收已确认:
|
||||
- `GET /portal/` 返回 `200`
|
||||
- `GET /kimi-portal/` 返回 `302 -> /portal/`
|
||||
|
||||
@@ -3,12 +3,17 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -299,6 +304,36 @@ func TestAPIDeleteProviderDraftReturnsNoContent(t *testing.T) {
|
||||
assertStatusCode(t, response, http.StatusNoContent)
|
||||
}
|
||||
|
||||
func TestAPIPublishProviderDraftReturnsSummary(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
PublishProviderDraft: func(_ context.Context, req PublishProviderDraftRequest) (PublishProviderDraftResult, error) {
|
||||
if req.DraftID != "draft_001" {
|
||||
t.Fatalf("DraftID = %q, want draft_001", req.DraftID)
|
||||
}
|
||||
return PublishProviderDraftResult{
|
||||
DraftID: req.DraftID,
|
||||
PackID: "openai-cn-pack",
|
||||
ProviderID: "openai-zhongzhuan",
|
||||
ProviderPath: "packs/openai-cn-pack/providers/openai-zhongzhuan.json",
|
||||
PackVersionBefore: "1.1.4",
|
||||
PackVersionAfter: "1.1.5",
|
||||
PublishMode: "created",
|
||||
CommitMessage: "feat(pack): publish provider draft openai-zhongzhuan",
|
||||
CommitSHA: "abc1234",
|
||||
RepoRoot: "/srv/sub2api-cn-relay-manager",
|
||||
}, nil
|
||||
},
|
||||
})
|
||||
request := httptestRequest(t, http.MethodPost, "/api/provider-drafts/draft_001/publish", map[string]any{
|
||||
"commit_message": "feat(pack): publish provider draft openai-zhongzhuan",
|
||||
}, "secret-token")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, http.StatusOK)
|
||||
assertJSONContains(t, response.Body().Bytes(), "publish.provider_id", "openai-zhongzhuan")
|
||||
assertJSONContains(t, response.Body().Bytes(), "publish.pack_version_after", "1.1.5")
|
||||
assertJSONContains(t, response.Body().Bytes(), "publish.commit_sha", "abc1234")
|
||||
}
|
||||
|
||||
func TestAPIImportProviderReturnsConflictWithBatchStatus(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ImportProvider: func(context.Context, ImportProviderRequest) (provision.RuntimeImportResult, error) {
|
||||
@@ -1403,3 +1438,126 @@ func TestNewActionSetPackErrorPaths(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewActionSetPublishProviderDraftCreatesPackCommit(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git is required for publish action test")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
store, err := sqlite.Open(ctx, "file:"+filepath.Join(t.TempDir(), "publish-action.db")+"?_foreign_keys=on&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
repoRoot := t.TempDir()
|
||||
packDir := filepath.Join(repoRoot, "packs", "openai-cn-pack")
|
||||
createPackFixtureAt(t, packDir, map[string]string{
|
||||
"pack.json": `{
|
||||
"pack_id": "openai-cn-pack",
|
||||
"version": "1.1.4",
|
||||
"vendor": "YourTeam",
|
||||
"target_host": "sub2api",
|
||||
"min_host_version": "0.1.126",
|
||||
"max_host_version": "0.2.x",
|
||||
"providers_dir": "providers",
|
||||
"checksum_file": "checksums.txt"
|
||||
}`,
|
||||
"providers/deepseek.json": `{
|
||||
"provider_id": "deepseek",
|
||||
"display_name": "DeepSeek",
|
||||
"base_url": "https://api.deepseek.com",
|
||||
"platform": "openai",
|
||||
"account_type": "apikey",
|
||||
"default_models": ["deepseek-chat"],
|
||||
"smoke_test_model": "deepseek-chat",
|
||||
"group_template": {"name": "g", "rate_multiplier": 1.0},
|
||||
"channel_template": {"name": "c", "model_mapping": {"deepseek-chat": "deepseek-chat"}},
|
||||
"plan_template": {"name": "p", "price": 1, "validity_days": 30, "validity_unit": "day"},
|
||||
"import": {"supports_multi_key": true, "supports_strict": true, "supports_partial": true}
|
||||
}`,
|
||||
})
|
||||
initGitRepo(t, repoRoot)
|
||||
|
||||
if _, err := store.ProviderDrafts().Create(ctx, sqlite.ProviderDraft{
|
||||
DraftID: "draft_publish_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","display_name":"OpenAI 中转","platform":"openai","base_url":"https://api.example.com/v1","smoke_test_model":"gpt-5.4"}`,
|
||||
SourceHostID: "remote43-current-host",
|
||||
}); err != nil {
|
||||
t.Fatalf("ProviderDrafts().Create() error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("SUB2API_CRM_REPO_ROOT", repoRoot)
|
||||
actions := NewActionSet(appTestDSN(t, store))
|
||||
result, err := actions.PublishProviderDraft(ctx, PublishProviderDraftRequest{
|
||||
DraftID: "draft_publish_001",
|
||||
CommitMessage: "feat(pack): publish provider draft openai-zhongzhuan",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PublishProviderDraft() error = %v", err)
|
||||
}
|
||||
if result.PublishMode != "created" {
|
||||
t.Fatalf("PublishMode = %q, want created", result.PublishMode)
|
||||
}
|
||||
if result.PackVersionAfter != "1.1.5" {
|
||||
t.Fatalf("PackVersionAfter = %q, want 1.1.5", result.PackVersionAfter)
|
||||
}
|
||||
if strings.TrimSpace(result.CommitSHA) == "" {
|
||||
t.Fatal("CommitSHA is empty")
|
||||
}
|
||||
|
||||
body, err := os.ReadFile(filepath.Join(packDir, "providers", "openai-zhongzhuan.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("os.ReadFile() provider error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), `"provider_id": "openai-zhongzhuan"`) {
|
||||
t.Fatalf("provider body = %s, want openai-zhongzhuan", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func createPackFixtureAt(t *testing.T, packDir string, files map[string]string) {
|
||||
t.Helper()
|
||||
|
||||
lines := make([]string, 0, len(files))
|
||||
for relativePath, content := range files {
|
||||
absolutePath := filepath.Join(packDir, relativePath)
|
||||
if err := os.MkdirAll(filepath.Dir(absolutePath), 0o755); err != nil {
|
||||
t.Fatalf("os.MkdirAll(%q) error = %v", absolutePath, err)
|
||||
}
|
||||
if err := os.WriteFile(absolutePath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("os.WriteFile(%q) error = %v", absolutePath, err)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
lines = append(lines, hex.EncodeToString(sum[:])+" "+relativePath)
|
||||
}
|
||||
checksumPath := filepath.Join(packDir, "checksums.txt")
|
||||
if err := os.WriteFile(checksumPath, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("os.WriteFile(%q) error = %v", checksumPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func initGitRepo(t *testing.T, repoRoot string) {
|
||||
t.Helper()
|
||||
runGitTest(t, repoRoot, "init")
|
||||
runGitTest(t, repoRoot, "config", "user.name", "Test User")
|
||||
runGitTest(t, repoRoot, "config", "user.email", "test@example.com")
|
||||
runGitTest(t, repoRoot, "add", ".")
|
||||
runGitTest(t, repoRoot, "commit", "-m", "chore: seed pack fixture")
|
||||
}
|
||||
|
||||
func runGitTest(t *testing.T, repoRoot string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", repoRoot}, args...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v error = %v: %s", args, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestDefaultBackgroundSchedulersAndNewActionSet(t *testing.T) {
|
||||
if actions.CreateBatchImportRun == nil || actions.ListBatchImportRuns == nil || actions.GetBatchImportRun == nil || actions.ListBatchImportRunItems == nil || actions.GetBatchImportRunItem == nil {
|
||||
t.Fatalf("NewActionSet() returned nil batch actions: %+v", actions)
|
||||
}
|
||||
if actions.CreateHost == nil || actions.ListPacks == nil || actions.GetPack == nil || actions.ListPackProviders == nil {
|
||||
if actions.CreateHost == nil || actions.ListPacks == nil || actions.GetPack == nil || actions.ListPackProviders == nil || actions.PublishProviderDraft == nil {
|
||||
t.Fatalf("NewActionSet() returned nil app actions: %+v", actions)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/batch"
|
||||
"sub2api-cn-relay-manager/internal/config"
|
||||
"sub2api-cn-relay-manager/internal/host/sub2api"
|
||||
"sub2api-cn-relay-manager/internal/pack"
|
||||
"sub2api-cn-relay-manager/internal/provision"
|
||||
@@ -32,6 +33,7 @@ type ActionSet struct {
|
||||
GetProviderDraft func(context.Context, string) (ProviderDraftInfo, error)
|
||||
UpdateProviderDraft func(context.Context, UpdateProviderDraftRequest) (ProviderDraftInfo, error)
|
||||
DeleteProviderDraft func(context.Context, string) error
|
||||
PublishProviderDraft func(context.Context, PublishProviderDraftRequest) (PublishProviderDraftResult, 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)
|
||||
@@ -129,6 +131,11 @@ type UpdateProviderDraftRequest struct {
|
||||
CreateProviderDraftRequest
|
||||
}
|
||||
|
||||
type PublishProviderDraftRequest struct {
|
||||
DraftID string `json:"-"`
|
||||
CommitMessage string `json:"commit_message,omitempty"`
|
||||
}
|
||||
|
||||
type ProviderDraftInfo struct {
|
||||
DraftID string `json:"draft_id"`
|
||||
PackID string `json:"pack_id"`
|
||||
@@ -145,6 +152,19 @@ type ProviderDraftInfo struct {
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type PublishProviderDraftResult struct {
|
||||
DraftID string `json:"draft_id"`
|
||||
PackID string `json:"pack_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ProviderPath string `json:"provider_path"`
|
||||
PackVersionBefore string `json:"pack_version_before"`
|
||||
PackVersionAfter string `json:"pack_version_after"`
|
||||
PublishMode string `json:"publish_mode"`
|
||||
CommitMessage string `json:"commit_message"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
RepoRoot string `json:"repo_root"`
|
||||
}
|
||||
|
||||
type AssignAccessSubscriptionsRequest struct {
|
||||
HostID string `json:"host_id,omitempty"`
|
||||
PackPath string `json:"pack_path"`
|
||||
@@ -288,6 +308,9 @@ func NewAPIHandler(adminToken string, actions ActionSet) http.Handler {
|
||||
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("POST /api/provider-drafts/{draftID}/publish", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handlePublishProviderDraft(w, r, actions.PublishProviderDraft)
|
||||
})))
|
||||
mux.Handle("GET /api/import-batches/{batchID}", requireAdminToken(adminToken, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleBatchDetail(w, r, actions.BatchDetail)
|
||||
})))
|
||||
@@ -455,6 +478,29 @@ func handleDeleteProviderDraft(w http.ResponseWriter, r *http.Request, fn func(c
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func handlePublishProviderDraft(w http.ResponseWriter, r *http.Request, fn func(context.Context, PublishProviderDraftRequest) (PublishProviderDraftResult, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "publish-provider-draft action is not configured"})
|
||||
return
|
||||
}
|
||||
var req PublishProviderDraftRequest
|
||||
if err := decodeJSON(r, &req); 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
|
||||
}
|
||||
result, err := fn(r.Context(), req)
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"publish": result})
|
||||
}
|
||||
|
||||
func requireAdminToken(token string, next http.Handler) http.Handler {
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -1050,6 +1096,8 @@ func classifyError(err error) *httpError {
|
||||
switch {
|
||||
case strings.Contains(message, "already installed") || strings.Contains(message, "checksum drift"):
|
||||
return &httpError{StatusCode: http.StatusConflict, Code: "pack_conflict", Message: message}
|
||||
case strings.Contains(message, "repo root"):
|
||||
return &httpError{StatusCode: http.StatusServiceUnavailable, Code: "publish_unavailable", Message: message}
|
||||
case strings.Contains(message, "run import again before reconcile"):
|
||||
return &httpError{StatusCode: http.StatusConflict, Code: "batch_not_reconcilable", Message: message}
|
||||
case strings.Contains(message, "not found in pack"):
|
||||
@@ -1187,6 +1235,51 @@ func NewActionSet(sqliteDSN string) ActionSet {
|
||||
defer store.Close()
|
||||
return store.ProviderDrafts().DeleteByDraftID(ctx, draftID)
|
||||
},
|
||||
PublishProviderDraft: func(ctx context.Context, req PublishProviderDraftRequest) (PublishProviderDraftResult, error) {
|
||||
startupCfg, err := config.LoadStartupFromEnv()
|
||||
if err != nil {
|
||||
return PublishProviderDraftResult{}, err
|
||||
}
|
||||
if strings.TrimSpace(startupCfg.Repository.RepoRoot) == "" {
|
||||
return PublishProviderDraftResult{}, fmt.Errorf("pack repo root is not configured")
|
||||
}
|
||||
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return PublishProviderDraftResult{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
row, err := store.ProviderDrafts().GetByDraftID(ctx, req.DraftID)
|
||||
if err != nil {
|
||||
return PublishProviderDraftResult{}, err
|
||||
}
|
||||
manifest, err := buildPublishedProviderManifest(row)
|
||||
if err != nil {
|
||||
return PublishProviderDraftResult{}, err
|
||||
}
|
||||
publishResult, err := pack.PublishProviderManifest(ctx, pack.PublishProviderManifestRequest{
|
||||
RepoRoot: startupCfg.Repository.RepoRoot,
|
||||
PackID: row.PackID,
|
||||
Manifest: manifest,
|
||||
CommitMessage: strings.TrimSpace(req.CommitMessage),
|
||||
})
|
||||
if err != nil {
|
||||
return PublishProviderDraftResult{}, err
|
||||
}
|
||||
return PublishProviderDraftResult{
|
||||
DraftID: row.DraftID,
|
||||
PackID: publishResult.PackID,
|
||||
ProviderID: publishResult.ProviderID,
|
||||
ProviderPath: publishResult.ProviderPath,
|
||||
PackVersionBefore: publishResult.PackVersionBefore,
|
||||
PackVersionAfter: publishResult.PackVersionAfter,
|
||||
PublishMode: publishResult.PublishMode,
|
||||
CommitMessage: publishResult.CommitMessage,
|
||||
CommitSHA: publishResult.CommitSHA,
|
||||
RepoRoot: publishResult.RepoRoot,
|
||||
}, nil
|
||||
},
|
||||
InstallPack: func(ctx context.Context, req InstallPackRequest) (provision.PackInstallResult, error) {
|
||||
loadedPack, err := pack.LoadPath(req.PackPath)
|
||||
if err != nil {
|
||||
@@ -1991,6 +2084,87 @@ func providerDraftRecordToInfoFromStored(row sqlite.ProviderDraft) (ProviderDraf
|
||||
return providerDraftRecordToInfo(row, manifestValue, supportedModels)
|
||||
}
|
||||
|
||||
func buildPublishedProviderManifest(row sqlite.ProviderDraft) (pack.ProviderManifest, error) {
|
||||
manifest := pack.ProviderManifest{
|
||||
ProviderID: strings.TrimSpace(row.ProviderID),
|
||||
DisplayName: strings.TrimSpace(row.DisplayName),
|
||||
BaseURL: strings.TrimSpace(row.BaseURL),
|
||||
Platform: strings.TrimSpace(row.Platform),
|
||||
AccountType: "apikey",
|
||||
SmokeTestModel: strings.TrimSpace(row.SmokeTestModel),
|
||||
DefaultModels: normalizeStringList(decodeStringList(row.SupportedModelsJSON)),
|
||||
GroupTemplate: pack.GroupTemplate{
|
||||
Name: strings.TrimSpace(row.DisplayName) + " 默认分组",
|
||||
RateMultiplier: 1.0,
|
||||
},
|
||||
ChannelTemplate: pack.ChannelTemplate{
|
||||
Name: strings.TrimSpace(row.DisplayName) + " 默认渠道",
|
||||
ModelMapping: map[string]string{},
|
||||
},
|
||||
PlanTemplate: pack.PlanTemplate{
|
||||
Name: strings.TrimSpace(row.DisplayName) + " 默认套餐",
|
||||
Price: 19.9,
|
||||
ValidityDays: 30,
|
||||
ValidityUnit: "day",
|
||||
},
|
||||
Import: pack.ImportOptions{
|
||||
SupportsMultiKey: true,
|
||||
SupportsStrict: true,
|
||||
SupportsPartial: true,
|
||||
},
|
||||
}
|
||||
if strings.TrimSpace(manifest.SmokeTestModel) == "" && len(manifest.DefaultModels) > 0 {
|
||||
manifest.SmokeTestModel = manifest.DefaultModels[0]
|
||||
}
|
||||
if len(manifest.DefaultModels) == 0 && strings.TrimSpace(manifest.SmokeTestModel) != "" {
|
||||
manifest.DefaultModels = []string{manifest.SmokeTestModel}
|
||||
}
|
||||
for _, model := range manifest.DefaultModels {
|
||||
manifest.ChannelTemplate.ModelMapping[model] = model
|
||||
}
|
||||
|
||||
if strings.TrimSpace(row.ManifestJSON) != "" {
|
||||
if err := json.Unmarshal([]byte(row.ManifestJSON), &manifest); err != nil {
|
||||
return pack.ProviderManifest{}, fmt.Errorf("decode publishable provider manifest: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
manifest.ProviderID = strings.TrimSpace(manifest.ProviderID)
|
||||
manifest.DisplayName = strings.TrimSpace(manifest.DisplayName)
|
||||
manifest.BaseURL = strings.TrimSpace(manifest.BaseURL)
|
||||
manifest.Platform = strings.TrimSpace(manifest.Platform)
|
||||
manifest.AccountType = strings.TrimSpace(manifest.AccountType)
|
||||
manifest.SmokeTestModel = strings.TrimSpace(manifest.SmokeTestModel)
|
||||
if manifest.AccountType == "" {
|
||||
manifest.AccountType = "apikey"
|
||||
}
|
||||
manifest.DefaultModels = normalizeStringList(manifest.DefaultModels)
|
||||
if len(manifest.DefaultModels) == 0 && manifest.SmokeTestModel != "" {
|
||||
manifest.DefaultModels = []string{manifest.SmokeTestModel}
|
||||
}
|
||||
if manifest.SmokeTestModel == "" && len(manifest.DefaultModels) > 0 {
|
||||
manifest.SmokeTestModel = manifest.DefaultModels[0]
|
||||
}
|
||||
if manifest.ChannelTemplate.ModelMapping == nil {
|
||||
manifest.ChannelTemplate.ModelMapping = map[string]string{}
|
||||
}
|
||||
for _, model := range manifest.DefaultModels {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := manifest.ChannelTemplate.ModelMapping[model]; !ok {
|
||||
manifest.ChannelTemplate.ModelMapping[model] = model
|
||||
}
|
||||
}
|
||||
if manifest.SmokeTestModel != "" {
|
||||
if _, ok := manifest.ChannelTemplate.ModelMapping[manifest.SmokeTestModel]; !ok {
|
||||
manifest.ChannelTemplate.ModelMapping[manifest.SmokeTestModel] = manifest.SmokeTestModel
|
||||
}
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func encodeStringList(values []string) string {
|
||||
encoded, err := json.Marshal(normalizeStringList(values))
|
||||
if err != nil {
|
||||
@@ -2011,6 +2185,18 @@ func normalizeStringList(values []string) []string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
func decodeStringList(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return []string{}
|
||||
}
|
||||
values := []string{}
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func deriveAccessStatus(gw sub2api.GatewayAccessResult) string {
|
||||
if provision.GatewayAccessReady(gw) {
|
||||
return provision.AccessStatusSubscriptionReady
|
||||
|
||||
@@ -11,6 +11,7 @@ const (
|
||||
EnvListenAddr = "SUB2API_CRM_LISTEN_ADDR"
|
||||
EnvSQLiteDSN = "SUB2API_CRM_SQLITE_DSN"
|
||||
EnvAdminToken = "SUB2API_CRM_ADMIN_TOKEN"
|
||||
EnvRepoRoot = "SUB2API_CRM_REPO_ROOT"
|
||||
EnvReconcileWorkerEnabled = "SUB2API_CRM_RECONCILE_WORKER_ENABLED"
|
||||
EnvReconcilePollInterval = "SUB2API_CRM_RECONCILE_POLL_INTERVAL"
|
||||
|
||||
@@ -32,10 +33,15 @@ type ReconcileConfig struct {
|
||||
PollInterval time.Duration
|
||||
}
|
||||
|
||||
type RepositoryConfig struct {
|
||||
RepoRoot string
|
||||
}
|
||||
|
||||
type StartupConfig struct {
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Reconcile ReconcileConfig
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Repository RepositoryConfig
|
||||
Reconcile ReconcileConfig
|
||||
}
|
||||
|
||||
func LoadStartupFromEnv() (StartupConfig, error) {
|
||||
@@ -54,6 +60,9 @@ func loadStartupFromLookupEnv(lookup func(string) (string, bool)) (StartupConfig
|
||||
Database: DatabaseConfig{
|
||||
SQLiteDSN: readOptionalEnv(lookup, EnvSQLiteDSN, DefaultSQLiteDSN),
|
||||
},
|
||||
Repository: RepositoryConfig{
|
||||
RepoRoot: readOptionalEnv(lookup, EnvRepoRoot, ""),
|
||||
},
|
||||
Reconcile: ReconcileConfig{
|
||||
WorkerEnabled: readOptionalBoolEnv(lookup, EnvReconcileWorkerEnabled, false),
|
||||
PollInterval: reconcilePollInterval,
|
||||
|
||||
@@ -63,6 +63,8 @@ func TestLoadStartupFromLookupEnv(t *testing.T) {
|
||||
return ":9090", true
|
||||
case EnvSQLiteDSN:
|
||||
return "/data/db.sqlite", true
|
||||
case EnvRepoRoot:
|
||||
return "/srv/sub2api-cn-relay-manager", true
|
||||
case EnvReconcileWorkerEnabled:
|
||||
return "true", true
|
||||
case EnvReconcilePollInterval:
|
||||
@@ -81,6 +83,9 @@ func TestLoadStartupFromLookupEnv(t *testing.T) {
|
||||
if cfg.Database.SQLiteDSN != "/data/db.sqlite" {
|
||||
t.Fatalf("SQLiteDSN = %q, want %q", cfg.Database.SQLiteDSN, "/data/db.sqlite")
|
||||
}
|
||||
if cfg.Repository.RepoRoot != "/srv/sub2api-cn-relay-manager" {
|
||||
t.Fatalf("RepoRoot = %q, want %q", cfg.Repository.RepoRoot, "/srv/sub2api-cn-relay-manager")
|
||||
}
|
||||
if !cfg.Reconcile.WorkerEnabled {
|
||||
t.Fatal("WorkerEnabled = false, want true")
|
||||
}
|
||||
@@ -102,6 +107,9 @@ func TestLoadStartupFromLookupEnv(t *testing.T) {
|
||||
if cfg.Database.SQLiteDSN != DefaultSQLiteDSN {
|
||||
t.Fatalf("SQLiteDSN = %q, want %q", cfg.Database.SQLiteDSN, DefaultSQLiteDSN)
|
||||
}
|
||||
if cfg.Repository.RepoRoot != "" {
|
||||
t.Fatalf("RepoRoot = %q, want empty by default", cfg.Repository.RepoRoot)
|
||||
}
|
||||
if cfg.Reconcile.WorkerEnabled {
|
||||
t.Fatal("WorkerEnabled = true, want false by default")
|
||||
}
|
||||
|
||||
316
internal/pack/publisher.go
Normal file
316
internal/pack/publisher.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PublishProviderManifestRequest struct {
|
||||
RepoRoot string
|
||||
PackID string
|
||||
Manifest ProviderManifest
|
||||
CommitMessage string
|
||||
}
|
||||
|
||||
type PublishProviderManifestResult struct {
|
||||
RepoRoot string `json:"repo_root"`
|
||||
PackID string `json:"pack_id"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
ProviderPath string `json:"provider_path"`
|
||||
PackVersionBefore string `json:"pack_version_before"`
|
||||
PackVersionAfter string `json:"pack_version_after"`
|
||||
PublishMode string `json:"publish_mode"`
|
||||
CommitMessage string `json:"commit_message"`
|
||||
CommitSHA string `json:"commit_sha"`
|
||||
}
|
||||
|
||||
func PublishProviderManifest(ctx context.Context, req PublishProviderManifestRequest) (PublishProviderManifestResult, error) {
|
||||
repoRoot, err := resolveRepoRoot(req.RepoRoot)
|
||||
if err != nil {
|
||||
return PublishProviderManifestResult{}, err
|
||||
}
|
||||
|
||||
manifest := normalizeProviderManifest(req.Manifest)
|
||||
packDir := filepath.Join(repoRoot, "packs", strings.TrimSpace(req.PackID))
|
||||
loadedPack, err := LoadDir(packDir)
|
||||
if err != nil {
|
||||
return PublishProviderManifestResult{}, fmt.Errorf("load pack dir %q: %w", packDir, err)
|
||||
}
|
||||
if err := validateProviders(packDir, []ProviderManifest{manifest}); err != nil {
|
||||
return PublishProviderManifestResult{}, err
|
||||
}
|
||||
|
||||
providersDir := filepath.Join(packDir, loadedPack.Manifest.ProvidersDir)
|
||||
if err := os.MkdirAll(providersDir, 0o755); err != nil {
|
||||
return PublishProviderManifestResult{}, fmt.Errorf("ensure providers dir %q: %w", providersDir, err)
|
||||
}
|
||||
|
||||
providerFileName := strings.TrimSpace(manifest.ProviderID) + ".json"
|
||||
providerPath := filepath.Join(providersDir, providerFileName)
|
||||
publishMode := "created"
|
||||
if _, err := os.Stat(providerPath); err == nil {
|
||||
publishMode = "updated"
|
||||
} else if !os.IsNotExist(err) {
|
||||
return PublishProviderManifestResult{}, fmt.Errorf("stat provider file %q: %w", providerPath, err)
|
||||
}
|
||||
|
||||
providerBody, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return PublishProviderManifestResult{}, fmt.Errorf("marshal provider manifest: %w", err)
|
||||
}
|
||||
providerBody = append(providerBody, '\n')
|
||||
if err := os.WriteFile(providerPath, providerBody, 0o644); err != nil {
|
||||
return PublishProviderManifestResult{}, fmt.Errorf("write provider manifest %q: %w", providerPath, err)
|
||||
}
|
||||
|
||||
packManifest := loadedPack.Manifest
|
||||
previousVersion := packManifest.Version
|
||||
nextVersion, err := bumpPatchVersion(previousVersion)
|
||||
if err != nil {
|
||||
return PublishProviderManifestResult{}, err
|
||||
}
|
||||
packManifest.Version = nextVersion
|
||||
packBody, err := json.MarshalIndent(packManifest, "", " ")
|
||||
if err != nil {
|
||||
return PublishProviderManifestResult{}, fmt.Errorf("marshal pack manifest: %w", err)
|
||||
}
|
||||
packBody = append(packBody, '\n')
|
||||
packManifestPath := filepath.Join(packDir, "pack.json")
|
||||
if err := os.WriteFile(packManifestPath, packBody, 0o644); err != nil {
|
||||
return PublishProviderManifestResult{}, fmt.Errorf("write pack manifest %q: %w", packManifestPath, err)
|
||||
}
|
||||
|
||||
if err := updateChecksumFile(packDir, packManifest.ChecksumFile, []string{
|
||||
"pack.json",
|
||||
filepath.ToSlash(filepath.Join(packManifest.ProvidersDir, providerFileName)),
|
||||
}); err != nil {
|
||||
return PublishProviderManifestResult{}, err
|
||||
}
|
||||
|
||||
if _, err := LoadDir(packDir); err != nil {
|
||||
return PublishProviderManifestResult{}, fmt.Errorf("re-validate published pack %q: %w", packDir, err)
|
||||
}
|
||||
|
||||
commitMessage := strings.TrimSpace(req.CommitMessage)
|
||||
if commitMessage == "" {
|
||||
commitMessage = fmt.Sprintf("feat(pack): publish provider draft %s", manifest.ProviderID)
|
||||
}
|
||||
commitSHA, err := commitPackPublish(ctx, repoRoot, commitMessage, []string{
|
||||
filepath.Join("packs", packManifest.PackID, "pack.json"),
|
||||
filepath.Join("packs", packManifest.PackID, packManifest.ChecksumFile),
|
||||
filepath.Join("packs", packManifest.PackID, packManifest.ProvidersDir, providerFileName),
|
||||
})
|
||||
if err != nil {
|
||||
return PublishProviderManifestResult{}, err
|
||||
}
|
||||
|
||||
return PublishProviderManifestResult{
|
||||
RepoRoot: repoRoot,
|
||||
PackID: packManifest.PackID,
|
||||
ProviderID: manifest.ProviderID,
|
||||
ProviderPath: filepath.ToSlash(filepath.Join("packs", packManifest.PackID, packManifest.ProvidersDir, providerFileName)),
|
||||
PackVersionBefore: previousVersion,
|
||||
PackVersionAfter: nextVersion,
|
||||
PublishMode: publishMode,
|
||||
CommitMessage: commitMessage,
|
||||
CommitSHA: commitSHA,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeProviderManifest(manifest ProviderManifest) ProviderManifest {
|
||||
normalized := manifest
|
||||
normalized.ProviderID = strings.TrimSpace(normalized.ProviderID)
|
||||
normalized.DisplayName = strings.TrimSpace(normalized.DisplayName)
|
||||
normalized.BaseURL = strings.TrimSpace(normalized.BaseURL)
|
||||
normalized.Platform = strings.TrimSpace(normalized.Platform)
|
||||
normalized.AccountType = strings.TrimSpace(normalized.AccountType)
|
||||
normalized.SmokeTestModel = strings.TrimSpace(normalized.SmokeTestModel)
|
||||
if normalized.AccountType == "" {
|
||||
normalized.AccountType = "apikey"
|
||||
}
|
||||
normalized.DefaultModels = normalizeModels(normalized.DefaultModels, normalized.SmokeTestModel)
|
||||
if normalized.GroupTemplate.Name == "" {
|
||||
normalized.GroupTemplate.Name = normalized.DisplayName + " 默认分组"
|
||||
}
|
||||
if normalized.GroupTemplate.RateMultiplier == 0 {
|
||||
normalized.GroupTemplate.RateMultiplier = 1.0
|
||||
}
|
||||
if normalized.ChannelTemplate.Name == "" {
|
||||
normalized.ChannelTemplate.Name = normalized.DisplayName + " 默认渠道"
|
||||
}
|
||||
if normalized.ChannelTemplate.ModelMapping == nil {
|
||||
normalized.ChannelTemplate.ModelMapping = make(map[string]string, len(normalized.DefaultModels))
|
||||
}
|
||||
for _, model := range normalized.DefaultModels {
|
||||
if _, ok := normalized.ChannelTemplate.ModelMapping[model]; !ok {
|
||||
normalized.ChannelTemplate.ModelMapping[model] = model
|
||||
}
|
||||
}
|
||||
if normalized.PlanTemplate.Name == "" {
|
||||
normalized.PlanTemplate.Name = normalized.DisplayName + " 默认套餐"
|
||||
}
|
||||
if normalized.PlanTemplate.ValidityDays <= 0 {
|
||||
normalized.PlanTemplate.ValidityDays = 30
|
||||
}
|
||||
if normalized.PlanTemplate.ValidityUnit == "" {
|
||||
normalized.PlanTemplate.ValidityUnit = "day"
|
||||
}
|
||||
if normalized.Import == (ImportOptions{}) {
|
||||
normalized.Import = ImportOptions{
|
||||
SupportsMultiKey: true,
|
||||
SupportsStrict: true,
|
||||
SupportsPartial: true,
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeModels(models []string, smokeTestModel string) []string {
|
||||
normalized := make([]string, 0, len(models)+1)
|
||||
seen := make(map[string]struct{}, len(models)+1)
|
||||
for _, model := range models {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[model]; ok {
|
||||
continue
|
||||
}
|
||||
normalized = append(normalized, model)
|
||||
seen[model] = struct{}{}
|
||||
}
|
||||
smokeTestModel = strings.TrimSpace(smokeTestModel)
|
||||
if smokeTestModel != "" {
|
||||
if _, ok := seen[smokeTestModel]; !ok {
|
||||
normalized = append(normalized, smokeTestModel)
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func resolveRepoRoot(repoRoot string) (string, error) {
|
||||
repoRoot = strings.TrimSpace(repoRoot)
|
||||
if repoRoot == "" {
|
||||
return "", fmt.Errorf("pack repo root is not configured")
|
||||
}
|
||||
absoluteRepoRoot, err := filepath.Abs(repoRoot)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve repo root %q: %w", repoRoot, err)
|
||||
}
|
||||
info, err := os.Stat(absoluteRepoRoot)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("stat repo root %q: %w", absoluteRepoRoot, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("repo root %q is not a directory", absoluteRepoRoot)
|
||||
}
|
||||
return absoluteRepoRoot, nil
|
||||
}
|
||||
|
||||
func bumpPatchVersion(version string) (string, error) {
|
||||
parts := strings.Split(strings.TrimSpace(version), ".")
|
||||
if len(parts) != 3 {
|
||||
return "", fmt.Errorf("pack version %q must use x.y.z format", version)
|
||||
}
|
||||
patch, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse pack version %q patch: %w", version, err)
|
||||
}
|
||||
parts[2] = strconv.Itoa(patch + 1)
|
||||
return strings.Join(parts, "."), nil
|
||||
}
|
||||
|
||||
func updateChecksumFile(packDir string, checksumFile string, relativePaths []string) error {
|
||||
path := filepath.Join(packDir, checksumFile)
|
||||
entries, err := readChecksumEntries(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, relativePath := range relativePaths {
|
||||
normalizedPath := filepath.ToSlash(filepath.Clean(strings.TrimSpace(relativePath)))
|
||||
if normalizedPath == "." || normalizedPath == "" {
|
||||
continue
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(packDir, normalizedPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read checksum target %q: %w", normalizedPath, err)
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
entries[normalizedPath] = hex.EncodeToString(sum[:])
|
||||
}
|
||||
paths := make([]string, 0, len(entries))
|
||||
for relativePath := range entries {
|
||||
paths = append(paths, relativePath)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
lines := make([]string, 0, len(paths))
|
||||
for _, relativePath := range paths {
|
||||
lines = append(lines, fmt.Sprintf("%s %s", entries[relativePath], relativePath))
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||
return fmt.Errorf("write checksum file %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readChecksumEntries(path string) (map[string]string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read checksum file %q: %w", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
entries := map[string]string{}
|
||||
scanner := bufio.NewScanner(file)
|
||||
lineNumber := 0
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("checksum file %q line %d: invalid format", path, lineNumber)
|
||||
}
|
||||
entries[filepath.ToSlash(parts[1])] = parts[0]
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scan checksum file %q: %w", path, err)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func commitPackPublish(ctx context.Context, repoRoot string, message string, relativePaths []string) (string, error) {
|
||||
addArgs := append([]string{"add"}, relativePaths...)
|
||||
if _, err := runGit(ctx, repoRoot, addArgs...); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := runGit(ctx, repoRoot, "commit", "-m", message); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sha, err := runGit(ctx, repoRoot, "rev-parse", "--short", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(sha), nil
|
||||
}
|
||||
|
||||
func runGit(ctx context.Context, repoRoot string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", append([]string{"-C", repoRoot}, args...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("run git %q: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
171
internal/pack/publisher_test.go
Normal file
171
internal/pack/publisher_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package pack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPublishProviderManifestWritesProviderBumpsPackAndCommits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git is required for publish test")
|
||||
}
|
||||
|
||||
repoRoot := t.TempDir()
|
||||
packDir := filepath.Join(repoRoot, "packs", "openai-cn-pack")
|
||||
createPackFixtureAt(t, packDir, map[string]string{
|
||||
"pack.json": `{
|
||||
"pack_id": "openai-cn-pack",
|
||||
"version": "1.1.4",
|
||||
"vendor": "YourTeam",
|
||||
"target_host": "sub2api",
|
||||
"min_host_version": "0.1.126",
|
||||
"max_host_version": "0.2.x",
|
||||
"providers_dir": "providers",
|
||||
"checksum_file": "checksums.txt"
|
||||
}`,
|
||||
"providers/minimax-53hk.json": `{
|
||||
"provider_id": "minimax-53hk",
|
||||
"display_name": "MiniMax 53hk 中转兼容",
|
||||
"base_url": "https://api.53hk.cn/v1",
|
||||
"platform": "openai",
|
||||
"account_type": "apikey",
|
||||
"default_models": ["MiniMax-M2.7-highspeed"],
|
||||
"smoke_test_model": "MiniMax-M2.7-highspeed",
|
||||
"group_template": {"name": "g", "rate_multiplier": 1.0},
|
||||
"channel_template": {"name": "c", "model_mapping": {"MiniMax-M2.7-highspeed": "MiniMax-M2.7-highspeed"}},
|
||||
"plan_template": {"name": "p", "price": 1, "validity_days": 30, "validity_unit": "day"},
|
||||
"import": {"supports_multi_key": true, "supports_strict": true, "supports_partial": true}
|
||||
}`,
|
||||
})
|
||||
initGitRepo(t, repoRoot)
|
||||
|
||||
result, err := PublishProviderManifest(context.Background(), PublishProviderManifestRequest{
|
||||
RepoRoot: repoRoot,
|
||||
PackID: "openai-cn-pack",
|
||||
Manifest: ProviderManifest{
|
||||
ProviderID: "openai-zhongzhuan",
|
||||
DisplayName: "OpenAI 中转",
|
||||
BaseURL: "https://api.example.com/v1",
|
||||
Platform: "openai",
|
||||
SmokeTestModel: "gpt-5.4",
|
||||
DefaultModels: []string{"gpt-5.4", "gpt-5.4-mini"},
|
||||
},
|
||||
CommitMessage: "feat(pack): publish openai-zhongzhuan",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PublishProviderManifest() error = %v", err)
|
||||
}
|
||||
|
||||
if result.PublishMode != "created" {
|
||||
t.Fatalf("PublishMode = %q, want created", result.PublishMode)
|
||||
}
|
||||
if result.PackVersionBefore != "1.1.4" || result.PackVersionAfter != "1.1.5" {
|
||||
t.Fatalf("pack versions = %q -> %q, want 1.1.4 -> 1.1.5", result.PackVersionBefore, result.PackVersionAfter)
|
||||
}
|
||||
if strings.TrimSpace(result.CommitSHA) == "" {
|
||||
t.Fatal("CommitSHA is empty")
|
||||
}
|
||||
|
||||
publishedPack, err := LoadDir(packDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDir() after publish error = %v", err)
|
||||
}
|
||||
if publishedPack.Manifest.Version != "1.1.5" {
|
||||
t.Fatalf("published pack version = %q, want 1.1.5", publishedPack.Manifest.Version)
|
||||
}
|
||||
|
||||
body, err := readFileString(filepath.Join(packDir, "providers", "openai-zhongzhuan.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("readFileString() provider error = %v", err)
|
||||
}
|
||||
var provider ProviderManifest
|
||||
if err := json.Unmarshal([]byte(body), &provider); err != nil {
|
||||
t.Fatalf("json.Unmarshal() provider error = %v", err)
|
||||
}
|
||||
if provider.AccountType != "apikey" {
|
||||
t.Fatalf("AccountType = %q, want apikey", provider.AccountType)
|
||||
}
|
||||
if provider.ChannelTemplate.ModelMapping["gpt-5.4-mini"] != "gpt-5.4-mini" {
|
||||
t.Fatalf("ChannelTemplate.ModelMapping = %+v, want identity mapping for gpt-5.4-mini", provider.ChannelTemplate.ModelMapping)
|
||||
}
|
||||
|
||||
checksums, err := readFileString(filepath.Join(packDir, "checksums.txt"))
|
||||
if err != nil {
|
||||
t.Fatalf("readFileString() checksums error = %v", err)
|
||||
}
|
||||
if !strings.Contains(checksums, "providers/openai-zhongzhuan.json") {
|
||||
t.Fatalf("checksums.txt = %q, want providers/openai-zhongzhuan.json entry", checksums)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishProviderManifestRejectsMissingRepoRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := PublishProviderManifest(context.Background(), PublishProviderManifestRequest{
|
||||
PackID: "openai-cn-pack",
|
||||
Manifest: ProviderManifest{
|
||||
ProviderID: "deepseek",
|
||||
DisplayName: "DeepSeek",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
Platform: "openai",
|
||||
SmokeTestModel: "deepseek-chat",
|
||||
DefaultModels: []string{"deepseek-chat"},
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "repo root") {
|
||||
t.Fatalf("PublishProviderManifest() error = %v, want repo root detail", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createPackFixtureAt(t *testing.T, packDir string, files map[string]string) {
|
||||
t.Helper()
|
||||
|
||||
var lines []string
|
||||
for relativePath, content := range files {
|
||||
absolutePath := filepath.Join(packDir, relativePath)
|
||||
mustWrite(t, absolutePath, content)
|
||||
sum := sha256Sum(content)
|
||||
lines = append(lines, sum+" "+relativePath)
|
||||
}
|
||||
mustWrite(t, filepath.Join(packDir, "checksums.txt"), strings.Join(lines, "\n")+"\n")
|
||||
}
|
||||
|
||||
func sha256Sum(content string) string {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func initGitRepo(t *testing.T, repoRoot string) {
|
||||
t.Helper()
|
||||
runGitTest(t, repoRoot, "init")
|
||||
runGitTest(t, repoRoot, "config", "user.name", "Test User")
|
||||
runGitTest(t, repoRoot, "config", "user.email", "test@example.com")
|
||||
runGitTest(t, repoRoot, "add", ".")
|
||||
runGitTest(t, repoRoot, "commit", "-m", "chore: seed pack fixture")
|
||||
}
|
||||
|
||||
func runGitTest(t *testing.T, repoRoot string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", repoRoot}, args...)...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v error = %v: %s", args, err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
|
||||
func readFileString(path string) (string, error) {
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
@@ -68,7 +68,7 @@ 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_HOME_FILE" "浏览器提交到 CRM"
|
||||
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Admin"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/api/packs"
|
||||
@@ -79,10 +79,13 @@ 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" "服务端草稿"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "Provider Manifest 草稿"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/portal-admin-api"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "/publish"
|
||||
assert_contains_file "$ADMIN_PROVIDERS_FILE" "发布 Commit Message"
|
||||
|
||||
assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html"
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ func TestLoadStartupFromEnvUsesDefaultsWhenOptionalValuesMissing(t *testing.T) {
|
||||
func TestLoadStartupFromEnvAppliesOverrides(t *testing.T) {
|
||||
t.Setenv("SUB2API_CRM_LISTEN_ADDR", "127.0.0.1:9090")
|
||||
t.Setenv("SUB2API_CRM_SQLITE_DSN", "file:custom.db?_foreign_keys=on")
|
||||
t.Setenv("SUB2API_CRM_REPO_ROOT", "/srv/sub2api-cn-relay-manager")
|
||||
|
||||
cfg, err := config.LoadStartupFromEnv()
|
||||
if err != nil {
|
||||
@@ -42,6 +43,10 @@ func TestLoadStartupFromEnvAppliesOverrides(t *testing.T) {
|
||||
if cfg.Database.SQLiteDSN != "file:custom.db?_foreign_keys=on" {
|
||||
t.Fatalf("SQLiteDSN = %q, want %q", cfg.Database.SQLiteDSN, "file:custom.db?_foreign_keys=on")
|
||||
}
|
||||
|
||||
if cfg.Repository.RepoRoot != "/srv/sub2api-cn-relay-manager" {
|
||||
t.Fatalf("RepoRoot = %q, want %q", cfg.Repository.RepoRoot, "/srv/sub2api-cn-relay-manager")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAdminTokenFromEnvReturnsToken(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user