feat(渠道监控): 检测间隔支持正负随机抖动配置

新增 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 <noreply@anthropic.com>
This commit is contained in:
bwlc
2026-06-12 22:09:53 +08:00
co-authored by Claude Fable 5
parent e34ad2b194
commit c70c6a2659
21 changed files with 420 additions and 11 deletions
+12 -1
View File
@@ -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))
@@ -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()
+45
View File
@@ -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))
+90
View File
@@ -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) {
+64
View File
@@ -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)
}
+4 -3
View File
@@ -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]},
},
},
}
+88 -1
View File
@@ -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
+8 -2
View File
@@ -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.
+4
View File
@@ -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(),
@@ -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,
@@ -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,
@@ -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",
)
@@ -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 而非 tickerjitter > 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())
}
}
}
@@ -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)
}
@@ -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(普通指针)
@@ -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
@@ -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;
+3
View File
@@ -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<string, string>
body_override_mode?: BodyOverrideMode
@@ -109,6 +109,12 @@
<p class="mt-1 text-xs text-gray-400">{{ t('admin.channelMonitor.form.intervalSecondsHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.channelMonitor.form.jitterSeconds') }}</label>
<input v-model.number="form.jitter_seconds" type="number" min="0" :max="maxJitterSeconds" class="input" />
<p class="mt-1 text-xs text-gray-400">{{ t('admin.channelMonitor.form.jitterSecondsHint') }}</p>
</div>
<div class="flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.channelMonitor.form.enabled') }}</label>
<Toggle v-model="form.enabled" />
@@ -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<MonitorForm>({
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<MonitorForm>({
body_override: null,
})
// jitter interval - jitter 15
const maxJitterSeconds = computed<number>(() => 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,
+2
View File
@@ -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'
},
+2
View File
@@ -2912,6 +2912,8 @@ export default {
groupNamePlaceholder: '可选,用于在用户视图中聚合显示',
intervalSeconds: '检测间隔 (秒)',
intervalSecondsHint: '范围:15 - 3600 秒',
jitterSeconds: '随机抖动 (± 秒)',
jitterSecondsHint: '每次检测在间隔基础上正负随机偏移该秒数,0 表示固定间隔;需满足 间隔 - 抖动 ≥ 15 秒',
enabled: '启用监控',
kindRequired: '请选择供应商'
},