diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55669194f..87b88d063 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## Unreleased
+- 管理后台“功能开放”新增自定义渠道开关,关闭后保留用户已有配置,但隐藏配置入口、过滤自定义模型,并拦截模型目录拉取、渠道中转与新的异步生成任务。
- 阿里云 OSS 与腾讯云 COS 的平台、个人存储支持配置可选 CDN 加速域名,上传继续使用 Endpoint,下载与预览在配置后改用 CDN 域名。
## v1.0.57
diff --git a/backend/internal/handler/channel_models.go b/backend/internal/handler/channel_models.go
index 802f77ec3..d7388d5ae 100644
--- a/backend/internal/handler/channel_models.go
+++ b/backend/internal/handler/channel_models.go
@@ -17,6 +17,10 @@ func RegisterChannelModelRoutes(r *gin.RouterGroup, svc *service.Service) {
failService(c, err)
return
}
+ if err := svc.RequireFeature(service.FeatureCustomChannels); err != nil {
+ failService(c, err)
+ return
+ }
if !enforceRateLimit(c, "channel-models:"+user.ID, 30, time.Minute) {
return
}
diff --git a/backend/internal/handler/custom_proxy.go b/backend/internal/handler/custom_proxy.go
index 07384e789..be1499337 100644
--- a/backend/internal/handler/custom_proxy.go
+++ b/backend/internal/handler/custom_proxy.go
@@ -27,6 +27,10 @@ func RegisterCustomRelayRoutes(r *gin.RouterGroup, svc *service.Service) {
failService(c, err)
return
}
+ if err := svc.RequireFeature(service.FeatureCustomChannels); err != nil {
+ failService(c, err)
+ return
+ }
policy, available := loadRuntimePolicy(c, svc)
if !available || !enforceRateLimit(c, "custom-relay:"+user.ID, policy.Request.CustomRelayPerMinute, time.Minute) {
return
diff --git a/backend/internal/handler/feature_availability.go b/backend/internal/handler/feature_availability.go
index 5fdfa5269..7a0229c3d 100644
--- a/backend/internal/handler/feature_availability.go
+++ b/backend/internal/handler/feature_availability.go
@@ -42,8 +42,13 @@ func RegisterFeatureAvailabilityRoutes(r *gin.RouterGroup, svc *service.Service)
failService(c, err)
return
}
+ current, err := svc.AdminFeatureAvailability(user)
+ if err != nil {
+ failService(c, err)
+ return
+ }
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10)
- var req service.FeatureAvailability
+ req := current.FeatureAvailability
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
return
diff --git a/backend/internal/service/desktop_local_channel_test.go b/backend/internal/service/desktop_local_channel_test.go
index 80394071e..f99174d09 100644
--- a/backend/internal/service/desktop_local_channel_test.go
+++ b/backend/internal/service/desktop_local_channel_test.go
@@ -67,7 +67,7 @@ func TestRuntimeFeatureAvailabilityExposesDesktopCapabilityReadOnly(t *testing.T
if !features.DesktopLocalChannelsEnabled {
t.Fatal("public feature payload must expose the server-held desktop capability")
}
- if writable := defaultFeatureAvailability(); writable.ShortDramaEnabled != features.ShortDramaEnabled || writable.TaskCenterEnabled != features.TaskCenterEnabled || writable.CreditsEnabled != features.CreditsEnabled {
+ if writable := defaultFeatureAvailability(); writable.ShortDramaEnabled != features.ShortDramaEnabled || writable.TaskCenterEnabled != features.TaskCenterEnabled || writable.CreditsEnabled != features.CreditsEnabled || writable.CustomChannelsEnabled != features.CustomChannelsEnabled {
t.Fatal("runtime capability must not alter writable feature availability")
}
}
diff --git a/backend/internal/service/feature_availability.go b/backend/internal/service/feature_availability.go
index 48bfa89f4..889898b65 100644
--- a/backend/internal/service/feature_availability.go
+++ b/backend/internal/service/feature_availability.go
@@ -14,15 +14,17 @@ import (
const featureAvailabilitySettingKey = "feature_availability"
const (
- FeatureShortDrama = "shortDrama"
- FeatureTaskCenter = "taskCenter"
- FeatureCredits = "credits"
+ FeatureShortDrama = "shortDrama"
+ FeatureTaskCenter = "taskCenter"
+ FeatureCredits = "credits"
+ FeatureCustomChannels = "customChannels"
)
type FeatureAvailability struct {
- ShortDramaEnabled bool `json:"shortDramaEnabled"`
- TaskCenterEnabled bool `json:"taskCenterEnabled"`
- CreditsEnabled bool `json:"creditsEnabled"`
+ ShortDramaEnabled bool `json:"shortDramaEnabled"`
+ TaskCenterEnabled bool `json:"taskCenterEnabled"`
+ CreditsEnabled bool `json:"creditsEnabled"`
+ CustomChannelsEnabled bool `json:"customChannelsEnabled"`
}
type PublicFeatureAvailability struct {
@@ -35,7 +37,7 @@ type PublicFeatureAvailability struct {
func defaultFeatureAvailability() FeatureAvailability {
// 缺少配置代表尚未由运维接管,默认保持现有功能全部开放。
- return FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true}
+ return FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true, CustomChannelsEnabled: true}
}
func (s *Service) FeatureAvailability() (*PublicFeatureAvailability, error) {
@@ -90,6 +92,8 @@ func (s *Service) FeatureEnabled(feature string) (bool, error) {
return value.TaskCenterEnabled, nil
case FeatureCredits:
return value.CreditsEnabled, nil
+ case FeatureCustomChannels:
+ return value.CustomChannelsEnabled, nil
default:
return false, errors.New("未知功能开放配置")
}
@@ -110,6 +114,8 @@ func (s *Service) RequireFeature(feature string) error {
return Forbidden("任务中心暂未开放")
case FeatureCredits:
return Forbidden("积分功能暂未开放")
+ case FeatureCustomChannels:
+ return Forbidden("自定义渠道暂未开放")
default:
return Forbidden("该功能暂未开放")
}
@@ -123,7 +129,8 @@ func (s *Service) readFeatureAvailability() (*model.SystemSetting, FeatureAvaila
if err != nil {
return nil, FeatureAvailability{}, err
}
- value := FeatureAvailability{}
+ // 以全开放默认值为基底,避免已有三字段配置在升级后因缺少新字段而意外关闭功能。
+ value := defaultFeatureAvailability()
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
return nil, FeatureAvailability{}, errors.New("功能开放配置格式无效")
}
diff --git a/backend/internal/service/feature_availability_test.go b/backend/internal/service/feature_availability_test.go
index 97f8932e9..ac2645347 100644
--- a/backend/internal/service/feature_availability_test.go
+++ b/backend/internal/service/feature_availability_test.go
@@ -18,7 +18,23 @@ func TestFeatureAvailabilityDefaultsToEnabled(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if setting.Configured || !setting.ShortDramaEnabled || !setting.TaskCenterEnabled || !setting.CreditsEnabled {
+ if setting.Configured || !setting.ShortDramaEnabled || !setting.TaskCenterEnabled || !setting.CreditsEnabled || !setting.CustomChannelsEnabled {
+ t.Fatalf("FeatureAvailability() = %#v", setting)
+ }
+}
+
+func TestFeatureAvailabilityLegacySettingKeepsCustomChannelsEnabled(t *testing.T) {
+ svc, db := newFeatureAvailabilityTestService(t)
+ legacy := &model.SystemSetting{Key: featureAvailabilitySettingKey, ValueJSON: `{"shortDramaEnabled":false,"taskCenterEnabled":true,"creditsEnabled":true}`}
+ if err := db.Create(legacy).Error; err != nil {
+ t.Fatal(err)
+ }
+
+ setting, err := svc.FeatureAvailability()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if setting.ShortDramaEnabled || !setting.CustomChannelsEnabled {
t.Fatalf("FeatureAvailability() = %#v", setting)
}
}
@@ -26,13 +42,13 @@ func TestFeatureAvailabilityDefaultsToEnabled(t *testing.T) {
func TestUpdateFeatureAvailabilityPersistsAndAudits(t *testing.T) {
svc, db := newFeatureAvailabilityTestService(t)
actor := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
- want := FeatureAvailability{ShortDramaEnabled: false, TaskCenterEnabled: true, CreditsEnabled: false}
+ want := FeatureAvailability{ShortDramaEnabled: false, TaskCenterEnabled: true, CreditsEnabled: false, CustomChannelsEnabled: true}
setting, err := svc.UpdateFeatureAvailability(actor, want)
if err != nil {
t.Fatal(err)
}
- if !setting.Configured || setting.ShortDramaEnabled || !setting.TaskCenterEnabled || setting.CreditsEnabled {
+ if !setting.Configured || setting.ShortDramaEnabled || !setting.TaskCenterEnabled || setting.CreditsEnabled || !setting.CustomChannelsEnabled {
t.Fatalf("UpdateFeatureAvailability() = %#v", setting)
}
if err := svc.RequireFeature(FeatureShortDrama); err == nil {
@@ -52,10 +68,33 @@ func TestUpdateFeatureAvailabilityPersistsAndAudits(t *testing.T) {
}
}
+func TestCustomChannelTaskInputRequiresFeature(t *testing.T) {
+ svc, _ := newFeatureAvailabilityTestService(t)
+ actor := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
+ if _, err := svc.UpdateFeatureAvailability(actor, FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true, CustomChannelsEnabled: false}); err != nil {
+ t.Fatal(err)
+ }
+
+ customInput, err := normalizeTaskInput(map[string]any{"config": providerConfig{BaseURL: "https://example.com", APIKey: "private-key", Model: "text-model"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.requireCustomChannelsForTaskInput(customInput); err == nil {
+ t.Fatal("custom channel task must be rejected when the feature is disabled")
+ }
+ systemInput, err := normalizeTaskInput(map[string]any{"config": providerConfig{ChannelID: "system-1", BaseURL: "/api/ai/system/system-1", APIKey: "system", Model: "text-model"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.requireCustomChannelsForTaskInput(systemInput); err != nil {
+ t.Fatalf("system channel task error = %v", err)
+ }
+}
+
func TestTaskBillingOrderSkipsPricingWhenCreditsDisabled(t *testing.T) {
svc, _ := newFeatureAvailabilityTestService(t)
actor := &model.User{ID: "admin-1", Role: model.UserRoleAdmin}
- if _, err := svc.UpdateFeatureAvailability(actor, FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: false}); err != nil {
+ if _, err := svc.UpdateFeatureAvailability(actor, FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: false, CustomChannelsEnabled: true}); err != nil {
t.Fatal(err)
}
diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go
index 286a159b0..c29e98db5 100644
--- a/backend/internal/service/service.go
+++ b/backend/internal/service/service.go
@@ -327,6 +327,9 @@ func (s *Service) CreateTask(userID string, req CreateTaskRequest) (*model.Task,
if err != nil {
return nil, err
}
+ if err := s.requireCustomChannelsForTaskInput(normalizedInput); err != nil {
+ return nil, err
+ }
if err := s.ValidateTaskCapability(normalizedInput); err != nil {
return nil, err
}
@@ -398,6 +401,24 @@ func normalizeTaskInput(input map[string]any) (map[string]any, error) {
return normalized, nil
}
+func (s *Service) requireCustomChannelsForTaskInput(input map[string]any) error {
+ if !taskInputUsesCustomChannel(input) {
+ return nil
+ }
+ return s.RequireFeature(FeatureCustomChannels)
+}
+
+func taskInputUsesCustomChannel(input map[string]any) bool {
+ config, ok := input["config"].(map[string]any)
+ if !ok {
+ return false
+ }
+ channelID, _ := config["channelId"].(string)
+ baseURL, _ := config["baseUrl"].(string)
+ apiKey, _ := config["apiKey"].(string)
+ return strings.TrimSpace(channelID) == "" && strings.TrimSpace(baseURL) != "" && strings.TrimSpace(apiKey) != ""
+}
+
func compactPersistedValue(value interface{}) interface{} {
switch item := value.(type) {
case map[string]interface{}:
@@ -507,6 +528,9 @@ func (s *Service) RetryTask(userID string, id string) (*model.Task, error) {
if err := json.Unmarshal([]byte(decryptedInput), &billingInput); err != nil {
return nil, err
}
+ if err := s.requireCustomChannelsForTaskInput(billingInput); err != nil {
+ return nil, err
+ }
billingOrder, err := s.taskBillingOrder(userID, task, billingInput)
if err != nil {
return nil, err
diff --git a/web/src/pages/admin/admin-route-pages.tsx b/web/src/pages/admin/admin-route-pages.tsx
index cd02c4663..488c03ff4 100644
--- a/web/src/pages/admin/admin-route-pages.tsx
+++ b/web/src/pages/admin/admin-route-pages.tsx
@@ -37,5 +37,5 @@ export function EmailSettingsPage() {
}
export function FeatureAvailabilityPage() {
- return