Files
pika/internal/service/alert_rule_service.go

441 lines
14 KiB
Go

package service
import (
"context"
"fmt"
"slices"
"strings"
"time"
"github.com/go-orz/orz"
"github.com/google/uuid"
"github.com/pika-monitor/pika/internal/models"
"github.com/pika-monitor/pika/internal/repo"
"go.uber.org/zap"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// DefaultAlertRuleName 默认规则名称(首次启动/升级时从全局告警配置生成的规则)
const DefaultAlertRuleName = "默认规则"
// EffectiveAlertConfig 主机实际生效的告警配置(按优先级命中的第一条启用的告警规则)
type EffectiveAlertConfig struct {
ConfigID string // 告警规则 ID
Name string // 规则名称
MaskIP bool // 通知中是否打码 IP
Notifications models.AlertNotifications // 事件通知开关(流量/SSH登录/防篡改/机器到期)
Rules models.AlertRules // 告警规则
Channels []string // 通知渠道类型列表(空 = 所有启用渠道)
MaintenanceEnabled bool // 是否启用每日计划维护
MaintenanceStartTime string // 每日计划维护开始时间(HH:mm)
MaintenanceEndTime string // 每日计划维护结束时间(HH:mm)
}
type AlertRuleService struct {
logger *zap.Logger
*repo.AlertRuleRepo
*orz.Service
propertyService *PropertyService
agentRepo *repo.AgentRepo
alertStateRepo *repo.AlertStateRepo
alertRecordRepo *repo.AlertRecordRepo
}
func NewAlertRuleService(logger *zap.Logger, db *gorm.DB, propertyService *PropertyService) *AlertRuleService {
return &AlertRuleService{
logger: logger,
AlertRuleRepo: repo.NewAlertRuleRepo(db),
Service: orz.NewService(db),
propertyService: propertyService,
agentRepo: repo.NewAgentRepo(db),
alertStateRepo: repo.NewAlertStateRepo(db),
alertRecordRepo: repo.NewAlertRecordRepo(db),
}
}
type AlertRuleRequest struct {
Name string `json:"name"`
Priority int `json:"priority"`
Enabled bool `json:"enabled"`
TargetType string `json:"targetType"`
AgentIds []string `json:"agentIds"`
Tags []string `json:"tags"`
Rules models.AlertRules `json:"rules"`
Channels []string `json:"channels"`
MaskIP bool `json:"maskIP"`
Notifications models.AlertNotifications `json:"notifications"`
MaintenanceEnabled bool `json:"maintenanceEnabled"`
MaintenanceStartTime string `json:"maintenanceStartTime"`
MaintenanceEndTime string `json:"maintenanceEndTime"`
}
func (s *AlertRuleService) CreateRule(ctx context.Context, req *AlertRuleRequest) (*models.AlertRule, error) {
if err := s.normalizeRuleRequest(req); err != nil {
return nil, err
}
rule := &models.AlertRule{
ID: uuid.NewString(),
Name: strings.TrimSpace(req.Name),
Priority: req.Priority,
Enabled: req.Enabled,
TargetType: req.TargetType,
AgentIds: datatypes.JSONSlice[string](req.AgentIds),
Tags: datatypes.JSONSlice[string](req.Tags),
Rules: datatypes.NewJSONType(req.Rules),
Channels: datatypes.JSONSlice[string](req.Channels),
MaskIP: req.MaskIP,
Notifications: datatypes.NewJSONType(req.Notifications),
MaintenanceEnabled: req.MaintenanceEnabled,
MaintenanceStartTime: req.MaintenanceStartTime,
MaintenanceEndTime: req.MaintenanceEndTime,
}
if err := s.AlertRuleRepo.Create(ctx, rule); err != nil {
return nil, err
}
return rule, nil
}
func (s *AlertRuleService) UpdateRule(ctx context.Context, id string, req *AlertRuleRequest) (*models.AlertRule, error) {
if err := s.normalizeRuleRequest(req); err != nil {
return nil, err
}
rule, err := s.AlertRuleRepo.FindById(ctx, id)
if err != nil {
return nil, err
}
rule.Name = strings.TrimSpace(req.Name)
rule.Priority = req.Priority
rule.Enabled = req.Enabled
rule.TargetType = req.TargetType
rule.AgentIds = req.AgentIds
rule.Tags = req.Tags
rule.Rules = datatypes.NewJSONType(req.Rules)
rule.Channels = req.Channels
rule.MaskIP = req.MaskIP
rule.Notifications = datatypes.NewJSONType(req.Notifications)
rule.MaintenanceEnabled = req.MaintenanceEnabled
rule.MaintenanceStartTime = req.MaintenanceStartTime
rule.MaintenanceEndTime = req.MaintenanceEndTime
if err := s.AlertRuleRepo.Save(ctx, &rule); err != nil {
return nil, err
}
// 规则变更后清理其下所有告警状态(阈值/持续时间等已变化,重新累计)
s.cleanupStates(ctx, id, nil)
return &rule, nil
}
func (s *AlertRuleService) DeleteRule(ctx context.Context, id string) error {
if _, err := s.AlertRuleRepo.FindById(ctx, id); err != nil {
return err
}
if err := s.AlertRuleRepo.DeleteById(ctx, id); err != nil {
return err
}
// 清理该规则下所有主机的告警状态
s.cleanupStates(ctx, id, nil)
return nil
}
// UpdateRuleEnabled 更新规则启用状态(停用时其下主机不再命中该规则,清理告警状态)
func (s *AlertRuleService) UpdateRuleEnabled(ctx context.Context, id string, enabled bool) error {
rule, err := s.AlertRuleRepo.FindById(ctx, id)
if err != nil {
return err
}
if rule.Enabled == enabled {
return nil
}
rule.Enabled = enabled
if err := s.AlertRuleRepo.Save(ctx, &rule); err != nil {
return err
}
if !enabled {
s.cleanupStates(ctx, id, nil)
}
return nil
}
// SeedDefaultRule 不存在任何告警规则时,从全局告警配置生成一条默认规则(覆盖全部主机)
func (s *AlertRuleService) SeedDefaultRule(ctx context.Context) error {
count, err := s.AlertRuleRepo.Count(ctx)
if err != nil {
return err
}
if count > 0 {
return nil
}
alertConfig, err := s.propertyService.GetAlertConfig(ctx)
if err != nil {
return err
}
rule := &models.AlertRule{
ID: uuid.NewString(),
Name: DefaultAlertRuleName,
Priority: 100,
Enabled: true,
TargetType: models.AlertRuleTargetAll,
Rules: datatypes.NewJSONType(alertConfig.Rules),
MaskIP: alertConfig.MaskIP,
Notifications: datatypes.NewJSONType(alertConfig.Notifications),
MaintenanceStartTime: "02:00",
MaintenanceEndTime: "02:20",
}
if err := s.AlertRuleRepo.Create(ctx, rule); err != nil {
return err
}
s.logger.Info("已从全局告警配置生成默认告警规则", zap.String("ruleID", rule.ID))
return nil
}
// ResolveForAgent 解析主机实际生效的告警配置:
// 启用的规则按优先级升序取第一条命中的规则;未命中任何规则时返回 nil(该主机不产生告警)。
func (s *AlertRuleService) ResolveForAgent(ctx context.Context, agentID string) (*EffectiveAlertConfig, error) {
configs, err := s.ResolveForAgents(ctx, []string{agentID})
if err != nil {
return nil, err
}
return configs[agentID], nil
}
// ResolveForAgents 批量解析主机实际生效的告警配置(返回的 map 中不含未命中任何规则的主机)
func (s *AlertRuleService) ResolveForAgents(ctx context.Context, agentIDs []string) (map[string]*EffectiveAlertConfig, error) {
result := make(map[string]*EffectiveAlertConfig, len(agentIDs))
if len(agentIDs) == 0 {
return result, nil
}
rules, err := s.FindEnabledRulesSorted(ctx)
if err != nil {
return nil, err
}
if len(rules) == 0 {
return result, nil
}
// 查询主机信息用于启用状态检查和标签匹配。禁用主机不命中任何告警规则,
// 从而统一阻止指标、离线、到期和监控等所有类型的告警。
agentTagMap := make(map[string][]string, len(agentIDs))
enabledAgents := make(map[string]struct{}, len(agentIDs))
agents, err := s.agentRepo.FindByIdIn(ctx, agentIDs)
if err != nil {
return nil, err
}
for _, agent := range agents {
if !agent.Enabled {
continue
}
enabledAgents[agent.ID] = struct{}{}
agentTagMap[agent.ID] = agent.Tags
}
for _, agentID := range agentIDs {
if _, enabled := enabledAgents[agentID]; !enabled {
continue
}
for i := range rules {
if !ruleMatchesAgent(&rules[i], agentID, agentTagMap[agentID]) {
continue
}
rule := &rules[i]
result[agentID] = &EffectiveAlertConfig{
ConfigID: rule.ID,
Name: rule.Name,
MaskIP: rule.MaskIP,
Notifications: rule.Notifications.Data(),
Rules: rule.Rules.Data(),
Channels: rule.Channels,
MaintenanceEnabled: rule.MaintenanceEnabled,
MaintenanceStartTime: rule.MaintenanceStartTime,
MaintenanceEndTime: rule.MaintenanceEndTime,
}
break
}
}
return result, nil
}
// ruleMatchesAgent 判断规则是否命中主机(按目标类型:全部 / 指定主机 / 按标签)
func ruleMatchesAgent(rule *models.AlertRule, agentID string, agentTags []string) bool {
switch rule.TargetType {
case "", models.AlertRuleTargetAll:
return true
case models.AlertRuleTargetAgents:
return slices.Contains(rule.AgentIds, agentID)
case models.AlertRuleTargetTags:
for _, tag := range agentTags {
if slices.Contains(rule.Tags, tag) {
return true
}
}
return false
default:
return false
}
}
// cleanupStates 清理指定规则下的告警状态(agentIDs 为空时清理该规则下全部状态);
// 正在告警的记录直接标记为已恢复(规则调整而非指标恢复,不发送恢复通知)。
func (s *AlertRuleService) cleanupStates(ctx context.Context, configID string, agentIDs []string) {
states, err := s.alertStateRepo.FindStatesByConfigID(ctx, configID)
if err != nil {
s.logger.Warn("查询告警状态失败", zap.String("configID", configID), zap.Error(err))
return
}
agentSet := make(map[string]struct{}, len(agentIDs))
for _, agentID := range agentIDs {
agentSet[agentID] = struct{}{}
}
now := time.Now().UnixMilli()
for _, state := range states {
if len(agentSet) > 0 {
if _, ok := agentSet[state.AgentID]; !ok {
continue
}
}
if state.IsFiring && state.LastRecordID > 0 {
record, err := s.alertRecordRepo.GetAlertRecordByID(ctx, state.LastRecordID)
if err == nil && record.Status == "firing" {
record.Status = "resolved"
record.ResolvedAt = now
record.UpdatedAt = now
if err := s.alertRecordRepo.UpdateAlertRecord(ctx, record); err != nil {
s.logger.Warn("更新告警记录失败", zap.Int64("recordId", record.ID), zap.Error(err))
}
}
}
if err := s.alertStateRepo.DeleteAlertState(ctx, state.ID); err != nil {
s.logger.Warn("删除告警状态失败", zap.String("stateID", state.ID), zap.Error(err))
}
}
}
func (s *AlertRuleService) normalizeRuleRequest(req *AlertRuleRequest) error {
if strings.TrimSpace(req.Name) == "" {
return fmt.Errorf("规则名称不能为空")
}
req.MaintenanceStartTime = strings.TrimSpace(req.MaintenanceStartTime)
req.MaintenanceEndTime = strings.TrimSpace(req.MaintenanceEndTime)
if req.MaintenanceStartTime == "" && !req.MaintenanceEnabled {
req.MaintenanceStartTime = "02:00"
}
if req.MaintenanceEndTime == "" && !req.MaintenanceEnabled {
req.MaintenanceEndTime = "02:20"
}
if _, err := parseMaintenanceTime(req.MaintenanceStartTime); err != nil {
return fmt.Errorf("计划维护开始时间格式不正确,请使用 HH:mm 格式")
}
if _, err := parseMaintenanceTime(req.MaintenanceEndTime); err != nil {
return fmt.Errorf("计划维护结束时间格式不正确,请使用 HH:mm 格式")
}
if req.MaintenanceStartTime == req.MaintenanceEndTime {
return fmt.Errorf("计划维护开始时间和结束时间不能相同")
}
switch req.TargetType {
case "", models.AlertRuleTargetAll:
req.TargetType = models.AlertRuleTargetAll
req.AgentIds = nil
req.Tags = nil
case models.AlertRuleTargetAgents:
if len(req.AgentIds) == 0 {
return fmt.Errorf("请选择适用主机")
}
req.Tags = nil
case models.AlertRuleTargetTags:
if len(req.Tags) == 0 {
return fmt.Errorf("请选择标签")
}
req.AgentIds = nil
default:
return fmt.Errorf("不支持的主机目标类型: %s", req.TargetType)
}
return nil
}
// IsInMaintenance 判断指定时间是否处于每日计划维护时段。时间按服务端本地时区解释,
// 范围采用左闭右开;开始时间晚于结束时间时表示跨天。
func (c *EffectiveAlertConfig) IsInMaintenance(now time.Time) bool {
if c == nil || !c.MaintenanceEnabled {
return false
}
start, startErr := parseMaintenanceTime(c.MaintenanceStartTime)
end, endErr := parseMaintenanceTime(c.MaintenanceEndTime)
if startErr != nil || endErr != nil || start == end {
return false
}
localNow := now.In(time.Local)
current := localNow.Hour()*60 + localNow.Minute()
if start < end {
return current >= start && current < end
}
return current >= start || current < end
}
// ConditionStartAfterMaintenance 将异常累计起点截断到最近一次维护结束时间,
// 避免维护结束后把维护期间计入持续时间。未启用维护或起点更晚时保持原值。
func (c *EffectiveAlertConfig) ConditionStartAfterMaintenance(now time.Time, startTime int64) int64 {
if c == nil || !c.MaintenanceEnabled {
return startTime
}
startMinute, startErr := parseMaintenanceTime(c.MaintenanceStartTime)
endMinute, endErr := parseMaintenanceTime(c.MaintenanceEndTime)
if startErr != nil || endErr != nil || startMinute == endMinute {
return startTime
}
localNow := now.In(time.Local)
var latestEnd time.Time
for dayOffset := -2; dayOffset <= 0; dayOffset++ {
day := localNow.AddDate(0, 0, dayOffset)
windowStart := time.Date(day.Year(), day.Month(), day.Day(), startMinute/60, startMinute%60, 0, 0, time.Local)
endDay := day
if startMinute > endMinute {
endDay = day.AddDate(0, 0, 1)
}
windowEnd := time.Date(endDay.Year(), endDay.Month(), endDay.Day(), endMinute/60, endMinute%60, 0, 0, time.Local)
if windowStart.After(localNow) || windowEnd.After(localNow) {
continue
}
if latestEnd.IsZero() || windowEnd.After(latestEnd) {
latestEnd = windowEnd
}
}
if latestEnd.IsZero() || latestEnd.UnixMilli() <= startTime {
return startTime
}
return latestEnd.UnixMilli()
}
func parseMaintenanceTime(value string) (int, error) {
parsed, err := time.Parse("15:04", value)
if err != nil {
return 0, err
}
return parsed.Hour()*60 + parsed.Minute(), nil
}