315 lines
11 KiB
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
|
|
}
|