Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions internal/domain/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ type Provider struct {
// 如果配置了,在 Route 匹配时会检查前置映射后的模型是否在支持列表中
// 空数组表示支持所有模型
SupportModels []string `json:"supportModels,omitempty"`

// 为 true 时,该 provider 不参与导出/备份
ExcludeFromExport bool `json:"excludeFromExport,omitempty"`
}

type Project struct {
Expand Down
11 changes: 6 additions & 5 deletions internal/handler/admin_import_export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,10 @@ func TestAdminHandler_ProvidersImport_WithTrailingSlash(t *testing.T) {
func TestAdminHandler_ProvidersExport_WithTrailingSlash(t *testing.T) {
providerRepo := &adminTestProviderRepo{
providers: []*domain.Provider{{
ID: 1,
Name: "exported-provider",
Type: "custom",
ID: 1,
Name: "exported-provider",
Type: "custom",
ExcludeFromExport: true,
}},
}
h := newAdminHandlerForProviderImportExportTests(providerRepo)
Expand All @@ -151,7 +152,7 @@ func TestAdminHandler_ProvidersExport_WithTrailingSlash(t *testing.T) {
t.Fatalf("decode response: %v", err)
}

if len(providers) != 1 || providers[0].Name != "exported-provider" {
t.Fatalf("providers = %+v, want one exported-provider", providers)
if len(providers) != 0 {
t.Fatalf("providers = %+v, want excluded providers to be omitted", providers)
}
}
9 changes: 9 additions & 0 deletions internal/repository/sqlite/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,15 @@ var migrations = []Migration{
Down: func(db *gorm.DB) error {
return nil
},
}, {
Version: 7,
Description: "Backfill providers.exclude_from_export defaults to 0",
Up: func(db *gorm.DB) error {
return db.Exec("UPDATE providers SET exclude_from_export = 0 WHERE exclude_from_export IS NULL").Error
},
Down: func(db *gorm.DB) error {
return nil
},
},
}

Expand Down
1 change: 1 addition & 0 deletions internal/repository/sqlite/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ type Provider struct {
Config LongText
SupportedClientTypes LongText
SupportModels LongText
ExcludeFromExport int `gorm:"default:0"`
}

func (Provider) TableName() string { return "providers" }
Expand Down
2 changes: 2 additions & 0 deletions internal/repository/sqlite/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ func (r *ProviderRepository) toModel(p *domain.Provider) *Provider {
Config: LongText(toJSON(p.Config)),
SupportedClientTypes: LongText(toJSON(p.SupportedClientTypes)),
SupportModels: LongText(toJSON(p.SupportModels)),
ExcludeFromExport: boolToInt(p.ExcludeFromExport),
}
}

Expand All @@ -104,5 +105,6 @@ func (r *ProviderRepository) toDomain(m *Provider) *domain.Provider {
Config: fromJSON[*domain.ProviderConfig](string(m.Config)),
SupportedClientTypes: fromJSON[[]domain.ClientType](string(m.SupportedClientTypes)),
SupportModels: fromJSON[[]string](string(m.SupportModels)),
ExcludeFromExport: m.ExcludeFromExport != 0,
}
}
10 changes: 8 additions & 2 deletions internal/service/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,14 @@ func (s *AdminService) ExportProviders(tenantID uint64) ([]*domain.Provider, err
if err != nil {
return nil, err
}
// Return as-is, the handler will handle JSON serialization
return providers, nil
filtered := make([]*domain.Provider, 0, len(providers))
for _, provider := range providers {
if provider.ExcludeFromExport {
continue
}
filtered = append(filtered, provider)
}
return filtered, nil
}

