- 这部分不会直接写仓库,只生成一个可复制的 JSON 草稿,解决“新增模型页面完全缺失”的问题。
- 真正落盘仍通过 pack 仓库提交完成。
+ 这部分既能生成与保存 provider 草稿,也能从已保存草稿一键发布到 pack/provider 文件并提交到仓库。
+ 页面本身不直接拼 Git 命令,所有写仓库动作都经由 CRM 服务端完成。
+
@@ -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);
diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md
index c33c7eb3..ad9f3524 100644
--- a/docs/DEPLOYMENT.md
+++ b/docs/DEPLOYMENT.md
@@ -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/
/providers/.json`
+ - bump `pack.json` patch 版本
+ - 更新 `checksums.txt`
+ - 校验整个 pack
+ - `git add` + `git commit`
+
兼容入口:
- `https://sub.tksea.top/kimi-portal/`
diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md
index 8b042e72..f92afc72 100644
--- a/docs/EXECUTION_BOARD.md
+++ b/docs/EXECUTION_BOARD.md
@@ -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/.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/`
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index b1c717bd..4a5c2e56 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -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)))
+ }
+}
diff --git a/internal/app/coverage_helpers_test.go b/internal/app/coverage_helpers_test.go
index 0bf89fdf..d6c3e8ec 100644
--- a/internal/app/coverage_helpers_test.go
+++ b/internal/app/coverage_helpers_test.go
@@ -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)
}
}
diff --git a/internal/app/http_api.go b/internal/app/http_api.go
index d1019430..19d6b2aa 100644
--- a/internal/app/http_api.go
+++ b/internal/app/http_api.go
@@ -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
diff --git a/internal/config/config.go b/internal/config/config.go
index 1b7b7227..7fdef2e1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -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,
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index d93af95e..62ac5374 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -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")
}
diff --git a/internal/pack/publisher.go b/internal/pack/publisher.go
new file mode 100644
index 00000000..be53daa3
--- /dev/null
+++ b/internal/pack/publisher.go
@@ -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
+}
diff --git a/internal/pack/publisher_test.go b/internal/pack/publisher_test.go
new file mode 100644
index 00000000..1995677f
--- /dev/null
+++ b/internal/pack/publisher_test.go
@@ -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
+}
diff --git a/scripts/test/test_tksea_portal_assets.sh b/scripts/test/test_tksea_portal_assets.sh
index 831c12fe..e69515d2 100755
--- a/scripts/test/test_tksea_portal_assets.sh
+++ b/scripts/test/test_tksea_portal_assets.sh
@@ -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"
diff --git a/tests/integration/config_bootstrap_test.go b/tests/integration/config_bootstrap_test.go
index ea7ee74f..6d99457b 100644
--- a/tests/integration/config_bootstrap_test.go
+++ b/tests/integration/config_bootstrap_test.go
@@ -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) {