diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 8e367e81..754f814a 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -217,8 +217,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { channelMonitorRepository := repository.NewChannelMonitorRepository(client, sqlDB) channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor) channelMonitorHandler := admin.NewChannelMonitorHandler(channelMonitorService) - channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService) - channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService) + channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService) + channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService) _ = channelMonitorRunner registry := payment.ProvideRegistry() encryptionKey, err := payment.ProvideEncryptionKey(configConfig) diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index a882d1a1..40c944eb 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -235,6 +235,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentCancelRateLimitWindow: paymentCfg.CancelRateLimitWindow, PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit, PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode, + + ChannelMonitorEnabled: settings.ChannelMonitorEnabled, + ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, } response.Success(c, systemSettingsResponseData(payload, authSourceDefaults)) } @@ -425,6 +428,10 @@ type UpdateSettingsRequest struct { PaymentCancelRateLimitWindow *int `json:"payment_cancel_rate_limit_window"` PaymentCancelRateLimitUnit *string `json:"payment_cancel_rate_limit_unit"` PaymentCancelRateLimitMode *string `json:"payment_cancel_rate_limit_window_mode"` + + // Channel Monitor feature switch + ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"` + ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"` } // UpdateSettings 更新系统设置 @@ -1219,6 +1226,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } return previousSettings.AccountQuotaNotifyEmails }(), + ChannelMonitorEnabled: func() bool { + if req.ChannelMonitorEnabled != nil { + return *req.ChannelMonitorEnabled + } + return previousSettings.ChannelMonitorEnabled + }(), + ChannelMonitorDefaultIntervalSeconds: func() int { + if req.ChannelMonitorDefaultIntervalSeconds != nil { + return *req.ChannelMonitorDefaultIntervalSeconds + } + return previousSettings.ChannelMonitorDefaultIntervalSeconds + }(), } authSourceDefaults := &service.AuthSourceDefaultSettings{ @@ -1449,6 +1468,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow, PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit, PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode, + + ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled, + ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds, } response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults)) } @@ -1805,6 +1827,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if !equalNotifyEmailEntries(before.AccountQuotaNotifyEmails, after.AccountQuotaNotifyEmails) { changed = append(changed, "account_quota_notify_emails") } + if before.ChannelMonitorEnabled != after.ChannelMonitorEnabled { + changed = append(changed, "channel_monitor_enabled") + } + if before.ChannelMonitorDefaultIntervalSeconds != after.ChannelMonitorDefaultIntervalSeconds { + changed = append(changed, "channel_monitor_default_interval_seconds") + } changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults) return changed } diff --git a/backend/internal/handler/channel_monitor_user_handler.go b/backend/internal/handler/channel_monitor_user_handler.go index 6a513dc1..cc36b334 100644 --- a/backend/internal/handler/channel_monitor_user_handler.go +++ b/backend/internal/handler/channel_monitor_user_handler.go @@ -14,11 +14,28 @@ import ( // ChannelMonitorUserHandler 渠道监控用户只读 handler。 type ChannelMonitorUserHandler struct { monitorService *service.ChannelMonitorService + settingService *service.SettingService } // NewChannelMonitorUserHandler 创建 handler。 -func NewChannelMonitorUserHandler(monitorService *service.ChannelMonitorService) *ChannelMonitorUserHandler { - return &ChannelMonitorUserHandler{monitorService: monitorService} +// settingService 用于每次请求前读取功能开关;关闭时 List/GetStatus 直接返回空/404。 +func NewChannelMonitorUserHandler( + monitorService *service.ChannelMonitorService, + settingService *service.SettingService, +) *ChannelMonitorUserHandler { + return &ChannelMonitorUserHandler{ + monitorService: monitorService, + settingService: settingService, + } +} + +// featureEnabled 返回当前渠道监控功能是否开启。 +// settingService 为 nil(测试场景)视为启用。 +func (h *ChannelMonitorUserHandler) featureEnabled(c *gin.Context) bool { + if h.settingService == nil { + return true + } + return h.settingService.GetChannelMonitorRuntime(c.Request.Context()).Enabled } // --- Response --- @@ -123,6 +140,10 @@ func userMonitorDetailToResponse(d *service.UserMonitorDetail) *channelMonitorUs // List GET /api/v1/channel-monitors func (h *ChannelMonitorUserHandler) List(c *gin.Context) { + if !h.featureEnabled(c) { + response.Success(c, gin.H{"items": []channelMonitorUserListItem{}}) + return + } views, err := h.monitorService.ListUserView(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) @@ -137,6 +158,10 @@ func (h *ChannelMonitorUserHandler) List(c *gin.Context) { // GetStatus GET /api/v1/channel-monitors/:id/status func (h *ChannelMonitorUserHandler) GetStatus(c *gin.Context) { + if !h.featureEnabled(c) { + response.ErrorFrom(c, service.ErrChannelMonitorNotFound) + return + } // 复用 admin.ParseChannelMonitorID 保持错误码与日志一致。 id, ok := admin.ParseChannelMonitorID(c) if !ok { diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index fc6a3f9e..9d9bb6c5 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -183,6 +183,10 @@ type SystemSettings struct { BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"` + + // Channel Monitor feature switch + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` } type DefaultSubscriptionSetting struct { @@ -230,6 +234,9 @@ type PublicSettings struct { AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"` BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"` BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"` + + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` } // OverloadCooldownSettings 529过载冷却配置 DTO diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go index c0f5c28b..8d72206f 100644 --- a/backend/internal/handler/setting_handler.go +++ b/backend/internal/handler/setting_handler.go @@ -70,5 +70,8 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) { AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled, BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold, BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL, + + ChannelMonitorEnabled: settings.ChannelMonitorEnabled, + ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds, }) } diff --git a/backend/internal/service/channel_monitor_runner.go b/backend/internal/service/channel_monitor_runner.go index 377903d3..4655e6df 100644 --- a/backend/internal/service/channel_monitor_runner.go +++ b/backend/internal/service/channel_monitor_runner.go @@ -18,8 +18,13 @@ import ( // - Stop 时优雅关闭:池 drain + ticker.Stop + wg.Wait // // 不引入 cron 库;清理调度通过"每小时检查时间"实现,足够 MVP。 +// +// 定时任务维护:删除/创建/编辑 monitor 无需显式 reload,每个 tick 都会重新查 DB +// (ListEnabled + listDueForCheck),新 monitor 的 LastCheckedAt 为 nil 天然立即到期, +// 被删除的 monitor 自然不再返回,interval 变化下次 tick 自动按新值判定。 type ChannelMonitorRunner struct { - svc *ChannelMonitorService + svc *ChannelMonitorService + settingService *SettingService pool pond.Pool stopCh chan struct{} @@ -37,11 +42,13 @@ type ChannelMonitorRunner struct { } // NewChannelMonitorRunner 构造调度器。Start 在 wire 中调用。 -func NewChannelMonitorRunner(svc *ChannelMonitorService) *ChannelMonitorRunner { +// settingService 用于在每次 tick 前读取功能开关;传 nil 时视为总是启用(兼容测试)。 +func NewChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService) *ChannelMonitorRunner { return &ChannelMonitorRunner{ - svc: svc, - stopCh: make(chan struct{}), - inFlight: make(map[int64]struct{}), + svc: svc, + settingService: settingService, + stopCh: make(chan struct{}), + inFlight: make(map[int64]struct{}), } } @@ -93,10 +100,15 @@ func (r *ChannelMonitorRunner) dueCheckLoop() { // tickDueChecks 一次扫描:查询到期监控并逐个提交到池。 // 已在执行的 monitor 会被跳过(防止单次检测耗时 > interval 时重复调度)。 // 池满时使用 TrySubmit 跳过(不能阻塞 ticker),同时立即释放已占用的 inFlight 槽。 +// 当功能开关关闭时直接返回——管理员可以动态禁用模块,runner 不会拉取 DB。 func (r *ChannelMonitorRunner) tickDueChecks() { ctx, cancel := context.WithTimeout(context.Background(), monitorListDueTimeout) defer cancel() + if r.settingService != nil && !r.settingService.GetChannelMonitorRuntime(ctx).Enabled { + return + } + due, err := r.svc.listDueForCheck(ctx) if err != nil { slog.Warn("channel_monitor: list due failed", "error", err) diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 3c6888b8..ef2259ed 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -242,6 +242,18 @@ const ( // SettingKeyOpsRuntimeLogConfig stores JSON config for runtime log settings. SettingKeyOpsRuntimeLogConfig = "ops_runtime_log_config" + // ========================= + // Channel Monitor (渠道监控) + // ========================= + + // SettingKeyChannelMonitorEnabled is a DB-backed soft switch for the channel monitor feature. + // When false: runner skips scheduling and user-facing endpoints return an empty list. + SettingKeyChannelMonitorEnabled = "channel_monitor_enabled" + + // SettingKeyChannelMonitorDefaultIntervalSeconds controls the default interval (seconds) + // pre-filled when creating a new channel monitor from the admin UI. Range: [15, 3600]. + SettingKeyChannelMonitorDefaultIntervalSeconds = "channel_monitor_default_interval_seconds" + // ========================= // Overload Cooldown (529) // ========================= diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index f2b644be..c901be84 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -450,6 +450,8 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings SettingKeyBalanceLowNotifyThreshold, SettingKeyBalanceLowNotifyRechargeURL, SettingKeyAccountQuotaNotifyEnabled, + SettingKeyChannelMonitorEnabled, + SettingKeyChannelMonitorDefaultIntervalSeconds, } settings, err := s.settingRepo.GetMultiple(ctx, keys) @@ -532,9 +534,67 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true", BalanceLowNotifyThreshold: balanceLowNotifyThreshold, BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL], + + ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]), + ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]), }, nil } +// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval +// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled). +const ( + channelMonitorIntervalMin = 15 + channelMonitorIntervalMax = 3600 + channelMonitorIntervalFallback = 60 +) + +// parseChannelMonitorInterval parses the stored string and clamps to [15, 3600]. +// Empty / invalid input falls back to channelMonitorIntervalFallback. +func parseChannelMonitorInterval(raw string) int { + v, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil { + return channelMonitorIntervalFallback + } + return clampChannelMonitorInterval(v) +} + +// clampChannelMonitorInterval clamps v to the allowed range. 0 means "not provided". +func clampChannelMonitorInterval(v int) int { + if v <= 0 { + return 0 + } + if v < channelMonitorIntervalMin { + return channelMonitorIntervalMin + } + if v > channelMonitorIntervalMax { + return channelMonitorIntervalMax + } + return v +} + +// ChannelMonitorRuntime is the lightweight view of the channel monitor feature +// consumed by the runner and user-facing handlers. +type ChannelMonitorRuntime struct { + Enabled bool + DefaultIntervalSeconds int +} + +// GetChannelMonitorRuntime reads the channel monitor feature flags directly from +// the settings store. Fail-open: on error returns Enabled=true with the default interval. +func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime { + vals, err := s.settingRepo.GetMultiple(ctx, []string{ + SettingKeyChannelMonitorEnabled, + SettingKeyChannelMonitorDefaultIntervalSeconds, + }) + if err != nil { + return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback} + } + return ChannelMonitorRuntime{ + Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]), + DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]), + } +} + // 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) func (s *SettingService) SetOnUpdateCallback(callback func()) { @@ -1085,6 +1145,12 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds) } + // Channel monitor feature switch + updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled) + if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 { + updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v) + } + // Claude Code version check updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion @@ -1630,6 +1696,10 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error { SettingKeyOpsQueryModeDefault: "auto", SettingKeyOpsMetricsIntervalSeconds: "60", + // Channel monitor defaults (enabled, 60s) + SettingKeyChannelMonitorEnabled: "true", + SettingKeyChannelMonitorDefaultIntervalSeconds: "60", + // Claude Code version check (default: empty = disabled) SettingKeyMinClaudeCodeVersion: "", SettingKeyMaxClaudeCodeVersion: "", @@ -1932,6 +2002,12 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin } } + // Channel monitor feature (default: enabled, 60s) + result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]) + result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval( + settings[SettingKeyChannelMonitorDefaultIntervalSeconds], + ) + // Claude Code version check result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion] result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion] diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index d2ef8fae..972faf80 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -125,6 +125,10 @@ type SystemSettings struct { OpsQueryModeDefault string OpsMetricsIntervalSeconds int + // Channel Monitor feature + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` + // Claude Code version check MinClaudeCodeVersion string MaxClaudeCodeVersion string @@ -209,6 +213,10 @@ type PublicSettings struct { AccountQuotaNotifyEnabled bool BalanceLowNotifyThreshold float64 BalanceLowNotifyRechargeURL string + + // Channel Monitor feature + ChannelMonitorEnabled bool `json:"channel_monitor_enabled"` + ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"` } type WeChatConnectOAuthConfig struct { diff --git a/backend/internal/service/wire.go b/backend/internal/service/wire.go index ce933798..5d8d88d2 100644 --- a/backend/internal/service/wire.go +++ b/backend/internal/service/wire.go @@ -500,8 +500,9 @@ func ProvideChannelMonitorService( // ProvideChannelMonitorRunner 创建并启动渠道监控调度器。 // Runner.Stop 由 cleanup function 调用。 -func ProvideChannelMonitorRunner(svc *ChannelMonitorService) *ChannelMonitorRunner { - r := NewChannelMonitorRunner(svc) +// settingService 用于 runner 每个 tick 读取功能开关。 +func ProvideChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService) *ChannelMonitorRunner { + r := NewChannelMonitorRunner(svc, settingService) r.Start() return r } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 0403b0f3..ab85c30c 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -469,6 +469,10 @@ export interface SystemSettings { balance_low_notify_recharge_url: string; account_quota_notify_enabled: boolean; account_quota_notify_emails: NotifyEmailEntry[]; + + // Channel Monitor feature switch + channel_monitor_enabled: boolean; + channel_monitor_default_interval_seconds: number; } export interface UpdateSettingsRequest { @@ -618,6 +622,10 @@ export interface UpdateSettingsRequest { balance_low_notify_recharge_url?: string; account_quota_notify_enabled?: boolean; account_quota_notify_emails?: NotifyEmailEntry[]; + + // Channel Monitor feature switch + channel_monitor_enabled?: boolean; + channel_monitor_default_interval_seconds?: number; } /** diff --git a/frontend/src/components/admin/channel/ModelTagInput.vue b/frontend/src/components/admin/channel/ModelTagInput.vue index a1ce4022..b91aa119 100644 --- a/frontend/src/components/admin/channel/ModelTagInput.vue +++ b/frontend/src/components/admin/channel/ModelTagInput.vue @@ -27,6 +27,7 @@ @keydown.tab.prevent="addModel" @keydown.delete="handleBackspace" @paste="handlePaste" + @blur="addModel" />
diff --git a/frontend/src/components/admin/monitor/MonitorFormDialog.vue b/frontend/src/components/admin/monitor/MonitorFormDialog.vue
index 920c3f79..e1489ffb 100644
--- a/frontend/src/components/admin/monitor/MonitorFormDialog.vue
+++ b/frontend/src/components/admin/monitor/MonitorFormDialog.vue
@@ -143,6 +143,13 @@ const emit = defineEmits<{
const { t } = useI18n()
const appStore = useAppStore()
+// System-configured default interval for new monitors. Falls back to the static
+// constant when public settings haven't loaded yet or store the legacy 0 value.
+const systemDefaultInterval = computed
+ {{ t('admin.settings.features.channelMonitor.description') }}
+
+ {{ t('admin.settings.features.channelMonitor.enabledHint') }}
+
+ {{ t('admin.settings.features.channelMonitor.defaultIntervalHint') }}
+
+ {{ t('admin.settings.features.channelMonitor.title') }}
+
+