// ImportProviders imports providers from exported data
Expand Down
3 changes: 3 additions & 0 deletions internal/service/backup.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ func (s *BackupService) Export(tenantID uint64) (*domain.BackupFile, error) {
return nil, fmt.Errorf("failed to export providers: %w", err)
}
for _, p := range providers {
if p.ExcludeFromExport {
continue
}
Comment on lines +111 to +113
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

排除 Provider 后会导出悬空的 Route/ModelMapping 引用。

Line 111-113 过滤了 provider,但后续仍导出全量 routes 与 model mappings;这些记录可能引用已被过滤的 provider,导入时会变成无效/被跳过。建议在导出 route 与 mapping 时同步按可导出 provider 过滤。

🛠 建议修复
@@
 	// 6. Export Routes
 	routes, err := s.routeRepo.List(tenantID)
@@
 	for _, r := range routes {
+		providerName, ok := providerIDToName[r.ProviderID]
+		if !ok {
+			// provider 被排除导出时,跳过其关联路由,避免悬空引用
+			continue
+		}
 		backup.Data.Routes = append(backup.Data.Routes, domain.BackupRoute{
@@
-			ProviderName:    providerIDToName[r.ProviderID],
+			ProviderName:    providerName,
 			Position:        r.Position,
 			RetryConfigName: retryConfigIDToName[r.RetryConfigID],
 		})
 	}
@@
-	for _, m := range mappings {
+mappingLoop:
+	for _, m := range mappings {
 		bm := domain.BackupModelMapping{
@@
 		if m.ProviderID != 0 {
-			bm.ProviderName = providerIDToName[m.ProviderID]
+			name, ok := providerIDToName[m.ProviderID]
+			if !ok {
+				continue mappingLoop
+			}
+			bm.ProviderName = name
 		}
@@
 		if m.RouteID != 0 {
 			// Find the route to get its composite key
 			for _, r := range routes {
 				if r.ID == m.RouteID {
+					providerName, ok := providerIDToName[r.ProviderID]
+					if !ok {
+						continue mappingLoop
+					}
 					bm.RouteName = fmt.Sprintf("%s:%s:%s",
-						providerIDToName[r.ProviderID],
+						providerName,
 						r.ClientType,
 						projectIDToSlug[r.ProjectID])
 					break
 				}
 			}
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/service/backup.go` around lines 111 - 113, 当前逻辑仅在遍历 provider 时用
p.ExcludeFromExport 跳过 Provider,但导出 routes 与 model mappings 时并未同步过滤,导致导出的
route/modelMapping 可能引用已被排除的 Provider。修改导出 routes 和 model mappings
的逻辑(例如在导出函数或变量处理处:exportRoutes、exportModelMappings 或遍历 routes/modelMappings
的代码)增加与 p.ExcludeFromExport 等价的过滤条件:构建一组可导出的 provider IDs(基于
p.ExcludeFromExport)并在导出每个 route 或 modelMapping 前检查其 providerId/related provider
是否在该集合内,若不在则跳过导出,从而避免悬空引用。

providerIDToName[p.ID] = p.Name
backup.Data.Providers = append(backup.Data.Providers, domain.BackupProvider{
Name: p.Name,
Expand Down
32 changes: 32 additions & 0 deletions tests/e2e/backup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,35 @@ func TestBackupImport_SkipStrategy(t *testing.T) {
t.Fatalf("Expected import with skip strategy to succeed, got %v", result["success"])
}
}

func TestBackupExport_ExcludedProviderIsOmitted(t *testing.T) {
env := NewTestEnv(t)

provider := map[string]any{
"name": "excluded-provider",
"type": "custom",
"excludeFromExport": true,
"config": map[string]any{
"custom": map[string]any{
"baseURL": "https://api.example.com",
"apiKey": "sk-hidden-key",
},
},
"supportedClientTypes": []string{"claude"},
}
resp := env.AdminPost("/api/admin/providers", provider)
AssertStatus(t, resp, http.StatusCreated)
resp.Body.Close()

resp = env.AdminGet("/api/admin/backup/export")
AssertStatus(t, resp, http.StatusOK)

var backup map[string]any
DecodeJSON(t, resp, &backup)

data := backup["data"].(map[string]any)
providers, _ := data["providers"].([]any)
if len(providers) != 0 {
t.Fatalf("expected excluded providers to be omitted from backup export, got %d entries", len(providers))
}
}
1 change: 1 addition & 0 deletions web/src/lib/transport/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export interface Provider {
config: ProviderConfig | null;
supportedClientTypes: ClientType[];
supportModels?: string[]; // 支持的模型列表(通配符模式),空数组表示支持所有模型
excludeFromExport?: boolean; // 为 true 时不参与导出/备份
}

// supportedClientTypes 可选,后端会根据 provider type 自动设置
Expand Down
4 changes: 3 additions & 1 deletion web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1218,7 +1218,9 @@
"endpointOverride": "Endpoint Override",
"errorCooldownTitle": "3. Error Cooldown",
"disableErrorCooldown": "Disable Error Cooldown",
"disableErrorCooldownDesc": "When enabled, errors won't trigger automatic cooldown. Manual freezes and explicit cooldown times still apply."
"disableErrorCooldownDesc": "When enabled, errors won't trigger automatic cooldown. Manual freezes and explicit cooldown times still apply.",
"excludeFromExport": "Exclude from export and backup",
"excludeFromExportDesc": "When enabled, this provider stays local to this system and will not be included in exported files or backup packages."
},
"cooldown": {
"title": "Cooldown Protection",
Expand Down
4 changes: 3 additions & 1 deletion web/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1218,7 +1218,9 @@
"endpointOverride": "端点覆盖",
"errorCooldownTitle": "3. 错误冷冻",
"disableErrorCooldown": "禁用错误冷冻",
"disableErrorCooldownDesc": "开启后,错误将不再触发自动冷冻;手动冷冻与上游明确冷冻时间仍会生效。"
"disableErrorCooldownDesc": "开启后,错误将不再触发自动冷冻;手动冷冻与上游明确冷冻时间仍会生效。",
"excludeFromExport": "从导出与备份中排除",
"excludeFromExportDesc": "开启后,该提供商仅保留在当前系统中,不会被写入导出文件或系统备份。"
},
"cooldown": {
"title": "冷却保护中",
Expand Down
40 changes: 29 additions & 11 deletions web/src/pages/providers/components/custom-config-step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function CustomConfigStep() {
},
},
supportedClientTypes,
excludeFromExport: !!formData.excludeFromExport,
};

const provider = await createProvider.mutateAsync(data);
Expand Down Expand Up @@ -224,19 +225,36 @@ export function CustomConfigStep() {
<h3 className="text-lg font-semibold text-text-primary border-b border-border pb-2">
{t('provider.errorCooldownTitle')}
</h3>
<div className="flex items-center justify-between p-4 bg-card border border-border rounded-xl">
<div className="pr-4">
<div className="text-sm font-medium text-foreground">
{t('provider.disableErrorCooldown')}
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-card border border-border rounded-xl">
<div className="pr-4">
<div className="text-sm font-medium text-foreground">
{t('provider.disableErrorCooldown')}
</div>
<p className="text-xs text-muted-foreground mt-1">
{t('provider.disableErrorCooldownDesc')}
</p>
</div>
<p className="text-xs text-muted-foreground mt-1">
{t('provider.disableErrorCooldownDesc')}
</p>
<Switch
checked={!!formData.disableErrorCooldown}
onCheckedChange={(checked) => updateFormData({ disableErrorCooldown: checked })}
/>
</div>

<div className="flex items-center justify-between p-4 bg-card border border-border rounded-xl">
<div className="pr-4">
<div className="text-sm font-medium text-foreground">
{t('provider.excludeFromExport')}
</div>
<p className="text-xs text-muted-foreground mt-1">
{t('provider.excludeFromExportDesc')}
</p>
</div>
<Switch
checked={!!formData.excludeFromExport}
onCheckedChange={(checked) => updateFormData({ excludeFromExport: checked })}
/>
</div>
<Switch
checked={!!formData.disableErrorCooldown}
onCheckedChange={(checked) => updateFormData({ disableErrorCooldown: checked })}
/>
</div>
</div>

Expand Down
2 changes: 2 additions & 0 deletions web/src/pages/providers/components/provider-edit-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) {
},
supportedClientTypes,
supportModels: formData.supportModels.length > 0 ? formData.supportModels : undefined,
excludeFromExport: !!provider.excludeFromExport,
};

await updateProvider.mutateAsync({ id: Number(provider.id), data });
Expand Down Expand Up @@ -446,6 +447,7 @@ export function ProviderEditFlow({ provider, onClose }: ProviderEditFlowProps) {
},
supportedClientTypes,
supportModels: formData.supportModels.length > 0 ? formData.supportModels : undefined,
excludeFromExport: !!provider.excludeFromExport,
};

const newProvider = await createProvider.mutateAsync(data);
Expand Down
1 change: 1 addition & 0 deletions web/src/pages/providers/context/provider-form-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const initialFormData: ProviderFormData = {
cloakStrictMode: false,
cloakSensitiveWords: '',
disableErrorCooldown: false,
excludeFromExport: false,
};

export function ProviderFormProvider({ children }: { children: ReactNode }) {
Expand Down
1 change: 1 addition & 0 deletions web/src/pages/providers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ export type ProviderFormData = {
modelMappings?: TemplateModelMapping[]; // 模型映射
logo?: string; // Logo URL
disableErrorCooldown?: boolean;
excludeFromExport?: boolean;
};

// Create step type
Expand Down
Loading