From c70c6a26592371398e1e2c0e350bd52148542de2 Mon Sep 17 00:00:00 2001 From: bwlc <1350701980@qq.com> Date: Fri, 12 Jun 2026 22:09:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=A0=E9=81=93=E7=9B=91=E6=8E=A7):=20?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E9=97=B4=E9=9A=94=E6=94=AF=E6=8C=81=E6=AD=A3?= =?UTF-8?q?=E8=B4=9F=E9=9A=8F=E6=9C=BA=E6=8A=96=E5=8A=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 jitter_seconds 配置:每轮调度在 interval 基础上 ± [0, jitter] 均匀随机偏移触发,避免多个监控以固定节奏同步请求上游。 - ent schema 新增 jitter_seconds 字段(默认 0),附迁移 151 - 校验:jitter >= 0 且 interval - jitter >= 15s(创建/更新均校验) - runner 由固定 ticker 改为每轮重新随机化的 timer,0 抖动时行为不变 - 前端监控表单新增「随机抖动 (± 秒)」输入框,上限随间隔联动 Co-Authored-By: Claude Fable 5 --- backend/ent/channelmonitor.go | 13 ++- backend/ent/channelmonitor/channelmonitor.go | 12 +++ backend/ent/channelmonitor/where.go | 45 ++++++++++ backend/ent/channelmonitor_create.go | 90 +++++++++++++++++++ backend/ent/channelmonitor_update.go | 64 +++++++++++++ backend/ent/migrate/schema.go | 7 +- backend/ent/mutation.go | 89 +++++++++++++++++- backend/ent/runtime/runtime.go | 10 ++- backend/ent/schema/channel_monitor.go | 4 + .../handler/admin/channel_monitor_handler.go | 6 ++ .../repository/channel_monitor_repo.go | 3 + .../internal/service/channel_monitor_const.go | 3 + .../service/channel_monitor_runner.go | 32 ++++++- .../service/channel_monitor_service.go | 13 +++ .../internal/service/channel_monitor_types.go | 3 + .../service/channel_monitor_validate.go | 9 ++ .../migrations/151_channel_monitor_jitter.sql | 7 ++ frontend/src/api/admin/channelMonitor.ts | 3 + .../admin/monitor/MonitorFormDialog.vue | 14 +++ frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + 21 files changed, 420 insertions(+), 11 deletions(-) create mode 100644 backend/migrations/151_channel_monitor_jitter.sql diff --git a/backend/ent/channelmonitor.go b/backend/ent/channelmonitor.go index defd06c63a..e5697863f0 100644 --- a/backend/ent/channelmonitor.go +++ b/backend/ent/channelmonitor.go @@ -43,6 +43,8 @@ type ChannelMonitor struct { Enabled bool `json:"enabled,omitempty"` // IntervalSeconds holds the value of the "interval_seconds" field. IntervalSeconds int `json:"interval_seconds,omitempty"` + // 每次调度在 interval 基础上 ± [0, jitter] 的均匀随机偏移(秒);0 表示固定间隔。service 层另保证 interval - jitter >= 15 + JitterSeconds int `json:"jitter_seconds,omitempty"` // LastCheckedAt holds the value of the "last_checked_at" field. LastCheckedAt *time.Time `json:"last_checked_at,omitempty"` // CreatedBy holds the value of the "created_by" field. @@ -112,7 +114,7 @@ func (*ChannelMonitor) scanValues(columns []string) ([]any, error) { values[i] = new([]byte) case channelmonitor.FieldEnabled: values[i] = new(sql.NullBool) - case channelmonitor.FieldID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID: + case channelmonitor.FieldID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID: values[i] = new(sql.NullInt64) case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode: values[i] = new(sql.NullString) @@ -213,6 +215,12 @@ func (_m *ChannelMonitor) assignValues(columns []string, values []any) error { } else if value.Valid { _m.IntervalSeconds = int(value.Int64) } + case channelmonitor.FieldJitterSeconds: + if value, ok := values[i].(*sql.NullInt64); !ok { + return fmt.Errorf("unexpected type %T for field jitter_seconds", values[i]) + } else if value.Valid { + _m.JitterSeconds = int(value.Int64) + } case channelmonitor.FieldLastCheckedAt: if value, ok := values[i].(*sql.NullTime); !ok { return fmt.Errorf("unexpected type %T for field last_checked_at", values[i]) @@ -341,6 +349,9 @@ func (_m *ChannelMonitor) String() string { builder.WriteString("interval_seconds=") builder.WriteString(fmt.Sprintf("%v", _m.IntervalSeconds)) builder.WriteString(", ") + builder.WriteString("jitter_seconds=") + builder.WriteString(fmt.Sprintf("%v", _m.JitterSeconds)) + builder.WriteString(", ") if v := _m.LastCheckedAt; v != nil { builder.WriteString("last_checked_at=") builder.WriteString(v.Format(time.ANSIC)) diff --git a/backend/ent/channelmonitor/channelmonitor.go b/backend/ent/channelmonitor/channelmonitor.go index 0723ad0da2..afdc6957d6 100644 --- a/backend/ent/channelmonitor/channelmonitor.go +++ b/backend/ent/channelmonitor/channelmonitor.go @@ -39,6 +39,8 @@ const ( FieldEnabled = "enabled" // FieldIntervalSeconds holds the string denoting the interval_seconds field in the database. FieldIntervalSeconds = "interval_seconds" + // FieldJitterSeconds holds the string denoting the jitter_seconds field in the database. + FieldJitterSeconds = "jitter_seconds" // FieldLastCheckedAt holds the string denoting the last_checked_at field in the database. FieldLastCheckedAt = "last_checked_at" // FieldCreatedBy holds the string denoting the created_by field in the database. @@ -97,6 +99,7 @@ var Columns = []string{ FieldGroupName, FieldEnabled, FieldIntervalSeconds, + FieldJitterSeconds, FieldLastCheckedAt, FieldCreatedBy, FieldTemplateID, @@ -144,6 +147,10 @@ var ( DefaultEnabled bool // IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save. IntervalSecondsValidator func(int) error + // DefaultJitterSeconds holds the default value on creation for the "jitter_seconds" field. + DefaultJitterSeconds int + // JitterSecondsValidator is a validator for the "jitter_seconds" field. It is called by the builders before save. + JitterSecondsValidator func(int) error // DefaultExtraHeaders holds the default value on creation for the "extra_headers" field. DefaultExtraHeaders map[string]string // DefaultBodyOverrideMode holds the default value on creation for the "body_override_mode" field. @@ -239,6 +246,11 @@ func ByIntervalSeconds(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldIntervalSeconds, opts...).ToFunc() } +// ByJitterSeconds orders the results by the jitter_seconds field. +func ByJitterSeconds(opts ...sql.OrderTermOption) OrderOption { + return sql.OrderByField(FieldJitterSeconds, opts...).ToFunc() +} + // ByLastCheckedAt orders the results by the last_checked_at field. func ByLastCheckedAt(opts ...sql.OrderTermOption) OrderOption { return sql.OrderByField(FieldLastCheckedAt, opts...).ToFunc() diff --git a/backend/ent/channelmonitor/where.go b/backend/ent/channelmonitor/where.go index 8bd8f6272e..6575aa5df6 100644 --- a/backend/ent/channelmonitor/where.go +++ b/backend/ent/channelmonitor/where.go @@ -105,6 +105,11 @@ func IntervalSeconds(v int) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldEQ(FieldIntervalSeconds, v)) } +// JitterSeconds applies equality check predicate on the "jitter_seconds" field. It's identical to JitterSecondsEQ. +func JitterSeconds(v int) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEQ(FieldJitterSeconds, v)) +} + // LastCheckedAt applies equality check predicate on the "last_checked_at" field. It's identical to LastCheckedAtEQ. func LastCheckedAt(v time.Time) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldEQ(FieldLastCheckedAt, v)) @@ -675,6 +680,46 @@ func IntervalSecondsLTE(v int) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldLTE(FieldIntervalSeconds, v)) } +// JitterSecondsEQ applies the EQ predicate on the "jitter_seconds" field. +func JitterSecondsEQ(v int) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldEQ(FieldJitterSeconds, v)) +} + +// JitterSecondsNEQ applies the NEQ predicate on the "jitter_seconds" field. +func JitterSecondsNEQ(v int) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNEQ(FieldJitterSeconds, v)) +} + +// JitterSecondsIn applies the In predicate on the "jitter_seconds" field. +func JitterSecondsIn(vs ...int) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldIn(FieldJitterSeconds, vs...)) +} + +// JitterSecondsNotIn applies the NotIn predicate on the "jitter_seconds" field. +func JitterSecondsNotIn(vs ...int) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldNotIn(FieldJitterSeconds, vs...)) +} + +// JitterSecondsGT applies the GT predicate on the "jitter_seconds" field. +func JitterSecondsGT(v int) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldGT(FieldJitterSeconds, v)) +} + +// JitterSecondsGTE applies the GTE predicate on the "jitter_seconds" field. +func JitterSecondsGTE(v int) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldGTE(FieldJitterSeconds, v)) +} + +// JitterSecondsLT applies the LT predicate on the "jitter_seconds" field. +func JitterSecondsLT(v int) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldLT(FieldJitterSeconds, v)) +} + +// JitterSecondsLTE applies the LTE predicate on the "jitter_seconds" field. +func JitterSecondsLTE(v int) predicate.ChannelMonitor { + return predicate.ChannelMonitor(sql.FieldLTE(FieldJitterSeconds, v)) +} + // LastCheckedAtEQ applies the EQ predicate on the "last_checked_at" field. func LastCheckedAtEQ(v time.Time) predicate.ChannelMonitor { return predicate.ChannelMonitor(sql.FieldEQ(FieldLastCheckedAt, v)) diff --git a/backend/ent/channelmonitor_create.go b/backend/ent/channelmonitor_create.go index 2593893fc6..6c7e654917 100644 --- a/backend/ent/channelmonitor_create.go +++ b/backend/ent/channelmonitor_create.go @@ -137,6 +137,20 @@ func (_c *ChannelMonitorCreate) SetIntervalSeconds(v int) *ChannelMonitorCreate return _c } +// SetJitterSeconds sets the "jitter_seconds" field. +func (_c *ChannelMonitorCreate) SetJitterSeconds(v int) *ChannelMonitorCreate { + _c.mutation.SetJitterSeconds(v) + return _c +} + +// SetNillableJitterSeconds sets the "jitter_seconds" field if the given value is not nil. +func (_c *ChannelMonitorCreate) SetNillableJitterSeconds(v *int) *ChannelMonitorCreate { + if v != nil { + _c.SetJitterSeconds(*v) + } + return _c +} + // SetLastCheckedAt sets the "last_checked_at" field. func (_c *ChannelMonitorCreate) SetLastCheckedAt(v time.Time) *ChannelMonitorCreate { _c.mutation.SetLastCheckedAt(v) @@ -305,6 +319,10 @@ func (_c *ChannelMonitorCreate) defaults() { v := channelmonitor.DefaultEnabled _c.mutation.SetEnabled(v) } + if _, ok := _c.mutation.JitterSeconds(); !ok { + v := channelmonitor.DefaultJitterSeconds + _c.mutation.SetJitterSeconds(v) + } if _, ok := _c.mutation.ExtraHeaders(); !ok { v := channelmonitor.DefaultExtraHeaders _c.mutation.SetExtraHeaders(v) @@ -390,6 +408,14 @@ func (_c *ChannelMonitorCreate) check() error { return &ValidationError{Name: "interval_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.interval_seconds": %w`, err)} } } + if _, ok := _c.mutation.JitterSeconds(); !ok { + return &ValidationError{Name: "jitter_seconds", err: errors.New(`ent: missing required field "ChannelMonitor.jitter_seconds"`)} + } + if v, ok := _c.mutation.JitterSeconds(); ok { + if err := channelmonitor.JitterSecondsValidator(v); err != nil { + return &ValidationError{Name: "jitter_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.jitter_seconds": %w`, err)} + } + } if _, ok := _c.mutation.CreatedBy(); !ok { return &ValidationError{Name: "created_by", err: errors.New(`ent: missing required field "ChannelMonitor.created_by"`)} } @@ -479,6 +505,10 @@ func (_c *ChannelMonitorCreate) createSpec() (*ChannelMonitor, *sqlgraph.CreateS _spec.SetField(channelmonitor.FieldIntervalSeconds, field.TypeInt, value) _node.IntervalSeconds = value } + if value, ok := _c.mutation.JitterSeconds(); ok { + _spec.SetField(channelmonitor.FieldJitterSeconds, field.TypeInt, value) + _node.JitterSeconds = value + } if value, ok := _c.mutation.LastCheckedAt(); ok { _spec.SetField(channelmonitor.FieldLastCheckedAt, field.TypeTime, value) _node.LastCheckedAt = &value @@ -744,6 +774,24 @@ func (u *ChannelMonitorUpsert) AddIntervalSeconds(v int) *ChannelMonitorUpsert { return u } +// SetJitterSeconds sets the "jitter_seconds" field. +func (u *ChannelMonitorUpsert) SetJitterSeconds(v int) *ChannelMonitorUpsert { + u.Set(channelmonitor.FieldJitterSeconds, v) + return u +} + +// UpdateJitterSeconds sets the "jitter_seconds" field to the value that was provided on create. +func (u *ChannelMonitorUpsert) UpdateJitterSeconds() *ChannelMonitorUpsert { + u.SetExcluded(channelmonitor.FieldJitterSeconds) + return u +} + +// AddJitterSeconds adds v to the "jitter_seconds" field. +func (u *ChannelMonitorUpsert) AddJitterSeconds(v int) *ChannelMonitorUpsert { + u.Add(channelmonitor.FieldJitterSeconds, v) + return u +} + // SetLastCheckedAt sets the "last_checked_at" field. func (u *ChannelMonitorUpsert) SetLastCheckedAt(v time.Time) *ChannelMonitorUpsert { u.Set(channelmonitor.FieldLastCheckedAt, v) @@ -1053,6 +1101,27 @@ func (u *ChannelMonitorUpsertOne) UpdateIntervalSeconds() *ChannelMonitorUpsertO }) } +// SetJitterSeconds sets the "jitter_seconds" field. +func (u *ChannelMonitorUpsertOne) SetJitterSeconds(v int) *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.SetJitterSeconds(v) + }) +} + +// AddJitterSeconds adds v to the "jitter_seconds" field. +func (u *ChannelMonitorUpsertOne) AddJitterSeconds(v int) *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.AddJitterSeconds(v) + }) +} + +// UpdateJitterSeconds sets the "jitter_seconds" field to the value that was provided on create. +func (u *ChannelMonitorUpsertOne) UpdateJitterSeconds() *ChannelMonitorUpsertOne { + return u.Update(func(s *ChannelMonitorUpsert) { + s.UpdateJitterSeconds() + }) +} + // SetLastCheckedAt sets the "last_checked_at" field. func (u *ChannelMonitorUpsertOne) SetLastCheckedAt(v time.Time) *ChannelMonitorUpsertOne { return u.Update(func(s *ChannelMonitorUpsert) { @@ -1544,6 +1613,27 @@ func (u *ChannelMonitorUpsertBulk) UpdateIntervalSeconds() *ChannelMonitorUpsert }) } +// SetJitterSeconds sets the "jitter_seconds" field. +func (u *ChannelMonitorUpsertBulk) SetJitterSeconds(v int) *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.SetJitterSeconds(v) + }) +} + +// AddJitterSeconds adds v to the "jitter_seconds" field. +func (u *ChannelMonitorUpsertBulk) AddJitterSeconds(v int) *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.AddJitterSeconds(v) + }) +} + +// UpdateJitterSeconds sets the "jitter_seconds" field to the value that was provided on create. +func (u *ChannelMonitorUpsertBulk) UpdateJitterSeconds() *ChannelMonitorUpsertBulk { + return u.Update(func(s *ChannelMonitorUpsert) { + s.UpdateJitterSeconds() + }) +} + // SetLastCheckedAt sets the "last_checked_at" field. func (u *ChannelMonitorUpsertBulk) SetLastCheckedAt(v time.Time) *ChannelMonitorUpsertBulk { return u.Update(func(s *ChannelMonitorUpsert) { diff --git a/backend/ent/channelmonitor_update.go b/backend/ent/channelmonitor_update.go index 2cd5e656a4..ccc6fa8cc2 100644 --- a/backend/ent/channelmonitor_update.go +++ b/backend/ent/channelmonitor_update.go @@ -189,6 +189,27 @@ func (_u *ChannelMonitorUpdate) AddIntervalSeconds(v int) *ChannelMonitorUpdate return _u } +// SetJitterSeconds sets the "jitter_seconds" field. +func (_u *ChannelMonitorUpdate) SetJitterSeconds(v int) *ChannelMonitorUpdate { + _u.mutation.ResetJitterSeconds() + _u.mutation.SetJitterSeconds(v) + return _u +} + +// SetNillableJitterSeconds sets the "jitter_seconds" field if the given value is not nil. +func (_u *ChannelMonitorUpdate) SetNillableJitterSeconds(v *int) *ChannelMonitorUpdate { + if v != nil { + _u.SetJitterSeconds(*v) + } + return _u +} + +// AddJitterSeconds adds value to the "jitter_seconds" field. +func (_u *ChannelMonitorUpdate) AddJitterSeconds(v int) *ChannelMonitorUpdate { + _u.mutation.AddJitterSeconds(v) + return _u +} + // SetLastCheckedAt sets the "last_checked_at" field. func (_u *ChannelMonitorUpdate) SetLastCheckedAt(v time.Time) *ChannelMonitorUpdate { _u.mutation.SetLastCheckedAt(v) @@ -462,6 +483,11 @@ func (_u *ChannelMonitorUpdate) check() error { return &ValidationError{Name: "interval_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.interval_seconds": %w`, err)} } } + if v, ok := _u.mutation.JitterSeconds(); ok { + if err := channelmonitor.JitterSecondsValidator(v); err != nil { + return &ValidationError{Name: "jitter_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.jitter_seconds": %w`, err)} + } + } if v, ok := _u.mutation.BodyOverrideMode(); ok { if err := channelmonitor.BodyOverrideModeValidator(v); err != nil { return &ValidationError{Name: "body_override_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.body_override_mode": %w`, err)} @@ -526,6 +552,12 @@ func (_u *ChannelMonitorUpdate) sqlSave(ctx context.Context) (_node int, err err if value, ok := _u.mutation.AddedIntervalSeconds(); ok { _spec.AddField(channelmonitor.FieldIntervalSeconds, field.TypeInt, value) } + if value, ok := _u.mutation.JitterSeconds(); ok { + _spec.SetField(channelmonitor.FieldJitterSeconds, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedJitterSeconds(); ok { + _spec.AddField(channelmonitor.FieldJitterSeconds, field.TypeInt, value) + } if value, ok := _u.mutation.LastCheckedAt(); ok { _spec.SetField(channelmonitor.FieldLastCheckedAt, field.TypeTime, value) } @@ -846,6 +878,27 @@ func (_u *ChannelMonitorUpdateOne) AddIntervalSeconds(v int) *ChannelMonitorUpda return _u } +// SetJitterSeconds sets the "jitter_seconds" field. +func (_u *ChannelMonitorUpdateOne) SetJitterSeconds(v int) *ChannelMonitorUpdateOne { + _u.mutation.ResetJitterSeconds() + _u.mutation.SetJitterSeconds(v) + return _u +} + +// SetNillableJitterSeconds sets the "jitter_seconds" field if the given value is not nil. +func (_u *ChannelMonitorUpdateOne) SetNillableJitterSeconds(v *int) *ChannelMonitorUpdateOne { + if v != nil { + _u.SetJitterSeconds(*v) + } + return _u +} + +// AddJitterSeconds adds value to the "jitter_seconds" field. +func (_u *ChannelMonitorUpdateOne) AddJitterSeconds(v int) *ChannelMonitorUpdateOne { + _u.mutation.AddJitterSeconds(v) + return _u +} + // SetLastCheckedAt sets the "last_checked_at" field. func (_u *ChannelMonitorUpdateOne) SetLastCheckedAt(v time.Time) *ChannelMonitorUpdateOne { _u.mutation.SetLastCheckedAt(v) @@ -1132,6 +1185,11 @@ func (_u *ChannelMonitorUpdateOne) check() error { return &ValidationError{Name: "interval_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.interval_seconds": %w`, err)} } } + if v, ok := _u.mutation.JitterSeconds(); ok { + if err := channelmonitor.JitterSecondsValidator(v); err != nil { + return &ValidationError{Name: "jitter_seconds", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.jitter_seconds": %w`, err)} + } + } if v, ok := _u.mutation.BodyOverrideMode(); ok { if err := channelmonitor.BodyOverrideModeValidator(v); err != nil { return &ValidationError{Name: "body_override_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.body_override_mode": %w`, err)} @@ -1213,6 +1271,12 @@ func (_u *ChannelMonitorUpdateOne) sqlSave(ctx context.Context) (_node *ChannelM if value, ok := _u.mutation.AddedIntervalSeconds(); ok { _spec.AddField(channelmonitor.FieldIntervalSeconds, field.TypeInt, value) } + if value, ok := _u.mutation.JitterSeconds(); ok { + _spec.SetField(channelmonitor.FieldJitterSeconds, field.TypeInt, value) + } + if value, ok := _u.mutation.AddedJitterSeconds(); ok { + _spec.AddField(channelmonitor.FieldJitterSeconds, field.TypeInt, value) + } if value, ok := _u.mutation.LastCheckedAt(); ok { _spec.SetField(channelmonitor.FieldLastCheckedAt, field.TypeTime, value) } diff --git a/backend/ent/migrate/schema.go b/backend/ent/migrate/schema.go index f235031c34..d5e9130416 100644 --- a/backend/ent/migrate/schema.go +++ b/backend/ent/migrate/schema.go @@ -437,6 +437,7 @@ var ( {Name: "group_name", Type: field.TypeString, Nullable: true, Size: 100, Default: ""}, {Name: "enabled", Type: field.TypeBool, Default: true}, {Name: "interval_seconds", Type: field.TypeInt}, + {Name: "jitter_seconds", Type: field.TypeInt, Default: 0}, {Name: "last_checked_at", Type: field.TypeTime, Nullable: true}, {Name: "created_by", Type: field.TypeInt64}, {Name: "extra_headers", Type: field.TypeJSON}, @@ -452,7 +453,7 @@ var ( ForeignKeys: []*schema.ForeignKey{ { Symbol: "channel_monitors_channel_monitor_request_templates_request_template", - Columns: []*schema.Column{ChannelMonitorsColumns[18]}, + Columns: []*schema.Column{ChannelMonitorsColumns[19]}, RefColumns: []*schema.Column{ChannelMonitorRequestTemplatesColumns[0]}, OnDelete: schema.SetNull, }, @@ -461,7 +462,7 @@ var ( { Name: "channelmonitor_enabled_last_checked_at", Unique: false, - Columns: []*schema.Column{ChannelMonitorsColumns[11], ChannelMonitorsColumns[13]}, + Columns: []*schema.Column{ChannelMonitorsColumns[11], ChannelMonitorsColumns[14]}, }, { Name: "channelmonitor_provider", @@ -481,7 +482,7 @@ var ( { Name: "channelmonitor_template_id", Unique: false, - Columns: []*schema.Column{ChannelMonitorsColumns[18]}, + Columns: []*schema.Column{ChannelMonitorsColumns[19]}, }, }, } diff --git a/backend/ent/mutation.go b/backend/ent/mutation.go index 9ce8e6362e..d62620bc7b 100644 --- a/backend/ent/mutation.go +++ b/backend/ent/mutation.go @@ -8871,6 +8871,8 @@ type ChannelMonitorMutation struct { enabled *bool interval_seconds *int addinterval_seconds *int + jitter_seconds *int + addjitter_seconds *int last_checked_at *time.Time created_by *int64 addcreated_by *int64 @@ -9469,6 +9471,62 @@ func (m *ChannelMonitorMutation) ResetIntervalSeconds() { m.addinterval_seconds = nil } +// SetJitterSeconds sets the "jitter_seconds" field. +func (m *ChannelMonitorMutation) SetJitterSeconds(i int) { + m.jitter_seconds = &i + m.addjitter_seconds = nil +} + +// JitterSeconds returns the value of the "jitter_seconds" field in the mutation. +func (m *ChannelMonitorMutation) JitterSeconds() (r int, exists bool) { + v := m.jitter_seconds + if v == nil { + return + } + return *v, true +} + +// OldJitterSeconds returns the old "jitter_seconds" field's value of the ChannelMonitor entity. +// If the ChannelMonitor object wasn't provided to the builder, the object is fetched from the database. +// An error is returned if the mutation operation is not UpdateOne, or the database query fails. +func (m *ChannelMonitorMutation) OldJitterSeconds(ctx context.Context) (v int, err error) { + if !m.op.Is(OpUpdateOne) { + return v, errors.New("OldJitterSeconds is only allowed on UpdateOne operations") + } + if m.id == nil || m.oldValue == nil { + return v, errors.New("OldJitterSeconds requires an ID field in the mutation") + } + oldValue, err := m.oldValue(ctx) + if err != nil { + return v, fmt.Errorf("querying old value for OldJitterSeconds: %w", err) + } + return oldValue.JitterSeconds, nil +} + +// AddJitterSeconds adds i to the "jitter_seconds" field. +func (m *ChannelMonitorMutation) AddJitterSeconds(i int) { + if m.addjitter_seconds != nil { + *m.addjitter_seconds += i + } else { + m.addjitter_seconds = &i + } +} + +// AddedJitterSeconds returns the value that was added to the "jitter_seconds" field in this mutation. +func (m *ChannelMonitorMutation) AddedJitterSeconds() (r int, exists bool) { + v := m.addjitter_seconds + if v == nil { + return + } + return *v, true +} + +// ResetJitterSeconds resets all changes to the "jitter_seconds" field. +func (m *ChannelMonitorMutation) ResetJitterSeconds() { + m.jitter_seconds = nil + m.addjitter_seconds = nil +} + // SetLastCheckedAt sets the "last_checked_at" field. func (m *ChannelMonitorMutation) SetLastCheckedAt(t time.Time) { m.last_checked_at = &t @@ -9926,7 +9984,7 @@ func (m *ChannelMonitorMutation) Type() string { // order to get all numeric fields that were incremented/decremented, call // AddedFields(). func (m *ChannelMonitorMutation) Fields() []string { - fields := make([]string, 0, 18) + fields := make([]string, 0, 19) if m.created_at != nil { fields = append(fields, channelmonitor.FieldCreatedAt) } @@ -9963,6 +10021,9 @@ func (m *ChannelMonitorMutation) Fields() []string { if m.interval_seconds != nil { fields = append(fields, channelmonitor.FieldIntervalSeconds) } + if m.jitter_seconds != nil { + fields = append(fields, channelmonitor.FieldJitterSeconds) + } if m.last_checked_at != nil { fields = append(fields, channelmonitor.FieldLastCheckedAt) } @@ -10013,6 +10074,8 @@ func (m *ChannelMonitorMutation) Field(name string) (ent.Value, bool) { return m.Enabled() case channelmonitor.FieldIntervalSeconds: return m.IntervalSeconds() + case channelmonitor.FieldJitterSeconds: + return m.JitterSeconds() case channelmonitor.FieldLastCheckedAt: return m.LastCheckedAt() case channelmonitor.FieldCreatedBy: @@ -10058,6 +10121,8 @@ func (m *ChannelMonitorMutation) OldField(ctx context.Context, name string) (ent return m.OldEnabled(ctx) case channelmonitor.FieldIntervalSeconds: return m.OldIntervalSeconds(ctx) + case channelmonitor.FieldJitterSeconds: + return m.OldJitterSeconds(ctx) case channelmonitor.FieldLastCheckedAt: return m.OldLastCheckedAt(ctx) case channelmonitor.FieldCreatedBy: @@ -10163,6 +10228,13 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error { } m.SetIntervalSeconds(v) return nil + case channelmonitor.FieldJitterSeconds: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.SetJitterSeconds(v) + return nil case channelmonitor.FieldLastCheckedAt: v, ok := value.(time.Time) if !ok { @@ -10216,6 +10288,9 @@ func (m *ChannelMonitorMutation) AddedFields() []string { if m.addinterval_seconds != nil { fields = append(fields, channelmonitor.FieldIntervalSeconds) } + if m.addjitter_seconds != nil { + fields = append(fields, channelmonitor.FieldJitterSeconds) + } if m.addcreated_by != nil { fields = append(fields, channelmonitor.FieldCreatedBy) } @@ -10229,6 +10304,8 @@ func (m *ChannelMonitorMutation) AddedField(name string) (ent.Value, bool) { switch name { case channelmonitor.FieldIntervalSeconds: return m.AddedIntervalSeconds() + case channelmonitor.FieldJitterSeconds: + return m.AddedJitterSeconds() case channelmonitor.FieldCreatedBy: return m.AddedCreatedBy() } @@ -10247,6 +10324,13 @@ func (m *ChannelMonitorMutation) AddField(name string, value ent.Value) error { } m.AddIntervalSeconds(v) return nil + case channelmonitor.FieldJitterSeconds: + v, ok := value.(int) + if !ok { + return fmt.Errorf("unexpected type %T for field %s", value, name) + } + m.AddJitterSeconds(v) + return nil case channelmonitor.FieldCreatedBy: v, ok := value.(int64) if !ok { @@ -10344,6 +10428,9 @@ func (m *ChannelMonitorMutation) ResetField(name string) error { case channelmonitor.FieldIntervalSeconds: m.ResetIntervalSeconds() return nil + case channelmonitor.FieldJitterSeconds: + m.ResetJitterSeconds() + return nil case channelmonitor.FieldLastCheckedAt: m.ResetLastCheckedAt() return nil diff --git a/backend/ent/runtime/runtime.go b/backend/ent/runtime/runtime.go index 09d2a85f7d..6a467e26b3 100644 --- a/backend/ent/runtime/runtime.go +++ b/backend/ent/runtime/runtime.go @@ -529,12 +529,18 @@ func init() { channelmonitorDescIntervalSeconds := channelmonitorFields[9].Descriptor() // channelmonitor.IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save. channelmonitor.IntervalSecondsValidator = channelmonitorDescIntervalSeconds.Validators[0].(func(int) error) + // channelmonitorDescJitterSeconds is the schema descriptor for jitter_seconds field. + channelmonitorDescJitterSeconds := channelmonitorFields[10].Descriptor() + // channelmonitor.DefaultJitterSeconds holds the default value on creation for the jitter_seconds field. + channelmonitor.DefaultJitterSeconds = channelmonitorDescJitterSeconds.Default.(int) + // channelmonitor.JitterSecondsValidator is a validator for the "jitter_seconds" field. It is called by the builders before save. + channelmonitor.JitterSecondsValidator = channelmonitorDescJitterSeconds.Validators[0].(func(int) error) // channelmonitorDescExtraHeaders is the schema descriptor for extra_headers field. - channelmonitorDescExtraHeaders := channelmonitorFields[13].Descriptor() + channelmonitorDescExtraHeaders := channelmonitorFields[14].Descriptor() // channelmonitor.DefaultExtraHeaders holds the default value on creation for the extra_headers field. channelmonitor.DefaultExtraHeaders = channelmonitorDescExtraHeaders.Default.(map[string]string) // channelmonitorDescBodyOverrideMode is the schema descriptor for body_override_mode field. - channelmonitorDescBodyOverrideMode := channelmonitorFields[14].Descriptor() + channelmonitorDescBodyOverrideMode := channelmonitorFields[15].Descriptor() // channelmonitor.DefaultBodyOverrideMode holds the default value on creation for the body_override_mode field. channelmonitor.DefaultBodyOverrideMode = channelmonitorDescBodyOverrideMode.Default.(string) // channelmonitor.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save. diff --git a/backend/ent/schema/channel_monitor.go b/backend/ent/schema/channel_monitor.go index 431ab9c8dd..d9594ab39c 100644 --- a/backend/ent/schema/channel_monitor.go +++ b/backend/ent/schema/channel_monitor.go @@ -62,6 +62,10 @@ func (ChannelMonitor) Fields() []ent.Field { Default(true), field.Int("interval_seconds"). Range(15, 3600), + field.Int("jitter_seconds"). + Default(0). + Range(0, 3600). + Comment("每次调度在 interval 基础上 ± [0, jitter] 的均匀随机偏移(秒);0 表示固定间隔。service 层另保证 interval - jitter >= 15"), field.Time("last_checked_at"). Optional(). Nillable(), diff --git a/backend/internal/handler/admin/channel_monitor_handler.go b/backend/internal/handler/admin/channel_monitor_handler.go index 5560a51313..4ef774e9e7 100644 --- a/backend/internal/handler/admin/channel_monitor_handler.go +++ b/backend/internal/handler/admin/channel_monitor_handler.go @@ -46,6 +46,7 @@ type channelMonitorCreateRequest struct { GroupName string `json:"group_name" binding:"max=100"` Enabled *bool `json:"enabled"` IntervalSeconds int `json:"interval_seconds" binding:"required,min=15,max=3600"` + JitterSeconds int `json:"jitter_seconds" binding:"omitempty,min=0,max=3585"` TemplateID *int64 `json:"template_id"` ExtraHeaders map[string]string `json:"extra_headers"` BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"` @@ -63,6 +64,7 @@ type channelMonitorUpdateRequest struct { GroupName *string `json:"group_name" binding:"omitempty,max=100"` Enabled *bool `json:"enabled"` IntervalSeconds *int `json:"interval_seconds" binding:"omitempty,min=15,max=3600"` + JitterSeconds *int `json:"jitter_seconds" binding:"omitempty,min=0,max=3585"` TemplateID *int64 `json:"template_id"` ClearTemplate bool `json:"clear_template"` // true 时把 template_id 置空,忽略 TemplateID ExtraHeaders *map[string]string `json:"extra_headers"` @@ -83,6 +85,7 @@ type channelMonitorResponse struct { GroupName string `json:"group_name"` Enabled bool `json:"enabled"` IntervalSeconds int `json:"interval_seconds"` + JitterSeconds int `json:"jitter_seconds"` LastCheckedAt *string `json:"last_checked_at"` CreatedBy int64 `json:"created_by"` CreatedAt string `json:"created_at"` @@ -150,6 +153,7 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse GroupName: m.GroupName, Enabled: m.Enabled, IntervalSeconds: m.IntervalSeconds, + JitterSeconds: m.JitterSeconds, CreatedBy: m.CreatedBy, CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339), UpdatedAt: m.UpdatedAt.UTC().Format(time.RFC3339), @@ -315,6 +319,7 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) { GroupName: req.GroupName, Enabled: enabled, IntervalSeconds: req.IntervalSeconds, + JitterSeconds: req.JitterSeconds, CreatedBy: subject.UserID, TemplateID: req.TemplateID, ExtraHeaders: req.ExtraHeaders, @@ -351,6 +356,7 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) { GroupName: req.GroupName, Enabled: req.Enabled, IntervalSeconds: req.IntervalSeconds, + JitterSeconds: req.JitterSeconds, TemplateID: req.TemplateID, ClearTemplate: req.ClearTemplate, ExtraHeaders: req.ExtraHeaders, diff --git a/backend/internal/repository/channel_monitor_repo.go b/backend/internal/repository/channel_monitor_repo.go index 6666a130cc..c7b33f2bd9 100644 --- a/backend/internal/repository/channel_monitor_repo.go +++ b/backend/internal/repository/channel_monitor_repo.go @@ -45,6 +45,7 @@ func (r *channelMonitorRepository) Create(ctx context.Context, m *service.Channe SetGroupName(m.GroupName). SetEnabled(m.Enabled). SetIntervalSeconds(m.IntervalSeconds). + SetJitterSeconds(m.JitterSeconds). SetCreatedBy(m.CreatedBy). SetExtraHeaders(emptyHeadersIfNilRepo(m.ExtraHeaders)). SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)) @@ -88,6 +89,7 @@ func (r *channelMonitorRepository) Update(ctx context.Context, m *service.Channe SetGroupName(m.GroupName). SetEnabled(m.Enabled). SetIntervalSeconds(m.IntervalSeconds). + SetJitterSeconds(m.JitterSeconds). SetExtraHeaders(emptyHeadersIfNilRepo(m.ExtraHeaders)). SetBodyOverrideMode(defaultBodyModeRepo(m.BodyOverrideMode)) if m.TemplateID != nil { @@ -718,6 +720,7 @@ func entToServiceMonitor(row *dbent.ChannelMonitor) *service.ChannelMonitor { GroupName: row.GroupName, Enabled: row.Enabled, IntervalSeconds: row.IntervalSeconds, + JitterSeconds: row.JitterSeconds, LastCheckedAt: row.LastCheckedAt, CreatedBy: row.CreatedBy, CreatedAt: row.CreatedAt, diff --git a/backend/internal/service/channel_monitor_const.go b/backend/internal/service/channel_monitor_const.go index d1dffac51d..61f9f79894 100644 --- a/backend/internal/service/channel_monitor_const.go +++ b/backend/internal/service/channel_monitor_const.go @@ -123,6 +123,9 @@ var ( ErrChannelMonitorInvalidInterval = infraerrors.BadRequest( "CHANNEL_MONITOR_INVALID_INTERVAL", "interval_seconds must be in [15, 3600]", ) + ErrChannelMonitorInvalidJitter = infraerrors.BadRequest( + "CHANNEL_MONITOR_INVALID_JITTER", "jitter_seconds must be >= 0 and interval_seconds - jitter_seconds must be >= 15", + ) ErrChannelMonitorInvalidEndpoint = infraerrors.BadRequest( "CHANNEL_MONITOR_INVALID_ENDPOINT", "endpoint must be a valid https URL", ) diff --git a/backend/internal/service/channel_monitor_runner.go b/backend/internal/service/channel_monitor_runner.go index 08178bc65d..748ffc3638 100644 --- a/backend/internal/service/channel_monitor_runner.go +++ b/backend/internal/service/channel_monitor_runner.go @@ -3,6 +3,7 @@ package service import ( "context" "log/slog" + "math/rand/v2" "sync" "time" @@ -68,9 +69,25 @@ type scheduledMonitor struct { id int64 name string interval time.Duration + jitter time.Duration // 每轮 ± [0, jitter] 的均匀随机偏移;0 = 固定间隔 cancel context.CancelFunc } +// nextDelay 计算下一次触发的等待时长:interval ± [0, jitter] 的均匀随机偏移。 +// 校验链路已保证 interval - jitter >= monitorMinIntervalSeconds, +// 这里仍 clamp 一次下限,兜底数据库中违反约束的脏数据。 +func (t *scheduledMonitor) nextDelay() time.Duration { + if t.jitter <= 0 { + return t.interval + } + offset := time.Duration(rand.Int64N(int64(2*t.jitter) + 1)) // [0, 2*jitter] + d := t.interval - t.jitter + offset + if floor := monitorMinIntervalSeconds * time.Second; d < floor { + d = floor + } + return d +} + // NewChannelMonitorRunner 构造调度器。Start 在 wire 中调用一次。 // settingService 用于在每次 fire 前读取功能开关;传 nil 时视为总是启用(兼容测试)。 // @@ -141,6 +158,10 @@ func (r *ChannelMonitorRunner) Schedule(m *ChannelMonitor) { "monitor_id", m.ID, "interval_seconds", m.IntervalSeconds) return } + jitter := time.Duration(m.JitterSeconds) * time.Second + if jitter < 0 { + jitter = 0 + } r.mu.Lock() if r.stopped { @@ -165,6 +186,7 @@ func (r *ChannelMonitorRunner) Schedule(m *ChannelMonitor) { id: m.ID, name: m.Name, interval: interval, + jitter: jitter, cancel: cancel, } r.tasks[m.ID] = task @@ -211,20 +233,22 @@ func (r *ChannelMonitorRunner) Stop() { } // runScheduled 单个监控的循环:立即触发首次(满足"新建/启用即跑"), -// 之后按 interval 周期触发;ctx 取消即退出。 +// 之后按 interval ± jitter 周期触发;ctx 取消即退出。 +// 用 timer 而非 ticker:jitter > 0 时每轮等待时长都需要重新随机化。 func (r *ChannelMonitorRunner) runScheduled(ctx context.Context, task *scheduledMonitor) { defer r.wg.Done() r.fire(ctx, task) - ticker := time.NewTicker(task.interval) - defer ticker.Stop() + timer := time.NewTimer(task.nextDelay()) + defer timer.Stop() for { select { case <-ctx.Done(): return - case <-ticker.C: + case <-timer.C: r.fire(ctx, task) + timer.Reset(task.nextDelay()) } } } diff --git a/backend/internal/service/channel_monitor_service.go b/backend/internal/service/channel_monitor_service.go index 6eec0ae04d..7b53bb20b0 100644 --- a/backend/internal/service/channel_monitor_service.go +++ b/backend/internal/service/channel_monitor_service.go @@ -128,6 +128,7 @@ func (s *ChannelMonitorService) Create(ctx context.Context, p ChannelMonitorCrea GroupName: strings.TrimSpace(p.GroupName), Enabled: p.Enabled, IntervalSeconds: p.IntervalSeconds, + JitterSeconds: p.JitterSeconds, CreatedBy: p.CreatedBy, TemplateID: p.TemplateID, ExtraHeaders: emptyHeadersIfNil(p.ExtraHeaders), @@ -157,6 +158,9 @@ func validateCreateParams(p ChannelMonitorCreateParams) error { if err := validateInterval(p.IntervalSeconds); err != nil { return err } + if err := validateJitter(p.JitterSeconds, p.IntervalSeconds); err != nil { + return err + } if err := validateEndpoint(p.Endpoint); err != nil { return err } @@ -509,6 +513,15 @@ func applyMonitorUpdate(existing *ChannelMonitor, p ChannelMonitorUpdateParams) } existing.IntervalSeconds = *p.IntervalSeconds } + if p.JitterSeconds != nil { + existing.JitterSeconds = *p.JitterSeconds + } + if p.IntervalSeconds != nil || p.JitterSeconds != nil { + // interval 与 jitter 任一变化都需要重新校验组合约束(interval - jitter >= 下限)。 + if err := validateJitter(existing.JitterSeconds, existing.IntervalSeconds); err != nil { + return err + } + } return applyMonitorAdvancedUpdate(existing, p, providerChanged) } diff --git a/backend/internal/service/channel_monitor_types.go b/backend/internal/service/channel_monitor_types.go index ef86eeb8c5..0f68bc29e4 100644 --- a/backend/internal/service/channel_monitor_types.go +++ b/backend/internal/service/channel_monitor_types.go @@ -39,6 +39,7 @@ type ChannelMonitor struct { GroupName string Enabled bool IntervalSeconds int + JitterSeconds int // 每次调度 ± [0, jitter] 的随机偏移(秒),0 = 固定间隔 LastCheckedAt *time.Time CreatedBy int64 CreatedAt time.Time @@ -76,6 +77,7 @@ type ChannelMonitorCreateParams struct { GroupName string Enabled bool IntervalSeconds int + JitterSeconds int CreatedBy int64 TemplateID *int64 ExtraHeaders map[string]string @@ -95,6 +97,7 @@ type ChannelMonitorUpdateParams struct { GroupName *string Enabled *bool IntervalSeconds *int + JitterSeconds *int // 自定义快照字段:指针为 nil 表示不更新,非 nil 覆盖 // TemplateID *(*int64):用 ** 表达三态:nil=不更新;&nil=清空;&&id=设为 id。 // 简化处理:用 ClearTemplate 显式标志 + TemplateID(普通指针) diff --git a/backend/internal/service/channel_monitor_validate.go b/backend/internal/service/channel_monitor_validate.go index 6ff22b9fcf..c5a4783b91 100644 --- a/backend/internal/service/channel_monitor_validate.go +++ b/backend/internal/service/channel_monitor_validate.go @@ -43,6 +43,15 @@ func validateInterval(sec int) error { return nil } +// validateJitter 校验 jitter_seconds(调度 ± 随机抖动): +// 非负,且 interval - jitter 不得低于最小检测间隔,防止随机偏移后实际间隔过短打爆上游。 +func validateJitter(jitterSec, intervalSec int) error { + if jitterSec < 0 || intervalSec-jitterSec < monitorMinIntervalSeconds { + return ErrChannelMonitorInvalidJitter + } + return nil +} + // validateEndpoint 校验 endpoint: // - scheme 强制 https(拒绝 http,避免明文凭证 + 部分 SSRF 利用面) // - 必须为 origin(无 path/query/fragment),防止用户填 https://api.openai.com/v1 diff --git a/backend/migrations/151_channel_monitor_jitter.sql b/backend/migrations/151_channel_monitor_jitter.sql new file mode 100644 index 0000000000..740cefc450 --- /dev/null +++ b/backend/migrations/151_channel_monitor_jitter.sql @@ -0,0 +1,7 @@ +-- Migration: 151_channel_monitor_jitter +-- 渠道监控新增正负随机抖动配置:每次调度在 interval_seconds 基础上 +-- ± [0, jitter_seconds] 的均匀随机偏移,避免多个监控固定同步触发。 +-- 0(默认)表示固定间隔,与历史行为一致。 + +ALTER TABLE channel_monitors + ADD COLUMN IF NOT EXISTS jitter_seconds INTEGER NOT NULL DEFAULT 0; diff --git a/frontend/src/api/admin/channelMonitor.ts b/frontend/src/api/admin/channelMonitor.ts index bdef7d33ef..0b9c62231c 100644 --- a/frontend/src/api/admin/channelMonitor.ts +++ b/frontend/src/api/admin/channelMonitor.ts @@ -28,6 +28,8 @@ export interface ChannelMonitor { group_name: string enabled: boolean interval_seconds: number + /** 每次调度在 interval 基础上 ± [0, jitter] 的随机偏移(秒),0 = 固定间隔 */ + jitter_seconds: number last_checked_at: string | null created_by: number created_at: string @@ -80,6 +82,7 @@ export interface CreateParams { group_name?: string enabled?: boolean interval_seconds: number + jitter_seconds?: number template_id?: number | null extra_headers?: Record body_override_mode?: BodyOverrideMode diff --git a/frontend/src/components/admin/monitor/MonitorFormDialog.vue b/frontend/src/components/admin/monitor/MonitorFormDialog.vue index cc916c65c9..e6cab8edf1 100644 --- a/frontend/src/components/admin/monitor/MonitorFormDialog.vue +++ b/frontend/src/components/admin/monitor/MonitorFormDialog.vue @@ -109,6 +109,12 @@

{{ t('admin.channelMonitor.form.intervalSecondsHint') }}

+
+ + +

{{ t('admin.channelMonitor.form.jitterSecondsHint') }}

+
+
@@ -254,6 +260,7 @@ interface MonitorForm { extra_models: string[] group_name: string interval_seconds: number + jitter_seconds: number enabled: boolean // 高级设置快照 template_id: number | null @@ -272,6 +279,7 @@ const form = reactive({ extra_models: [], group_name: '', interval_seconds: systemDefaultInterval.value, + jitter_seconds: 0, enabled: true, template_id: null, extra_headers: {}, @@ -279,6 +287,9 @@ const form = reactive({ body_override: null, }) +// jitter 上限与后端校验一致:interval - jitter 不得低于最小检测间隔 15 秒。 +const maxJitterSeconds = computed(() => Math.max(0, (form.interval_seconds || 0) - 15)) + let suppressFormWatchers = false // 可用模板列表(进入 dialog 时一次性拉取 cache;按 provider / api mode 过滤)。 @@ -419,6 +430,7 @@ function resetForm() { form.extra_models = [] form.group_name = '' form.interval_seconds = systemDefaultInterval.value + form.jitter_seconds = 0 form.enabled = true form.template_id = null form.extra_headers = {} @@ -438,6 +450,7 @@ function loadFromMonitor(m: ChannelMonitor) { form.extra_models = [...(m.extra_models || [])] form.group_name = m.group_name || '' form.interval_seconds = m.interval_seconds || systemDefaultInterval.value + form.jitter_seconds = m.jitter_seconds || 0 form.enabled = m.enabled form.template_id = m.template_id ?? null form.extra_headers = { ...(m.extra_headers || {}) } @@ -504,6 +517,7 @@ function buildPayload(): CreateParams { group_name: form.group_name.trim(), enabled: form.enabled, interval_seconds: form.interval_seconds, + jitter_seconds: form.jitter_seconds || 0, template_id: form.template_id, extra_headers: form.extra_headers, body_override_mode: form.body_override_mode, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f71d62a19e..7d87bb81fd 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2835,6 +2835,8 @@ export default { groupNamePlaceholder: 'Optional, used to group rows in user view', intervalSeconds: 'Interval (seconds)', intervalSecondsHint: 'Range: 15 - 3600 seconds', + jitterSeconds: 'Random Jitter (± seconds)', + jitterSecondsHint: 'Each check fires at interval ± a random offset within this value; 0 means fixed interval. Interval minus jitter must be ≥ 15s', enabled: 'Enable monitor', kindRequired: 'Please select a provider' }, diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 996b38d6d9..c1d9796e5c 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2912,6 +2912,8 @@ export default { groupNamePlaceholder: '可选,用于在用户视图中聚合显示', intervalSeconds: '检测间隔 (秒)', intervalSecondsHint: '范围:15 - 3600 秒', + jitterSeconds: '随机抖动 (± 秒)', + jitterSecondsHint: '每次检测在间隔基础上正负随机偏移该秒数,0 表示固定间隔;需满足 间隔 - 抖动 ≥ 15 秒', enabled: '启用监控', kindRequired: '请选择供应商' },