feat(channel-monitor): add feature switch settings + fix extra_models save
Settings:
- New "功能开关" tab between 通用设置 and 安全与认证
- ChannelMonitorEnabled toggle: runner skips scheduling when false,
user-facing list returns empty
- ChannelMonitorDefaultIntervalSeconds (15-3600): pre-fills interval
when creating a new monitor; each monitor can still override
Bug fix:
- ModelTagInput now commits pending input on blur, not just Enter/Tab.
Previously clicking "save" with an un-Enter'd extra model would drop
the value (DB stored extra_models=[] even when user typed entries).
Backend:
- domain_constants: SettingKeyChannelMonitor{Enabled,DefaultIntervalSeconds}
- SettingService.GetChannelMonitorRuntime: lightweight getter used by
runner tick + user handler per-request (fail-open on DB error)
- Runner tickDueChecks: bails early when feature disabled
- ChannelMonitorUserHandler: checks feature flag before serving
- Comment on runner doc: scheduler state is implicit (every tick re-reads
ListEnabled from DB), so CRUD ops on monitors self-maintain the schedule
Bump VERSION to 0.1.114.25
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
// =========================
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user