fix: make log alert delivery durable

This commit is contained in:
zxr
2026-07-21 02:32:18 +08:00
parent ce559d5218
commit 6e66bad884
8 changed files with 709 additions and 305 deletions

View File

@@ -1,29 +1,31 @@
package models
import "time"
// AlertOutbox 表示待发送或重试中的告警任务。
type AlertOutbox struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// LogEventID 关联日志事件 ID。
LogEventID uint `gorm:"index" json:"log_event_id"`
// PayloadJSON 保存 AlertReceiveBody 的 JSON 文本。
PayloadJSON string `gorm:"type:text" json:"payload_json"`
// Status 任务状态pending/retrying/sent/dead。
Status string `gorm:"size:32;index" json:"status"`
// RetryCount 已重试次数。
RetryCount int `json:"retry_count"`
// NextRetryAt 下一次可重试时间。
NextRetryAt time.Time `gorm:"index" json:"next_retry_at"`
// LastError 最近一次错误信息
LastError string `gorm:"type:text" json:"last_error"`
}
func (AlertOutbox) TableName() string {
return "logs_alert_outbox"
}
package models
import "time"
// AlertOutbox 表示待发送或重试中的告警任务。
type AlertOutbox struct {
ID uint `gorm:"primaryKey;index:idx_logs_alert_outbox_event_latest,priority:2" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// LogEventID 关联日志事件 ID。
LogEventID uint `gorm:"index;index:idx_logs_alert_outbox_event_latest,priority:1" json:"log_event_id"`
// PayloadJSON 保存 AlertReceiveBody 的 JSON 文本。
PayloadJSON string `gorm:"type:text" json:"payload_json"`
// Status 任务状态pending/processing/retrying/sent/dead。
Status string `gorm:"size:32;index" json:"status"`
// RetryCount 已重试次数。
RetryCount int `json:"retry_count"`
// NextRetryAt 下一次可重试时间。
NextRetryAt time.Time `gorm:"index" json:"next_retry_at"`
// LeaseUntil/LeaseOwner 用于多实例原子领取,避免同一任务并发发送
LeaseUntil *time.Time `gorm:"index" json:"lease_until,omitempty"`
LeaseOwner string `gorm:"size:128;index" json:"lease_owner"`
// LastError 最近一次错误信息。
LastError string `gorm:"type:text" json:"last_error"`
}
func (AlertOutbox) TableName() string {
return "logs_alert_outbox"
}

View File

@@ -1,45 +1,49 @@
package models
import "time"
// LogEvent 表示一条归一化/存储后的日志事件。
type LogEvent struct {
// ID 是数据库主键。
ID uint `gorm:"primaryKey" json:"id"`
// CreatedAt 记录创建时间(写入日志事件时)。
CreatedAt time.Time `json:"created_at"`
// SourceKind 表示日志来源类型(例如 trap/syslog 等)。
SourceKind string `gorm:"size:16;index" json:"source_kind"`
// RemoteAddr 表示日志发送方地址。
RemoteAddr string `gorm:"size:64" json:"remote_addr"`
// RawPayload 保存原始负载内容。
RawPayload string `gorm:"type:text" json:"raw_payload"`
// NormalizedSummary 保存归一化后的摘要信息。
NormalizedSummary string `gorm:"type:text" json:"normalized_summary"`
// NormalizedDetail 保存归一化后的详细信息。
NormalizedDetail string `gorm:"type:text" json:"normalized_detail"`
// DeviceName 表示关联设备名称。
DeviceName string `gorm:"size:512;index" json:"device_name"`
// SourceIP 表示原始来源 IP不含端口
SourceIP string `gorm:"size:64;index" json:"source_ip"`
// ResourceType 表示关联到的资源类型。
ResourceType string `gorm:"size:32;index" json:"resource_type"`
// ResourceID 表示关联到的资源 ID
ResourceID string `gorm:"size:128;index" json:"resource_id"`
// ResourceName 表示关联到的资源名称
ResourceName string `gorm:"size:256" json:"resource_name"`
// MatchMethod 表示资源命中方式ip/hostname/none
MatchMethod string `gorm:"size:32" json:"match_method"`
// DispatchStatus 表示告警分发状态not_applicable/pending/retrying/sent/dead)。
DispatchStatus string `gorm:"size:32;index" json:"dispatch_status"`
// SeverityCode 表示告警/严重度编码
SeverityCode string `gorm:"size:32" json:"severity_code"`
// TrapOID 表示关联的 Trap OID若来源为 trap
TrapOID string `gorm:"size:512;index" json:"trap_oid"`
// AlertSent 表示是否已将告警发送出去
AlertSent bool `json:"alert_sent"`
}
func (LogEvent) TableName() string {
return "logs_events"
}
package models
import "time"
// LogEvent 表示一条归一化/存储后的日志事件。
type LogEvent struct {
// ID 是数据库主键。
ID uint `gorm:"primaryKey" json:"id"`
// CreatedAt 记录创建时间(写入日志事件时)。
CreatedAt time.Time `json:"created_at"`
// SourceKind 表示日志来源类型(例如 trap/syslog 等)。
SourceKind string `gorm:"size:16;index" json:"source_kind"`
// RemoteAddr 表示日志发送方地址。
RemoteAddr string `gorm:"size:64" json:"remote_addr"`
// RawPayload 保存原始负载内容。
RawPayload string `gorm:"type:text" json:"raw_payload"`
// NormalizedSummary 保存归一化后的摘要信息。
NormalizedSummary string `gorm:"type:text" json:"normalized_summary"`
// NormalizedDetail 保存归一化后的详细信息。
NormalizedDetail string `gorm:"type:text" json:"normalized_detail"`
// DeviceName 表示关联设备名称。
DeviceName string `gorm:"size:512;index" json:"device_name"`
// SourceIP 表示原始来源 IP不含端口
SourceIP string `gorm:"size:64;index" json:"source_ip"`
// ResourceType 表示关联到的资源类型。
ResourceType string `gorm:"size:32;index" json:"resource_type"`
// ResourceUID 是跨服务使用的规范资源标识
ResourceUID string `gorm:"size:255;index" json:"resource_uid"`
// ResourceID 表示关联到的资源 ID
ResourceID string `gorm:"size:128;index" json:"resource_id"`
// ResourceName 表示关联到的资源名称
ResourceName string `gorm:"size:256" json:"resource_name"`
// MatchMethod 表示资源命中方式ip/hostname/none)。
MatchMethod string `gorm:"size:32" json:"match_method"`
// DispatchStatus 表示告警分发状态not_applicable/pending/retrying/sent/dead
DispatchStatus string `gorm:"size:32;index" json:"dispatch_status"`
// DispatchOutboxID 标识当前一次分发,防止旧任务覆盖较新的重放状态
DispatchOutboxID uint `gorm:"not null;default:0;index" json:"dispatch_outbox_id"`
// SeverityCode 表示告警/严重度编码
SeverityCode string `gorm:"size:32" json:"severity_code"`
// TrapOID 表示关联的 Trap OID若来源为 trap
TrapOID string `gorm:"size:512;index" json:"trap_oid"`
// AlertSent 表示是否已将告警发送出去。
AlertSent bool `json:"alert_sent"`
}
func (LogEvent) TableName() string {
return "logs_events"
}

View File

@@ -23,6 +23,9 @@ func InitData(db *gorm.DB) error {
if db == nil {
return nil
}
if err := backfillDispatchOutboxIDs(db); err != nil {
return err
}
if err := seedDefaultSyslogRules(db); err != nil {
return err
}
@@ -35,6 +38,52 @@ func InitData(db *gorm.DB) error {
return nil
}
func backfillDispatchOutboxIDs(db *gorm.DB) error {
const postgresUpdate = `
WITH latest AS (
SELECT target_event.id AS log_event_id, current_outbox.id, current_outbox.status
FROM logs_events AS target_event
JOIN LATERAL (
SELECT id, status
FROM logs_alert_outbox
WHERE log_event_id = target_event.id
ORDER BY id DESC
LIMIT 1
) AS current_outbox ON TRUE
WHERE target_event.dispatch_outbox_id = 0
)
UPDATE logs_events AS target_event
SET dispatch_outbox_id = latest.id,
dispatch_status = CASE latest.status WHEN 'processing' THEN 'retrying' ELSE latest.status END,
alert_sent = CASE WHEN latest.status = 'sent' THEN TRUE ELSE FALSE END
FROM latest
WHERE target_event.id = latest.log_event_id
AND target_event.dispatch_outbox_id = 0`
const mysqlUpdate = `
UPDATE logs_events AS target_event
JOIN (
SELECT candidate.id AS log_event_id, MAX(outbox.id) AS id
FROM logs_events AS candidate
JOIN logs_alert_outbox AS outbox ON outbox.log_event_id = candidate.id
WHERE candidate.dispatch_outbox_id = 0
GROUP BY candidate.id
) AS latest_id ON latest_id.log_event_id = target_event.id
JOIN logs_alert_outbox AS latest ON latest.id = latest_id.id
SET dispatch_outbox_id = latest.id,
dispatch_status = CASE latest.status WHEN 'processing' THEN 'retrying' ELSE latest.status END,
alert_sent = CASE WHEN latest.status = 'sent' THEN TRUE ELSE FALSE END
WHERE target_event.dispatch_outbox_id = 0`
switch db.Dialector.Name() {
case "postgres":
return db.Exec(postgresUpdate).Error
case "mysql":
return db.Exec(mysqlUpdate).Error
default:
return gorm.ErrUnsupportedDriver
}
}
func seedDefaultSyslogRules(db *gorm.DB) error {
var cnt int64
if err := db.Model(&SyslogRule{}).Count(&cnt).Error; err != nil {