From f83ff03df3ff6989d4db4d3d4a4b27a0dda62593 Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:25:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(features):=20=E5=8A=9F=E8=83=BD=E5=BC=80?= =?UTF-8?q?=E6=94=BE=20-=20=E5=A2=9E=E5=8A=A0=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + backend/internal/handler/channel_models.go | 4 ++ backend/internal/handler/custom_proxy.go | 4 ++ .../internal/handler/feature_availability.go | 7 ++- .../service/desktop_local_channel_test.go | 2 +- .../internal/service/feature_availability.go | 23 +++++---- .../service/feature_availability_test.go | 47 +++++++++++++++++-- backend/internal/service/service.go | 24 ++++++++++ web/src/pages/admin/admin-route-pages.tsx | 2 +- .../pages/admin/components/admin-shell.tsx | 2 +- .../components/feature-availability-panel.tsx | 8 ++-- web/src/pages/settings/index.tsx | 23 +++++---- web/src/services/api/auth.ts | 2 +- web/src/stores/use-config-store.ts | 13 ++++- web/src/stores/use-user-store.ts | 4 +- web/test/local-dreamina-model-catalog.test.ts | 18 +++++++ 16 files changed, 154 insertions(+), 30 deletions(-) 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 }>; + return }>; } diff --git a/web/src/pages/admin/components/admin-shell.tsx b/web/src/pages/admin/components/admin-shell.tsx index 5d1da56ec..fc3150875 100644 --- a/web/src/pages/admin/components/admin-shell.tsx +++ b/web/src/pages/admin/components/admin-shell.tsx @@ -40,7 +40,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> = { label: "系统配置", items: [ - { path: "/admin/settings/features", label: "功能开放", description: "菜单、页面与积分模式", icon: }, + { path: "/admin/settings/features", label: "功能开放", description: "菜单、渠道与积分模式", icon: }, { path: "/admin/settings/drawing-engine", label: "绘图工具", description: "画布绘图节点默认引擎", icon: }, { path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: }, { path: "/admin/settings/access", label: "登录与注册", description: "注册策略与 Linux.do", icon: }, diff --git a/web/src/pages/admin/components/feature-availability-panel.tsx b/web/src/pages/admin/components/feature-availability-panel.tsx index 1cb394d39..09e1c9e2d 100644 --- a/web/src/pages/admin/components/feature-availability-panel.tsx +++ b/web/src/pages/admin/components/feature-availability-panel.tsx @@ -1,17 +1,18 @@ import { useEffect, useState, type ReactNode } from "react"; import { App, Switch } from "antd"; -import { Clapperboard, Coins, ListChecks, ToggleLeft } from "lucide-react"; +import { Clapperboard, Coins, ListChecks, RadioTower, ToggleLeft } from "lucide-react"; import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/services/api/auth"; import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store"; import { SettingsSectionCard } from "./admin-ui"; -type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled"; +type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled"; const featureRows: Array<{ key: FeatureKey; title: string; menu: string; description: string; icon: ReactNode }> = [ { key: "shortDramaEnabled", title: "短剧创作", menu: "/projects", description: "关闭后隐藏短剧入口,并拦截项目列表、详情和项目 API。已有项目数据不会删除。", icon: }, { key: "taskCenterEnabled", title: "任务", menu: "/tasks", description: "关闭后仅隐藏并拦截任务中心页面;生成任务仍会创建、执行、记录和恢复。", icon: }, { key: "creditsEnabled", title: "积分中心", menu: "/wallet", description: "关闭后隐藏用户积分入口,新创建的任务和系统渠道请求不再冻结或消费积分。", icon: }, + { key: "customChannelsEnabled", title: "自定义渠道", menu: "/settings?section=channels", description: "关闭后隐藏用户自定义渠道入口,并拦截模型目录拉取、渠道中转和新的生成任务。已有渠道配置不会删除。", icon: }, ]; export default function FeatureAvailabilityPanel() { @@ -41,6 +42,7 @@ export default function FeatureAvailabilityPanel() { shortDramaEnabled: next.shortDramaEnabled, taskCenterEnabled: next.taskCenterEnabled, creditsEnabled: next.creditsEnabled, + customChannelsEnabled: next.customChannelsEnabled, }); setFeatures(result.features); setGlobalFeatures(result.features); @@ -73,7 +75,7 @@ export default function FeatureAvailabilityPanel() { } title="用户功能开放" - description="统一控制用户工作台入口、页面访问和积分消费策略。" + description="统一控制用户工作台入口、页面访问、渠道能力和积分消费策略。" status={{ label: features ? `${enabledCount}/${featureRows.length} 已开放` : "读取中", color: enabledCount === featureRows.length ? "success" : "default" }} >
diff --git a/web/src/pages/settings/index.tsx b/web/src/pages/settings/index.tsx index 50e6d94bb..a77c161de 100644 --- a/web/src/pages/settings/index.tsx +++ b/web/src/pages/settings/index.tsx @@ -34,17 +34,24 @@ export default function SettingsPage() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const requestedSection = searchParams.get("section"); - const [activeTab, setActiveTab] = useState(isConfigSection(requestedSection) ? requestedSection : "channels"); + const customChannelsEnabled = useUserStore((state) => state.features.customChannelsEnabled); + const initialSection = isConfigSection(requestedSection) ? requestedSection : customChannelsEnabled ? "channels" : "models"; + const [activeTab, setActiveTab] = useState(initialSection === "channels" && !customChannelsEnabled ? "models" : initialSection); const config = useConfigStore((state) => state.config); const effectiveConfig = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); const shouldPromptContinue = searchParams.get("continue") === "1"; const userId = useUserStore((state) => state.user?.id); const userChannels = config.channels.filter((channel) => channel.scope !== "system"); + const visibleConfigSections = customChannelsEnabled ? configSections : configSections.filter((section) => section.key !== "channels"); useEffect(() => { - if (isConfigSection(requestedSection)) setActiveTab(requestedSection); - }, [requestedSection]); + if (isConfigSection(requestedSection) && (requestedSection !== "channels" || customChannelsEnabled)) { + setActiveTab(requestedSection); + return; + } + if (!customChannelsEnabled) setActiveTab((current) => (current === "channels" ? "models" : current)); + }, [customChannelsEnabled, requestedSection]); useEffect(() => { if (!userId) return; @@ -65,7 +72,7 @@ export default function SettingsPage() { }; const finishConfig = () => { - const invalidChannel = userChannels.find((channel) => channelValidationError(channel)); + const invalidChannel = customChannelsEnabled ? userChannels.find((channel) => channelValidationError(channel)) : undefined; if (invalidChannel) { selectSection("channels"); message.warning(`${invalidChannel.name || "未命名渠道"}:${channelValidationError(invalidChannel)}`); @@ -73,9 +80,9 @@ export default function SettingsPage() { return; } const hasReadyLocalRuntime = effectiveConfig.channels.some((channel) => channel.transport === "local-runtime" && channel.enabled !== false && Boolean(channel.localModels?.length)); - if (!config.channels.some(isChannelReady) && !hasReadyLocalRuntime) { - selectSection("channels"); - message.error(shouldPromptContinue ? "请先完成至少一个渠道的 Base URL、API Key 和模型配置" : "当前没有可用渠道,请先完成连接信息和模型配置"); + if (!effectiveConfig.channels.some(isChannelReady) && !hasReadyLocalRuntime) { + selectSection(customChannelsEnabled ? "channels" : "models"); + message.error(customChannelsEnabled ? (shouldPromptContinue ? "请先完成至少一个渠道的 Base URL、API Key 和模型配置" : "当前没有可用渠道,请先完成连接信息和模型配置") : "当前没有可用的系统模型,请联系管理员配置系统渠道"); return; } message.success("配置已保存,正在返回创作页面"); @@ -190,7 +197,7 @@ export default function SettingsPage() {