From 6b03eb8fb9e06adce571016ed8ec8171bf4c1854 Mon Sep 17 00:00:00 2001
From: phamnazage-jpg
Date: Thu, 28 May 2026 12:18:10 +0800
Subject: [PATCH] feat(admin): harden provider draft model conflicts
---
deploy/tksea-portal/admin/providers.html | 264 +++++++++++++++++++++--
docs/EXECUTION_BOARD.md | 6 +
internal/app/app_test.go | 233 ++++++++++++++++++++
internal/app/http_api.go | 222 ++++++++++++++++++-
scripts/test/test_tksea_portal_assets.sh | 5 +
5 files changed, 702 insertions(+), 28 deletions(-)
diff --git a/deploy/tksea-portal/admin/providers.html b/deploy/tksea-portal/admin/providers.html
index 71f76d4a..e98d851a 100644
--- a/deploy/tksea-portal/admin/providers.html
+++ b/deploy/tksea-portal/admin/providers.html
@@ -519,15 +519,21 @@
页面本身不直接拼 Git 命令,所有写仓库动作都经由 CRM 服务端完成。
+ 最近成功模板:暂无。首次可以先按一份已有 provider 作为参考修改。
+
-
+ Provider ID 预览:等待填写模型信息。
+ 同模型已存在:当前未发现冲突。
+
Platform
@@ -597,6 +603,15 @@
diff --git a/docs/EXECUTION_BOARD.md b/docs/EXECUTION_BOARD.md
index e9903a24..8ac2d38f 100644
--- a/docs/EXECUTION_BOARD.md
+++ b/docs/EXECUTION_BOARD.md
@@ -79,6 +79,12 @@
- `SUB2API_CRM_ADMIN_USERNAME`
- `SUB2API_CRM_ADMIN_PASSWORD`
- `SUB2API_CRM_ADMIN_SESSION_TTL`
+ - 2026-05-28 已继续把 `providers.html` 的 manifest 草稿表单收口成“按最近成功模板起步”的录入流:
+ - 草稿区首次打开且字段为空时,会优先回填最近一次成功发布的模板;没有历史时,回退到当前 pack/provider 目录里的现有 provider,最后才使用静态样例
+ - `Provider ID` 现已按 `display_name / base_url / supported_models` 自动生成,并在与现有 provider / draft 冲突时自动补后缀避重
+ - 当新填的 `supported_models` 已在现有 provider 或草稿里出现时,页面会直接提示“同模型已存在”,并优先建议复用已有 `provider_id`,避免因为“官方 / 中转”重复新增同一模型定义
+ - `GET /api/packs/{pack_id}/providers` 现已补充返回 `base_url / smoke_test_model / supported_models`,用于前端做模板参考和模型冲突提示
+ - 2026-05-28 继续把这条规则下沉到服务端:`POST /api/provider-drafts`、`PUT /api/provider-drafts/{draft_id}`、`POST /api/provider-drafts/{draft_id}/publish` 现在都会做 pack 级模型冲突校验;同模型若已被其他 provider / draft 占用,会直接返回 `409 provider_model_conflict`
- 2026-05-26 已把“最终用户 -> 公网域名 -> OpenClaw”这一跳补进正式验证口径:
- 公网根地址当前统一为 `https://sub.tksea.top`
- OpenClaw 本地 `MiniMax` 运行时故障已定位为 `pi-ai/openai-node` 未继承系统 `HTTP(S)_PROXY`,不是 allowlist 或模型名大小写问题
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
index b74cf40b..5961c99e 100644
--- a/internal/app/app_test.go
+++ b/internal/app/app_test.go
@@ -545,6 +545,51 @@ func TestAPIProviderAccessStatusReturnsSummary(t *testing.T) {
}
}
+func TestAPIListPackProvidersReturnsProviderMetadata(t *testing.T) {
+ handler := NewAPIHandler("secret-token", ActionSet{
+ ListPackProviders: func(_ context.Context, packID string) ([]PackProviderInfo, error) {
+ if packID != "openai-cn-pack" {
+ t.Fatalf("packID = %q, want openai-cn-pack", packID)
+ }
+ return []PackProviderInfo{{
+ ProviderID: "deepseek-chat-official",
+ DisplayName: "DeepSeek Official",
+ Platform: "openai",
+ HostOverlays: 0,
+ BaseURL: "https://api.deepseek.com",
+ SmokeTestModel: "deepseek-chat",
+ SupportedModels: []string{"deepseek-chat", "deepseek-reasoner"},
+ }}, nil
+ },
+ })
+ request := httptestRequest(t, http.MethodGet, "/api/packs/openai-cn-pack/providers", nil, "secret-token")
+ response := httptestRecorder(handler, request)
+ assertStatusCode(t, response, http.StatusOK)
+
+ var payload struct {
+ Providers []PackProviderInfo `json:"providers"`
+ }
+ if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
+ t.Fatalf("json.Unmarshal() error = %v", err)
+ }
+ if len(payload.Providers) != 1 {
+ t.Fatalf("providers len = %d, want 1", len(payload.Providers))
+ }
+ got := payload.Providers[0]
+ if got.ProviderID != "deepseek-chat-official" {
+ t.Fatalf("provider_id = %q, want deepseek-chat-official", got.ProviderID)
+ }
+ if got.BaseURL != "https://api.deepseek.com" {
+ t.Fatalf("base_url = %q, want https://api.deepseek.com", got.BaseURL)
+ }
+ if got.SmokeTestModel != "deepseek-chat" {
+ t.Fatalf("smoke_test_model = %q, want deepseek-chat", got.SmokeTestModel)
+ }
+ if len(got.SupportedModels) != 2 || got.SupportedModels[0] != "deepseek-chat" || got.SupportedModels[1] != "deepseek-reasoner" {
+ t.Fatalf("supported_models = %#v, want [deepseek-chat deepseek-reasoner]", got.SupportedModels)
+ }
+}
+
func TestAPIProviderResourcesReturnsSummary(t *testing.T) {
handler := NewAPIHandler("secret-token", ActionSet{
GetProviderResources: func(_ context.Context, req ProviderQueryRequest) (provision.ProviderSnapshot, error) {
@@ -1571,6 +1616,194 @@ func TestNewActionSetPackErrorPaths(t *testing.T) {
})
}
+func TestNewActionSetCreateProviderDraftRejectsModelConflictWithStoredProvider(t *testing.T) {
+ ctx := context.Background()
+ store, err := sqlite.Open(ctx, "file:"+filepath.Join(t.TempDir(), "create-draft-conflict.db")+"?_foreign_keys=on&_busy_timeout=5000")
+ if err != nil {
+ t.Fatalf("sqlite.Open() error = %v", err)
+ }
+ defer store.Close()
+
+ packID, err := store.Packs().Create(ctx, sqlite.Pack{
+ PackID: "openai-cn-pack",
+ Version: "1.1.4",
+ Checksum: "chk-openai-cn-pack",
+ Vendor: "YourTeam",
+ TargetHost: "sub2api",
+ ManifestJSON: `{"pack_id":"openai-cn-pack","version":"1.1.4","target_host":"sub2api"}`,
+ })
+ if err != nil {
+ t.Fatalf("Packs().Create() error = %v", err)
+ }
+ if _, err := store.Providers().Create(ctx, sqlite.Provider{
+ PackID: packID,
+ ProviderID: "deepseek-chat-official",
+ DisplayName: "DeepSeek Official",
+ BaseURL: "https://api.deepseek.com",
+ Platform: "openai",
+ AccountType: "apikey",
+ DefaultModelsJSON: `["deepseek-chat"]`,
+ SmokeTestModel: "deepseek-chat",
+ ManifestJSON: `{"provider_id":"deepseek-chat-official","display_name":"DeepSeek Official","base_url":"https://api.deepseek.com","platform":"openai","default_models":["deepseek-chat"],"smoke_test_model":"deepseek-chat"}`,
+ }); err != nil {
+ t.Fatalf("Providers().Create() error = %v", err)
+ }
+
+ actions := NewActionSet(appTestDSN(t, store))
+ _, err = actions.CreateProviderDraft(ctx, CreateProviderDraftRequest{
+ PackID: "openai-cn-pack",
+ ProviderID: "deepseek-chat-relay",
+ DisplayName: "DeepSeek Relay",
+ Platform: "openai",
+ BaseURL: "https://relay.example.com/v1",
+ SmokeTestModel: "deepseek-chat",
+ SupportedModels: []string{"deepseek-chat"},
+ })
+ if err == nil {
+ t.Fatal("CreateProviderDraft() error = nil, want provider model conflict")
+ }
+ var httpErr *httpError
+ if !errors.As(err, &httpErr) {
+ t.Fatalf("CreateProviderDraft() error = %T, want *httpError", err)
+ }
+ if httpErr.StatusCode != http.StatusConflict || httpErr.Code != "provider_model_conflict" {
+ t.Fatalf("CreateProviderDraft() = %#v, want 409/provider_model_conflict", httpErr)
+ }
+}
+
+func TestNewActionSetUpdateProviderDraftRejectsModelConflictWithOtherDraft(t *testing.T) {
+ ctx := context.Background()
+ store, err := sqlite.Open(ctx, "file:"+filepath.Join(t.TempDir(), "update-draft-conflict.db")+"?_foreign_keys=on&_busy_timeout=5000")
+ if err != nil {
+ t.Fatalf("sqlite.Open() error = %v", err)
+ }
+ defer store.Close()
+
+ if _, err := store.ProviderDrafts().Create(ctx, sqlite.ProviderDraft{
+ DraftID: "draft_existing",
+ PackID: "openai-cn-pack",
+ ProviderID: "deepseek-chat-official",
+ DisplayName: "DeepSeek Official",
+ Platform: "openai",
+ BaseURL: "https://api.deepseek.com",
+ SmokeTestModel: "deepseek-chat",
+ SupportedModelsJSON: `["deepseek-chat"]`,
+ ManifestJSON: `{"provider_id":"deepseek-chat-official","display_name":"DeepSeek Official","platform":"openai","base_url":"https://api.deepseek.com","smoke_test_model":"deepseek-chat"}`,
+ }); err != nil {
+ t.Fatalf("ProviderDrafts().Create(existing) error = %v", err)
+ }
+ if _, err := store.ProviderDrafts().Create(ctx, sqlite.ProviderDraft{
+ DraftID: "draft_target",
+ PackID: "openai-cn-pack",
+ ProviderID: "deepseek-chat-relay",
+ DisplayName: "DeepSeek Relay",
+ Platform: "openai",
+ BaseURL: "https://relay.example.com/v1",
+ SmokeTestModel: "deepseek-reasoner",
+ SupportedModelsJSON: `["deepseek-reasoner"]`,
+ ManifestJSON: `{"provider_id":"deepseek-chat-relay","display_name":"DeepSeek Relay","platform":"openai","base_url":"https://relay.example.com/v1","smoke_test_model":"deepseek-reasoner"}`,
+ }); err != nil {
+ t.Fatalf("ProviderDrafts().Create(target) error = %v", err)
+ }
+
+ actions := NewActionSet(appTestDSN(t, store))
+ _, err = actions.UpdateProviderDraft(ctx, UpdateProviderDraftRequest{
+ DraftID: "draft_target",
+ CreateProviderDraftRequest: CreateProviderDraftRequest{
+ PackID: "openai-cn-pack",
+ ProviderID: "deepseek-chat-relay",
+ DisplayName: "DeepSeek Relay",
+ Platform: "openai",
+ BaseURL: "https://relay.example.com/v1",
+ SmokeTestModel: "deepseek-chat",
+ SupportedModels: []string{"deepseek-chat"},
+ },
+ })
+ if err == nil {
+ t.Fatal("UpdateProviderDraft() error = nil, want provider model conflict")
+ }
+ var httpErr *httpError
+ if !errors.As(err, &httpErr) {
+ t.Fatalf("UpdateProviderDraft() error = %T, want *httpError", err)
+ }
+ if httpErr.StatusCode != http.StatusConflict || httpErr.Code != "provider_model_conflict" {
+ t.Fatalf("UpdateProviderDraft() = %#v, want 409/provider_model_conflict", httpErr)
+ }
+}
+
+func TestNewActionSetPublishProviderDraftRejectsModelConflictWithRepoProvider(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-draft-conflict.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-chat-official.json": `{
+ "provider_id": "deepseek-chat-official",
+ "display_name": "DeepSeek Official",
+ "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_conflict",
+ PackID: "openai-cn-pack",
+ ProviderID: "deepseek-chat-relay",
+ DisplayName: "DeepSeek Relay",
+ Platform: "openai",
+ BaseURL: "https://relay.example.com/v1",
+ SmokeTestModel: "deepseek-chat",
+ SupportedModelsJSON: `["deepseek-chat"]`,
+ ManifestJSON: `{"provider_id":"deepseek-chat-relay","display_name":"DeepSeek Relay","platform":"openai","base_url":"https://relay.example.com/v1","smoke_test_model":"deepseek-chat"}`,
+ }); err != nil {
+ t.Fatalf("ProviderDrafts().Create() error = %v", err)
+ }
+
+ t.Setenv("SUB2API_CRM_REPO_ROOT", repoRoot)
+ actions := NewActionSet(appTestDSN(t, store))
+ _, err = actions.PublishProviderDraft(ctx, PublishProviderDraftRequest{
+ DraftID: "draft_publish_conflict",
+ CommitMessage: "feat(pack): publish provider draft deepseek-chat-relay",
+ })
+ if err == nil {
+ t.Fatal("PublishProviderDraft() error = nil, want provider model conflict")
+ }
+ var httpErr *httpError
+ if !errors.As(err, &httpErr) {
+ t.Fatalf("PublishProviderDraft() error = %T, want *httpError", err)
+ }
+ if httpErr.StatusCode != http.StatusConflict || httpErr.Code != "provider_model_conflict" {
+ t.Fatalf("PublishProviderDraft() = %#v, want 409/provider_model_conflict", httpErr)
+ }
+}
+
func TestNewActionSetPublishProviderDraftCreatesPackCommit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is required for publish action test")
diff --git a/internal/app/http_api.go b/internal/app/http_api.go
index ef1a199b..43ffb67c 100644
--- a/internal/app/http_api.go
+++ b/internal/app/http_api.go
@@ -2,11 +2,14 @@ package app
import (
"context"
+ "database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
+ "os"
+ "path/filepath"
"strconv"
"strings"
"time"
@@ -100,10 +103,13 @@ type PackInfo struct {
}
type PackProviderInfo struct {
- ProviderID string `json:"provider_id"`
- DisplayName string `json:"display_name"`
- Platform string `json:"platform,omitempty"`
- HostOverlays int `json:"host_overlays,omitempty"`
+ ProviderID string `json:"provider_id"`
+ DisplayName string `json:"display_name"`
+ Platform string `json:"platform,omitempty"`
+ HostOverlays int `json:"host_overlays,omitempty"`
+ BaseURL string `json:"base_url,omitempty"`
+ SmokeTestModel string `json:"smoke_test_model,omitempty"`
+ SupportedModels []string `json:"supported_models,omitempty"`
}
type CreateProviderDraftRequest struct {
@@ -1131,6 +1137,9 @@ func NewActionSet(sqliteDSN string) ActionSet {
if err != nil {
return ProviderDraftInfo{}, err
}
+ if err := validateProviderDraftModelConflicts(ctx, store, strings.TrimSpace(req.PackID), strings.TrimSpace(req.ProviderID), "", supportedModels, strings.TrimSpace(req.SmokeTestModel)); err != nil {
+ return ProviderDraftInfo{}, err
+ }
draftRow := sqlite.ProviderDraft{
DraftID: draftID,
@@ -1203,6 +1212,9 @@ func NewActionSet(sqliteDSN string) ActionSet {
if err != nil {
return ProviderDraftInfo{}, err
}
+ if err := validateProviderDraftModelConflicts(ctx, store, strings.TrimSpace(req.PackID), strings.TrimSpace(req.ProviderID), strings.TrimSpace(req.DraftID), supportedModels, strings.TrimSpace(req.SmokeTestModel)); err != nil {
+ return ProviderDraftInfo{}, err
+ }
if err := store.ProviderDrafts().UpdateByDraftID(ctx, sqlite.ProviderDraft{
DraftID: strings.TrimSpace(req.DraftID),
@@ -1256,6 +1268,9 @@ func NewActionSet(sqliteDSN string) ActionSet {
if err != nil {
return PublishProviderDraftResult{}, err
}
+ if err := validateProviderDraftModelConflicts(ctx, store, strings.TrimSpace(row.PackID), strings.TrimSpace(manifest.ProviderID), strings.TrimSpace(row.DraftID), manifest.DefaultModels, strings.TrimSpace(manifest.SmokeTestModel)); err != nil {
+ return PublishProviderDraftResult{}, err
+ }
publishResult, err := pack.PublishProviderManifest(ctx, pack.PublishProviderManifestRequest{
RepoRoot: startupCfg.Repository.RepoRoot,
PackID: row.PackID,
@@ -1698,18 +1713,27 @@ func NewActionSet(sqliteDSN string) ActionSet {
result := make([]PackProviderInfo, 0, len(providers))
for _, p := range providers {
hostOverlays := 0
+ baseURL := ""
+ smokeTestModel := ""
+ supportedModels := []string{}
if strings.TrimSpace(p.ManifestJSON) != "" {
var providerManifest pack.ProviderManifest
if err := json.Unmarshal([]byte(p.ManifestJSON), &providerManifest); err != nil {
return nil, fmt.Errorf("decode stored provider manifest: %w", err)
}
hostOverlays = len(providerManifest.HostOverlays)
+ baseURL = strings.TrimSpace(providerManifest.BaseURL)
+ smokeTestModel = strings.TrimSpace(providerManifest.SmokeTestModel)
+ supportedModels = append([]string(nil), providerManifest.DefaultModels...)
}
result = append(result, PackProviderInfo{
- ProviderID: p.ProviderID,
- DisplayName: p.DisplayName,
- Platform: p.Platform,
- HostOverlays: hostOverlays,
+ ProviderID: p.ProviderID,
+ DisplayName: p.DisplayName,
+ Platform: p.Platform,
+ HostOverlays: hostOverlays,
+ BaseURL: baseURL,
+ SmokeTestModel: smokeTestModel,
+ SupportedModels: supportedModels,
})
}
return result, nil
@@ -2016,6 +2040,149 @@ func packRecordToInfo(pack sqlite.Pack) PackInfo {
}
}
+type providerModelOwner struct {
+ ProviderID string
+ DisplayName string
+ Models []string
+ Source string
+ DraftID string
+}
+
+func validateProviderDraftModelConflicts(ctx context.Context, store *sqlite.DB, packID, providerID, draftID string, supportedModels []string, smokeTestModel string) error {
+ packID = strings.TrimSpace(packID)
+ providerID = strings.TrimSpace(providerID)
+ draftID = strings.TrimSpace(draftID)
+ targetModels := normalizeProviderModels(supportedModels, smokeTestModel)
+ if packID == "" || providerID == "" || len(targetModels) == 0 {
+ return nil
+ }
+
+ owners, err := collectProviderModelOwners(ctx, store, packID)
+ if err != nil {
+ return err
+ }
+
+ conflicts := make([]string, 0)
+ seen := make(map[string]struct{})
+ for _, owner := range owners {
+ if owner.ProviderID == "" || owner.ProviderID == providerID {
+ continue
+ }
+ if owner.DraftID != "" && owner.DraftID == draftID {
+ continue
+ }
+ matched := intersectNormalizedModels(targetModels, owner.Models)
+ if len(matched) == 0 {
+ continue
+ }
+ label := fmt.Sprintf("%s -> %s[%s]", strings.Join(matched, "/"), owner.ProviderID, owner.Source)
+ if _, ok := seen[label]; ok {
+ continue
+ }
+ seen[label] = struct{}{}
+ conflicts = append(conflicts, label)
+ }
+ if len(conflicts) == 0 {
+ return nil
+ }
+ return &httpError{
+ StatusCode: http.StatusConflict,
+ Code: "provider_model_conflict",
+ Message: fmt.Sprintf("provider model conflict in pack %q: %s", packID, strings.Join(conflicts, "; ")),
+ }
+}
+
+func collectProviderModelOwners(ctx context.Context, store *sqlite.DB, packID string) ([]providerModelOwner, error) {
+ owners := make([]providerModelOwner, 0)
+
+ repoOwners, err := loadRepoPackModelOwners(packID)
+ if err != nil {
+ return nil, err
+ }
+ if len(repoOwners) > 0 {
+ owners = append(owners, repoOwners...)
+ } else {
+ storedOwners, err := loadStoredPackModelOwners(ctx, store, packID)
+ if err != nil {
+ return nil, err
+ }
+ owners = append(owners, storedOwners...)
+ }
+
+ draftRows, err := store.ProviderDrafts().List(ctx, sqlite.ListProviderDraftsFilter{PackID: packID})
+ if err != nil {
+ return nil, err
+ }
+ for _, row := range draftRows {
+ owners = append(owners, providerModelOwner{
+ ProviderID: strings.TrimSpace(row.ProviderID),
+ DisplayName: strings.TrimSpace(row.DisplayName),
+ Models: normalizeProviderModels(decodeStringList(row.SupportedModelsJSON), row.SmokeTestModel),
+ Source: "draft",
+ DraftID: strings.TrimSpace(row.DraftID),
+ })
+ }
+
+ return owners, nil
+}
+
+func loadRepoPackModelOwners(packID string) ([]providerModelOwner, error) {
+ startupCfg, err := config.LoadStartupFromEnv()
+ if err != nil {
+ return nil, err
+ }
+ repoRoot := strings.TrimSpace(startupCfg.Repository.RepoRoot)
+ if repoRoot == "" {
+ return nil, nil
+ }
+ packDir := filepath.Join(repoRoot, "packs", strings.TrimSpace(packID))
+ if _, err := os.Stat(packDir); err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("stat repo pack dir %q: %w", packDir, err)
+ }
+ loadedPack, err := pack.LoadPath(packDir)
+ if err != nil {
+ return nil, fmt.Errorf("load repo pack %q for conflict check: %w", packID, err)
+ }
+
+ owners := make([]providerModelOwner, 0, len(loadedPack.Providers))
+ for _, provider := range loadedPack.Providers {
+ owners = append(owners, providerModelOwner{
+ ProviderID: strings.TrimSpace(provider.ProviderID),
+ DisplayName: strings.TrimSpace(provider.DisplayName),
+ Models: normalizeProviderModels(provider.DefaultModels, provider.SmokeTestModel),
+ Source: "repo",
+ })
+ }
+ return owners, nil
+}
+
+func loadStoredPackModelOwners(ctx context.Context, store *sqlite.DB, packID string) ([]providerModelOwner, error) {
+ packRow, err := store.Packs().GetByPackID(ctx, packID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ providerRows, err := store.Providers().ListByPackID(ctx, packRow.ID)
+ if err != nil {
+ return nil, err
+ }
+ owners := make([]providerModelOwner, 0, len(providerRows))
+ for _, row := range providerRows {
+ owners = append(owners, providerModelOwner{
+ ProviderID: strings.TrimSpace(row.ProviderID),
+ DisplayName: strings.TrimSpace(row.DisplayName),
+ Models: normalizeProviderModels(decodeStringList(row.DefaultModelsJSON), row.SmokeTestModel),
+ Source: "store",
+ })
+ }
+ return owners, nil
+}
+
func normalizeProviderDraftPayload(req CreateProviderDraftRequest) (string, any, []string, error) {
supportedModels := normalizeStringList(req.SupportedModels)
if len(req.Manifest) > 0 {
@@ -2171,6 +2338,45 @@ func encodeStringList(values []string) string {
return string(encoded)
}
+func normalizeProviderModels(values []string, smokeTestModel string) []string {
+ normalized := normalizeStringList(values)
+ if len(normalized) == 0 {
+ if smoke := strings.TrimSpace(smokeTestModel); smoke != "" {
+ normalized = []string{smoke}
+ }
+ }
+ if len(normalized) == 0 {
+ return nil
+ }
+ seen := make(map[string]struct{}, len(normalized))
+ result := make([]string, 0, len(normalized))
+ for _, value := range normalized {
+ if _, ok := seen[value]; ok {
+ continue
+ }
+ seen[value] = struct{}{}
+ result = append(result, value)
+ }
+ return result
+}
+
+func intersectNormalizedModels(left, right []string) []string {
+ if len(left) == 0 || len(right) == 0 {
+ return nil
+ }
+ rightSet := make(map[string]struct{}, len(right))
+ for _, value := range right {
+ rightSet[value] = struct{}{}
+ }
+ matched := make([]string, 0)
+ for _, value := range left {
+ if _, ok := rightSet[value]; ok {
+ matched = append(matched, value)
+ }
+ }
+ return matched
+}
+
func normalizeStringList(values []string) []string {
normalized := make([]string, 0, len(values))
for _, value := range values {
diff --git a/scripts/test/test_tksea_portal_assets.sh b/scripts/test/test_tksea_portal_assets.sh
index 4f2747b0..5656fc23 100755
--- a/scripts/test/test_tksea_portal_assets.sh
+++ b/scripts/test/test_tksea_portal_assets.sh
@@ -91,6 +91,11 @@ 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_PROVIDERS_FILE" "credentials: \"include\""
+assert_contains_file "$ADMIN_PROVIDERS_FILE" "最近成功模板"
+assert_contains_file "$ADMIN_PROVIDERS_FILE" "根据 display name / base url / models 自动生成"
+assert_contains_file "$ADMIN_PROVIDERS_FILE" "同模型已存在"
+assert_contains_file "$ADMIN_PROVIDERS_FILE" "providerIdPreview"
+assert_contains_file "$ADMIN_PROVIDERS_FILE" "modelConflicts"
assert_contains_file "$ADMIN_BATCH_FILE" "/portal/admin-batch-import.html"
assert_contains_file "$ADMIN_HTML_FILE" "管理员登录"