feat(channels): gate available channels behind feature switch (backend)

Add a DB-backed soft switch "available_channels_enabled" controlling
the user-facing /channels/available endpoint and sidebar entry. Default
to false (opt-in) — the feature stays invisible until an admin enables
it under Admin Settings > Features.

- domain_constants: SettingKeyAvailableChannelsEnabled
- settings_view: AllSettings/PublicSettings + AvailableChannelsEnabled
- setting_service: public+all read/write, seed default "false",
  GetAvailableChannelsRuntime helper (fail-closed on read error)
- admin setting_handler: UpdateSettingsRequest *bool + update branch
  + audit diff entry
- public setting_handler: expose via GET /api/v1/settings
- available_channel_handler: featureEnabled() guard — returns empty
  list after auth when disabled (401 precedes the feature check to
  preserve existing behavior)
This commit is contained in:
erio
2026-04-21 17:23:20 +08:00
parent 59290e39f9
commit 9ba42aa556
8 changed files with 84 additions and 1 deletions

View File

@@ -234,7 +234,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService) settingHandler := admin.NewSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService) paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService)
paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService) paymentHandler := admin.NewPaymentHandler(paymentService, paymentConfigService)
availableChannelUserHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService) availableChannelUserHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler) adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, paymentHandler)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig) usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient) userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)

View File

@@ -238,6 +238,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
} }
response.Success(c, systemSettingsResponseData(payload, authSourceDefaults)) response.Success(c, systemSettingsResponseData(payload, authSourceDefaults))
} }
@@ -432,6 +434,9 @@ type UpdateSettingsRequest struct {
// Channel Monitor feature switch // Channel Monitor feature switch
ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"` ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
} }
// UpdateSettings 更新系统设置 // UpdateSettings 更新系统设置
@@ -1238,6 +1243,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
} }
return previousSettings.ChannelMonitorDefaultIntervalSeconds return previousSettings.ChannelMonitorDefaultIntervalSeconds
}(), }(),
AvailableChannelsEnabled: func() bool {
if req.AvailableChannelsEnabled != nil {
return *req.AvailableChannelsEnabled
}
return previousSettings.AvailableChannelsEnabled
}(),
} }
authSourceDefaults := &service.AuthSourceDefaultSettings{ authSourceDefaults := &service.AuthSourceDefaultSettings{
@@ -1471,6 +1482,8 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled, ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
} }
response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults)) response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults))
} }
@@ -1833,6 +1846,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.ChannelMonitorDefaultIntervalSeconds != after.ChannelMonitorDefaultIntervalSeconds { if before.ChannelMonitorDefaultIntervalSeconds != after.ChannelMonitorDefaultIntervalSeconds {
changed = append(changed, "channel_monitor_default_interval_seconds") changed = append(changed, "channel_monitor_default_interval_seconds")
} }
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
changed = append(changed, "available_channels_enabled")
}
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults) changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
return changed return changed
} }

View File

@@ -21,19 +21,30 @@ import (
type AvailableChannelHandler struct { type AvailableChannelHandler struct {
channelService *service.ChannelService channelService *service.ChannelService
apiKeyService *service.APIKeyService apiKeyService *service.APIKeyService
settingService *service.SettingService
} }
// NewAvailableChannelHandler 创建用户侧可用渠道 handler。 // NewAvailableChannelHandler 创建用户侧可用渠道 handler。
func NewAvailableChannelHandler( func NewAvailableChannelHandler(
channelService *service.ChannelService, channelService *service.ChannelService,
apiKeyService *service.APIKeyService, apiKeyService *service.APIKeyService,
settingService *service.SettingService,
) *AvailableChannelHandler { ) *AvailableChannelHandler {
return &AvailableChannelHandler{ return &AvailableChannelHandler{
channelService: channelService, channelService: channelService,
apiKeyService: apiKeyService, apiKeyService: apiKeyService,
settingService: settingService,
} }
} }
// featureEnabled 返回 available-channels 开关是否启用。默认关闭opt-in
func (h *AvailableChannelHandler) featureEnabled(c *gin.Context) bool {
if h.settingService == nil {
return false
}
return h.settingService.GetAvailableChannelsRuntime(c.Request.Context()).Enabled
}
// userAvailableGroup 用户可见的分组概要(白名单字段)。 // userAvailableGroup 用户可见的分组概要(白名单字段)。
type userAvailableGroup struct { type userAvailableGroup struct {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -89,6 +100,13 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
return return
} }
// Feature 未启用时返回空数组(不暴露渠道信息)。检查放在认证之后,
// 保持与未开关前的 401 行为一致:未登录先 401登录后再按开关决定。
if !h.featureEnabled(c) {
response.Success(c, []userAvailableChannel{})
return
}
userGroups, err := h.apiKeyService.GetAvailableGroups(c.Request.Context(), subject.UserID) userGroups, err := h.apiKeyService.GetAvailableGroups(c.Request.Context(), subject.UserID)
if err != nil { if err != nil {
response.ErrorFrom(c, err) response.ErrorFrom(c, err)

View File

@@ -187,6 +187,9 @@ type SystemSettings struct {
// Channel Monitor feature switch // Channel Monitor feature switch
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
} }
type DefaultSubscriptionSetting struct { type DefaultSubscriptionSetting struct {
@@ -237,6 +240,8 @@ type PublicSettings struct {
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
} }
// OverloadCooldownSettings 529过载冷却配置 DTO // OverloadCooldownSettings 529过载冷却配置 DTO

View File

@@ -73,5 +73,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
ChannelMonitorEnabled: settings.ChannelMonitorEnabled, ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
}) })
} }

