feat(admin): publish provider drafts into pack repo

This commit is contained in:
phamnazage-jpg
2026-05-28 07:30:02 +08:00
parent 8d7aa925df
commit 3a00f1b859
14 changed files with 948 additions and 16 deletions

View File

@@ -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`

View File

@@ -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>

View File

@@ -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("&", "&amp;")
@@ -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);

View File

@@ -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/`

View File

@@ -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/`

View File

@@ -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)))
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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
View 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
}

View 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
}

View File

@@ -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"

View File

@@ -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) {