Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/alert.go

315 lines
11 KiB
Go

package platform
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"strings"
)
type alertStore interface {
AlertSummary(context.Context, AlertQuery) (AlertSummary, error)
AlertEvents(context.Context, AlertQuery) (Page[AlertEvent], error)
AlertEvent(context.Context, string) (AlertEvent, error)
AlertRules(context.Context) ([]AlertRule, error)
SaveAlertRule(context.Context, AlertRuleInput) (AlertRule, error)
SetAlertRuleEnabled(context.Context, string, AlertRuleEnabledUpdate) (AlertRule, error)
ActOnAlert(context.Context, string, AlertActionRequest) (AlertEvent, error)
AlertNotifications(context.Context, AlertNotificationQuery) (Page[AlertNotification], error)
MarkAlertNotificationsRead(context.Context, AlertNotificationReadRequest) (int, error)
}
type alertEvaluatorStore interface {
EvaluateAlerts(context.Context) (AlertEvaluationResult, error)
}
func (s *Service) alertStore() (alertStore, error) {
store, ok := s.store.(alertStore)
if !ok {
return nil, fmt.Errorf("store does not provide durable alert center")
}
return store, nil
}
func (s *Service) AlertSummary(ctx context.Context, query AlertQuery) (AlertSummary, error) {
store, err := s.alertStore()
if err != nil {
return AlertSummary{}, err
}
return store.AlertSummary(ctx, normalizeAlertQuery(query))
}
func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[AlertEvent], error) {
store, err := s.alertStore()
if err != nil {
return Page[AlertEvent]{}, err
}
return store.AlertEvents(ctx, normalizeAlertQuery(query))
}
func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error) {
if strings.TrimSpace(id) == "" {
return AlertEvent{}, clientError{Code: "ALERT_EVENT_ID_REQUIRED", Message: "告警事件 ID 不能为空"}
}
store, err := s.alertStore()
if err != nil {
return AlertEvent{}, err
}
return store.AlertEvent(ctx, strings.TrimSpace(id))
}
func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) {
store, err := s.alertStore()
if err != nil {
return nil, err
}
return store.AlertRules(ctx)
}
func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) {
definitions, err := s.metricDefinitions(ctx)
if err != nil {
return AlertRule{}, err
}
if err := validateAlertRule(input, definitions...); err != nil {
return AlertRule{}, err
}
input.ID = strings.TrimSpace(input.ID)
if input.ID == "" {
id, err := newAlertID("rule")
if err != nil {
return AlertRule{}, err
}
input.ID = id
}
input.Actor = firstNonEmpty(strings.TrimSpace(input.Actor), "platform-admin")
input.ScopeProtocols = cleanUniqueStrings(input.ScopeProtocols)
input.ScopeVINs = cleanUniqueStrings(input.ScopeVINs)
input.ScopeOEMs = cleanUniqueStrings(input.ScopeOEMs)
input.ScopeModels = cleanUniqueStrings(input.ScopeModels)
input.ScopeCompanies = cleanUniqueStrings(input.ScopeCompanies)
if err := validateAlertRuleScopes(input); err != nil {
return AlertRule{}, err
}
input.NotificationChannels = normalizeAlertChannels(input.NotificationChannels)
if strings.EqualFold(input.Operator, "changed") {
input.DurationSec = 0
}
if strings.EqualFold(input.ValueType, "boolean") && input.BooleanThreshold != nil {
if *input.BooleanThreshold {
input.Threshold = 1
} else {
input.Threshold = 0
}
}
store, err := s.alertStore()
if err != nil {
return AlertRule{}, err
}
return store.SaveAlertRule(ctx, input)
}
func validateAlertRuleScopes(input AlertRuleInput) error {
for _, scope := range []struct {
name string
values []string
}{
{"协议", input.ScopeProtocols}, {"VIN", input.ScopeVINs}, {"厂家", input.ScopeOEMs},
{"车型", input.ScopeModels}, {"企业", input.ScopeCompanies},
} {
if len(scope.values) > 500 {
return clientError{Code: "ALERT_RULE_SCOPE_TOO_LARGE", Message: scope.name + "范围最多允许 500 项"}
}
for _, value := range scope.values {
if len([]rune(value)) > 128 {
return clientError{Code: "ALERT_RULE_SCOPE_VALUE_INVALID", Message: scope.name + "范围值不能超过 128 个字符"}
}
}
}
return nil
}
func (s *Service) SetAlertRuleEnabled(ctx context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) {
if strings.TrimSpace(id) == "" || update.Version <= 0 {
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_REQUIRED", Message: "规则 ID 和版本不能为空"}
}
update.Actor = firstNonEmpty(strings.TrimSpace(update.Actor), "platform-admin")
store, err := s.alertStore()
if err != nil {
return AlertRule{}, err
}
return store.SetAlertRuleEnabled(ctx, strings.TrimSpace(id), update)
}
func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertActionRequest) (AlertEvent, error) {
if strings.TrimSpace(id) == "" || request.Version <= 0 {
return AlertEvent{}, clientError{Code: "ALERT_EVENT_VERSION_REQUIRED", Message: "事件 ID 和版本不能为空"}
}
request.Action = strings.ToLower(strings.TrimSpace(request.Action))
if request.Action != "acknowledge" && request.Action != "close" && request.Action != "ignore" {
return AlertEvent{}, clientError{Code: "ALERT_ACTION_INVALID", Message: "仅支持确认、关闭或忽略告警"}
}
if len([]rune(request.Note)) > 200 {
return AlertEvent{}, clientError{Code: "ALERT_NOTE_TOO_LONG", Message: "处置备注不能超过 200 字"}
}
request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
store, err := s.alertStore()
if err != nil {
return AlertEvent{}, err
}
return store.ActOnAlert(ctx, strings.TrimSpace(id), request)
}
func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificationQuery) (Page[AlertNotification], error) {
if query.Limit <= 0 {
query.Limit = 20
}
if query.Limit > 100 {
query.Limit = 100
}
if query.Offset < 0 {
query.Offset = 0
}
store, err := s.alertStore()
if err != nil {
return Page[AlertNotification]{}, err
}
return store.AlertNotifications(ctx, query)
}
func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertNotificationReadRequest) (int, error) {
if len(request.IDs) == 0 || len(request.IDs) > 100 {
return 0, clientError{Code: "ALERT_NOTIFICATION_IDS_INVALID", Message: "请选择 1 到 100 条通知"}
}
request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin")
store, err := s.alertStore()
if err != nil {
return 0, err
}
return store.MarkAlertNotificationsRead(ctx, request)
}
func (s *Service) EvaluateAlerts(ctx context.Context) (AlertEvaluationResult, error) {
store, ok := s.store.(alertEvaluatorStore)
if !ok {
return AlertEvaluationResult{}, fmt.Errorf("store does not provide alert evaluation")
}
return store.EvaluateAlerts(ctx)
}
func normalizeAlertQuery(query AlertQuery) AlertQuery {
query.Keyword = strings.TrimSpace(query.Keyword)
query.Severity = strings.ToLower(strings.TrimSpace(query.Severity))
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
query.RuleID = strings.TrimSpace(query.RuleID)
query.Protocol = strings.TrimSpace(query.Protocol)
if query.Limit <= 0 {
query.Limit = 20
}
if query.Limit > 200 {
query.Limit = 200
}
if query.Offset < 0 {
query.Offset = 0
}
return query
}
func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error {
if strings.TrimSpace(input.Name) == "" || len([]rune(input.Name)) > 80 {
return clientError{Code: "ALERT_RULE_NAME_INVALID", Message: "规则名称不能为空且不能超过 80 字"}
}
severity := strings.ToLower(strings.TrimSpace(input.Severity))
if severity != "critical" && severity != "major" && severity != "minor" {
return clientError{Code: "ALERT_RULE_SEVERITY_INVALID", Message: "告警级别仅支持 critical、major、minor"}
}
valueType := strings.ToLower(strings.TrimSpace(input.ValueType))
if valueType != "numeric" && valueType != "boolean" {
return clientError{Code: "ALERT_RULE_VALUE_TYPE_INVALID", Message: "规则值类型仅支持 numeric 或 boolean"}
}
if strings.TrimSpace(input.Metric) == "" {
return clientError{Code: "ALERT_RULE_METRIC_REQUIRED", Message: "规则指标不能为空"}
}
var metric *MetricDefinition
definitions := catalog
if len(definitions) == 0 {
definitions = metricDefinitions()
}
for index := range definitions {
candidate := definitions[index]
if candidate.Key == strings.TrimSpace(input.Metric) {
metric = &candidate
break
}
}
if metric == nil || !metric.Alertable {
return clientError{Code: "ALERT_RULE_METRIC_INVALID", Message: "规则指标不在可告警指标目录中"}
}
if _, supported := alertMetricValue(input.Metric, alertEvaluationEvidence{}); !supported {
return clientError{Code: "ALERT_RULE_METRIC_UNSUPPORTED", Message: "规则指标尚未接入告警评估器"}
}
if metric.ValueType != valueType {
return clientError{Code: "ALERT_RULE_METRIC_TYPE_MISMATCH", Message: "规则值类型与指标目录不一致"}
}
operator := strings.ToLower(strings.TrimSpace(input.Operator))
allowed := map[string]bool{"gt": true, "gte": true, "lt": true, "lte": true, "eq": true, "neq": true, "between": true, "outside": true, "changed": true}
if !allowed[operator] {
return clientError{Code: "ALERT_RULE_OPERATOR_INVALID", Message: "规则比较符无效"}
}
if (operator == "between" || operator == "outside") && (valueType != "numeric" || input.ThresholdHigh <= input.Threshold) {
return clientError{Code: "ALERT_RULE_RANGE_INVALID", Message: "区间规则必须是数值型且上限大于下限"}
}
if operator == "changed" && valueType != "boolean" {
return clientError{Code: "ALERT_RULE_CHANGE_INVALID", Message: "状态变化规则必须使用布尔值类型"}
}
if valueType == "boolean" && operator != "changed" && input.BooleanThreshold == nil {
return clientError{Code: "ALERT_RULE_BOOLEAN_REQUIRED", Message: "布尔规则必须配置目标值"}
}
if input.DurationSec < 0 || input.DurationSec > 86400 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 {
return clientError{Code: "ALERT_RULE_INTERVAL_INVALID", Message: "持续时间或重复间隔超出允许范围"}
}
if input.Version < 0 {
return clientError{Code: "ALERT_RULE_VERSION_INVALID", Message: "规则版本无效"}
}
return nil
}
func normalizeAlertChannels(values []string) []string {
allowed := map[string]bool{"in_app": true, "sms": true, "email": true, "wecom": true}
out := make([]string, 0, len(values)+1)
seen := map[string]bool{}
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if allowed[value] && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
if !seen["in_app"] {
out = append([]string{"in_app"}, out...)
}
return out
}
func cleanUniqueStrings(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
return out
}
func newAlertID(prefix string) (string, error) {
buf := make([]byte, 10)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return prefix + "-" + hex.EncodeToString(buf), nil
}