View File

@@ -254,6 +254,11 @@ const (
// pre-filled when creating a new channel monitor from the admin UI. Range: [15, 3600]. // pre-filled when creating a new channel monitor from the admin UI. Range: [15, 3600].
SettingKeyChannelMonitorDefaultIntervalSeconds = "channel_monitor_default_interval_seconds" SettingKeyChannelMonitorDefaultIntervalSeconds = "channel_monitor_default_interval_seconds"
// SettingKeyAvailableChannelsEnabled is a DB-backed soft switch for the "Available Channels"
// user-facing aggregate view. When false: user endpoint returns an empty list and the
// sidebar entry is hidden. Defaults to false (opt-in feature).
SettingKeyAvailableChannelsEnabled = "available_channels_enabled"
// ========================= // =========================
// Overload Cooldown (529) // Overload Cooldown (529)
// ========================= // =========================

View File

@@ -452,6 +452,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyAccountQuotaNotifyEnabled, SettingKeyAccountQuotaNotifyEnabled,
SettingKeyChannelMonitorEnabled, SettingKeyChannelMonitorEnabled,
SettingKeyChannelMonitorDefaultIntervalSeconds, SettingKeyChannelMonitorDefaultIntervalSeconds,
SettingKeyAvailableChannelsEnabled,
} }
settings, err := s.settingRepo.GetMultiple(ctx, keys) settings, err := s.settingRepo.GetMultiple(ctx, keys)
@@ -537,6 +538,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]), ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]),
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
}, nil }, nil
} }
@@ -595,6 +598,25 @@ func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMo
} }
} }
// AvailableChannelsRuntime is the lightweight view of the available-channels feature
// switch consumed by the user-facing handler.
type AvailableChannelsRuntime struct {
Enabled bool
}
// GetAvailableChannelsRuntime reads the available-channels feature switch directly
// from the settings store. Fail-closed: on error returns Enabled=false, matching
// the opt-in default (unknown ↔ disabled).
func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) AvailableChannelsRuntime {
vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAvailableChannelsEnabled})
if err != nil {
return AvailableChannelsRuntime{Enabled: false}
}
return AvailableChannelsRuntime{
Enabled: vals[SettingKeyAvailableChannelsEnabled] == "true",
}
}
// SetOnUpdateCallback sets a callback function to be called when settings are updated // SetOnUpdateCallback sets a callback function to be called when settings are updated
// This is used for cache invalidation (e.g., HTML cache in frontend server) // This is used for cache invalidation (e.g., HTML cache in frontend server)
func (s *SettingService) SetOnUpdateCallback(callback func()) { func (s *SettingService) SetOnUpdateCallback(callback func()) {
@@ -1151,6 +1173,9 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v) updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v)
} }
// Available channels feature switch
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
// Claude Code version check // Claude Code version check
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
@@ -1700,6 +1725,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyChannelMonitorEnabled: "true", SettingKeyChannelMonitorEnabled: "true",
SettingKeyChannelMonitorDefaultIntervalSeconds: "60", SettingKeyChannelMonitorDefaultIntervalSeconds: "60",
// Available channels feature (default disabled; opt-in)
SettingKeyAvailableChannelsEnabled: "false",
// Claude Code version check (default: empty = disabled) // Claude Code version check (default: empty = disabled)
SettingKeyMinClaudeCodeVersion: "", SettingKeyMinClaudeCodeVersion: "",
SettingKeyMaxClaudeCodeVersion: "", SettingKeyMaxClaudeCodeVersion: "",
@@ -2008,6 +2036,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
settings[SettingKeyChannelMonitorDefaultIntervalSeconds], settings[SettingKeyChannelMonitorDefaultIntervalSeconds],
) )
// Available channels feature (default: disabled; strict true)
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
// Claude Code version check // Claude Code version check
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion] result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion] result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]

View File

@@ -129,6 +129,9 @@ type SystemSettings struct {
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
// Available Channels feature (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Claude Code version check // Claude Code version check
MinClaudeCodeVersion string MinClaudeCodeVersion string
MaxClaudeCodeVersion string MaxClaudeCodeVersion string
@@ -217,6 +220,9 @@ type PublicSettings struct {
// Channel Monitor feature // Channel Monitor feature
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
// Available Channels feature (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
} }
type WeChatConnectOAuthConfig struct { type WeChatConnectOAuthConfig struct {