feat(features): 功能开放 - 增加自定义渠道开关
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("功能开放配置格式无效")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user