package platform import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "strings" "time" ) 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) AlertRulePage(context.Context, AlertRuleQuery) (AlertRulePage, error) AlertRuleRevisions(context.Context, string) ([]AlertRuleRevision, error) SaveAlertRule(context.Context, AlertRuleInput) (AlertRule, error) SetAlertRuleEnabled(context.Context, string, AlertRuleEnabledUpdate) (AlertRule, error) SetAlertRuleArchived(context.Context, string, bool, AlertRuleLifecycleRequest) (AlertRule, error) ActOnAlert(context.Context, string, AlertActionRequest) (AlertEvent, error) AlertNotifications(context.Context, AlertNotificationQuery) (Page[AlertNotification], error) MarkAlertNotificationsRead(context.Context, AlertNotificationReadRequest) (int, error) RetryAlertNotification(context.Context, int64, AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) AlertNotificationRetryAudits(context.Context, int64) ([]AlertNotificationRetryAudit, error) } type alertEvaluatorStore interface { EvaluateAlerts(context.Context) (AlertEvaluationResult, error) } type alertNotificationHealthStore interface { AlertNotificationDeliveryHealth(context.Context) (AlertNotificationDeliveryHealth, 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) { if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" { if strings.TrimSpace(query.Keyword) == "" { return Page[AlertEvent]{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "客户账号仅可查询已授权车辆的告警"} } vin, err := s.resolveVehicleVIN(ctx, query.Keyword, query.Protocol) if err != nil { return Page[AlertEvent]{}, err } if err := authorizeVehicleVIN(ctx, vin); err != nil { return Page[AlertEvent]{}, err } query.Keyword = vin } store, err := s.alertStore() if err != nil { return Page[AlertEvent]{}, err } page, err := store.AlertEvents(ctx, normalizeAlertQuery(query)) if err != nil { return Page[AlertEvent]{}, err } for index := range page.Items { page.Items[index] = normalizeVehicleEvent(page.Items[index]) } return page, nil } 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 } event, err := store.AlertEvent(ctx, strings.TrimSpace(id)) if err != nil { return AlertEvent{}, err } return normalizeVehicleEvent(event), nil } func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) { store, err := s.alertStore() if err != nil { return nil, err } return store.AlertRules(ctx) } func normalizeAlertRuleQuery(query AlertRuleQuery) AlertRuleQuery { query.Keyword = strings.TrimSpace(query.Keyword) if len([]rune(query.Keyword)) > 160 { query.Keyword = string([]rune(query.Keyword)[:160]) } query.Protocol = strings.TrimSpace(query.Protocol) query.Status = strings.ToLower(strings.TrimSpace(query.Status)) if query.Status != "enabled" && query.Status != "disabled" { query.Status = "all" } query.Lifecycle = strings.ToLower(strings.TrimSpace(query.Lifecycle)) if query.Lifecycle != "archived" { query.Lifecycle = "current" } if query.Limit != 20 && query.Limit != 50 { query.Limit = 10 } if query.Offset < 0 { query.Offset = 0 } query.Offset = query.Offset / query.Limit * query.Limit return query } func (s *Service) AlertRulePage(ctx context.Context, query AlertRuleQuery) (AlertRulePage, error) { if err := authorizeInternalOperations(ctx, true); err != nil { return AlertRulePage{}, err } store, err := s.alertStore() if err != nil { return AlertRulePage{}, err } return store.AlertRulePage(ctx, normalizeAlertRuleQuery(query)) } func (s *Service) AlertNotificationConfig(ctx context.Context) (AlertNotificationConfig, error) { if err := authorizeInternalOperations(ctx, false); err != nil { return AlertNotificationConfig{}, err } return NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig), nil } func (s *Service) AlertNotificationDeliveryHealth(ctx context.Context) (AlertNotificationDeliveryHealth, error) { if err := authorizeInternalOperations(ctx, false); err != nil { return AlertNotificationDeliveryHealth{}, err } store, ok := s.store.(alertNotificationHealthStore) if !ok { return AlertNotificationDeliveryHealth{}, fmt.Errorf("store does not provide notification delivery health") } health, err := store.AlertNotificationDeliveryHealth(ctx) if err != nil { return AlertNotificationDeliveryHealth{}, err } config := NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig) configured := map[string]AlertNotificationChannelCapability{} for _, channel := range config.Channels { configured[channel.Channel] = channel } byChannel := map[string]AlertNotificationChannelHealth{} for _, channel := range health.Channels { byChannel[channel.Channel] = channel } health.Channels = make([]AlertNotificationChannelHealth, 0, len(config.Channels)) for _, capability := range config.Channels { channel := byChannel[capability.Channel] channel.Channel = capability.Channel channel.Label = capability.Label channel.Configured = capability.Configured health.Channels = append(health.Channels, channel) } if health.AsOf == "" { health.AsOf = time.Now().Format(time.RFC3339) } return health, nil } func (s *Service) AlertRuleRevisions(ctx context.Context, id string) ([]AlertRuleRevision, error) { id = strings.TrimSpace(id) if id == "" { return nil, clientError{Code: "ALERT_RULE_ID_REQUIRED", Message: "自动化 ID 不能为空"} } store, err := s.alertStore() if err != nil { return nil, err } return store.AlertRuleRevisions(ctx, id) } func alertRuleInputFromRule(rule AlertRule) AlertRuleInput { return AlertRuleInput{ ID: rule.ID, Name: rule.Name, Description: rule.Description, TriggerType: rule.TriggerType, FenceName: rule.FenceName, FenceLongitude: rule.FenceLongitude, FenceLatitude: rule.FenceLatitude, FenceRadiusM: rule.FenceRadiusM, Severity: rule.Severity, ValueType: rule.ValueType, Metric: rule.Metric, Operator: rule.Operator, Threshold: rule.Threshold, ThresholdHigh: rule.ThresholdHigh, BooleanThreshold: rule.BooleanThreshold, DurationSec: rule.DurationSec, RecoveryOperator: rule.RecoveryOperator, RecoveryThreshold: rule.RecoveryThreshold, RepeatIntervalSec: rule.RepeatIntervalSec, ScopeProtocols: append([]string(nil), rule.ScopeProtocols...), ScopeVINs: append([]string(nil), rule.ScopeVINs...), ScopeOEMs: append([]string(nil), rule.ScopeOEMs...), ScopeModels: append([]string(nil), rule.ScopeModels...), ScopeCompanies: append([]string(nil), rule.ScopeCompanies...), NotificationChannels: append([]string(nil), rule.NotificationChannels...), NotificationTargets: append([]AlertNotificationTarget(nil), rule.NotificationTargets...), Enabled: rule.Enabled, Version: rule.Version, } } func alertRuleConfigurationSignature(input AlertRuleInput) string { input.Version = 0 input.Actor = "" input.AuditAction = "" value, _ := json.Marshal(input) return string(value) } func (s *Service) RollbackAlertRule(ctx context.Context, id string, request AlertRuleRollbackRequest) (AlertRule, error) { id = strings.TrimSpace(id) if id == "" || request.TargetVersion <= 0 || request.CurrentVersion <= 0 { return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_VERSION_REQUIRED", Message: "自动化 ID、当前版本和目标版本不能为空"} } if request.TargetVersion == request.CurrentVersion { return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_TARGET_INVALID", Message: "目标版本必须与当前版本不同"} } store, err := s.alertStore() if err != nil { return AlertRule{}, err } rules, err := store.AlertRules(ctx) if err != nil { return AlertRule{}, err } var current *AlertRule for index := range rules { if rules[index].ID == id { current = &rules[index] break } } if current == nil { return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"} } if current.Version != request.CurrentVersion { return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"} } revisions, err := store.AlertRuleRevisions(ctx, id) if err != nil { return AlertRule{}, err } var target *AlertRule for index := range revisions { if revisions[index].Version == request.TargetVersion { target = &revisions[index].Snapshot break } } if target == nil { return AlertRule{}, clientError{Code: "ALERT_RULE_REVISION_NOT_FOUND", Message: "目标版本不存在或已被清理"} } next := alertRuleInputFromRule(*target) next.ID = id next.Version = current.Version next.Enabled = current.Enabled next.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin") next.AuditAction = "rollback" if alertRuleConfigurationSignature(next) == alertRuleConfigurationSignature(alertRuleInputFromRule(*current)) { return AlertRule{}, clientError{Code: "ALERT_RULE_ROLLBACK_NO_CHANGES", Message: "目标版本与当前配置相同,无需恢复"} } return s.SaveAlertRule(ctx, next) } func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (AlertRule, error) { if err := authorizeInternalOperations(ctx, true); err != nil { return AlertRule{}, err } normalizeAlertRuleTrigger(&input) 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 } if err := normalizeAlertNotificationTargets(&input); err != nil { return AlertRule{}, err } if err := s.validateAlertNotificationTargets(input.NotificationTargets); err != nil { return AlertRule{}, err } 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 err := authorizeInternalOperations(ctx, true); err != nil { return AlertRule{}, err } 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 normalizeAlertRuleLifecycleRequest(request AlertRuleLifecycleRequest) (AlertRuleLifecycleRequest, error) { request.Reason = strings.TrimSpace(request.Reason) if request.Version <= 0 { return request, clientError{Code: "ALERT_RULE_VERSION_REQUIRED", Message: "规则版本不能为空"} } if len([]rune(request.Reason)) < 4 { return request, clientError{Code: "ALERT_RULE_ARCHIVE_REASON_REQUIRED", Message: "请填写至少 4 个字符的归档或恢复原因"} } if len([]rune(request.Reason)) > 200 { return request, clientError{Code: "ALERT_RULE_ARCHIVE_REASON_TOO_LONG", Message: "归档或恢复原因不能超过 200 字"} } request.Actor = firstNonEmpty(strings.TrimSpace(request.Actor), "platform-admin") return request, nil } func (s *Service) SetAlertRuleArchived(ctx context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) { if err := authorizeInternalOperations(ctx, true); err != nil { return AlertRule{}, err } id = strings.TrimSpace(id) if id == "" { return AlertRule{}, clientError{Code: "ALERT_RULE_ID_REQUIRED", Message: "自动化 ID 不能为空"} } normalized, err := normalizeAlertRuleLifecycleRequest(request) if err != nil { return AlertRule{}, err } store, err := s.alertStore() if err != nil { return AlertRule{}, err } return store.SetAlertRuleArchived(ctx, id, archived, normalized) } 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 } event, err := store.ActOnAlert(ctx, strings.TrimSpace(id), request) if err != nil { return AlertEvent{}, err } return normalizeVehicleEvent(event), nil } func normalizeVehicleEvent(event AlertEvent) AlertEvent { event.EventCategory, event.EventType = canonicalVehicleEvent(event.TriggerType, event.Metric, event.Operator) switch strings.ToLower(strings.TrimSpace(event.Status)) { case "processing": event.ExecutionState = "processing" case "recovered": event.ExecutionState = "recovered" case "closed": event.ExecutionState = "completed" case "ignored": event.ExecutionState = "ignored" default: event.ExecutionState = "pending" } return event } func canonicalVehicleEvent(triggerType, metric, operator string) (string, string) { triggerType = strings.ToLower(strings.TrimSpace(triggerType)) metric = strings.ToLower(strings.TrimSpace(metric)) operator = strings.ToLower(strings.TrimSpace(operator)) if triggerType == "geofence" { action := map[string]string{"enter": "entered", "exit": "exited", "inside": "inside", "outside": "outside"}[operator] if action == "" { action = "changed" } return "geofence", "vehicle.geofence." + action } if triggerType == "offline" || metric == "freshness_sec" { return "connectivity", "vehicle.connectivity.offline" } if triggerType == "stationary" { return "telemetry", "vehicle.motion.stationary" } switch metric { case "soc_percent": if operator == "lt" || operator == "lte" { return "telemetry", "vehicle.telemetry.soc_low" } case "speed_kmh": if operator == "gt" || operator == "gte" { return "telemetry", "vehicle.motion.speed_high" } case "alarm_active": return "safety", "vehicle.safety.alarm_activated" case "hydrogen_concentration_percent": return "safety", "vehicle.safety.hydrogen_concentration_high" case "daily_mileage_km": return "business", "vehicle.mileage.daily_completed" } if strings.Contains(metric, "hydrogen") { return "safety", "vehicle.safety." + metric } if strings.Contains(metric, "mileage") || strings.HasPrefix(metric, "daily_") { return "business", "vehicle.business." + metric } if metric == "" { metric = "changed" } return "telemetry", "vehicle.telemetry." + metric } 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 } query.Search = strings.TrimSpace(query.Search) searchRunes := []rune(query.Search) if len(searchRunes) > 160 { query.Search = string(searchRunes[:160]) } query.DeliveryStatus = strings.ToLower(strings.TrimSpace(query.DeliveryStatus)) switch query.DeliveryStatus { case "", "failed", "queued", "delivered": default: return Page[AlertNotification]{}, clientError{Code: "ALERT_NOTIFICATION_DELIVERY_STATUS_INVALID", Message: "通知送达状态无效"} } if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" { query.AllowedVINs = alertNotificationAllowedVINs(principal) if len(query.AllowedVINs) == 0 { return Page[AlertNotification]{Items: []AlertNotification{}, Limit: query.Limit, Offset: query.Offset}, nil } } 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") if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" { request.AllowedVINs = alertNotificationAllowedVINs(principal) if len(request.AllowedVINs) == 0 { return 0, nil } } store, err := s.alertStore() if err != nil { return 0, err } return store.MarkAlertNotificationsRead(ctx, request) } func alertNotificationAllowedVINs(principal Principal) []string { seen := map[string]struct{}{} allowed := make([]string, 0, len(principal.VehicleVINs)+len(principal.VehicleGrants)) for _, value := range principal.VehicleVINs { vin := strings.ToUpper(strings.TrimSpace(value)) if vin == "" { continue } if _, exists := seen[vin]; exists { continue } seen[vin] = struct{}{} allowed = append(allowed, vin) } for _, grant := range principal.VehicleGrants { vin := strings.ToUpper(strings.TrimSpace(grant.VIN)) if vin == "" { continue } if _, exists := seen[vin]; exists { continue } seen[vin] = struct{}{} allowed = append(allowed, vin) } return allowed } func alertNotificationRetryActor(ctx context.Context) (string, error) { principal, ok := PrincipalFromContext(ctx) if !ok { return "", clientError{Code: "ALERT_NOTIFICATION_RETRY_AUTH_REQUIRED", Message: "通知重试需要已认证的操作账号"} } role := strings.ToLower(strings.TrimSpace(principal.Role)) if role != "admin" && role != "operator" { return "", clientError{Code: "ALERT_NOTIFICATION_RETRY_FORBIDDEN", Message: "当前账号无权重新投递通知"} } return firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "platform-operator"), nil } func validAlertNotificationRetryKey(value string) bool { if len(value) < 16 || len(value) > 96 { return false } for _, char := range value { if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') { continue } switch char { case '-', '_', '.', ':': continue default: return false } } return true } func (s *Service) RetryAlertNotification(ctx context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) { actor, err := alertNotificationRetryActor(ctx) if err != nil { return AlertNotificationRetryResult{}, err } if id <= 0 { return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"} } request.Reason = strings.TrimSpace(request.Reason) reasonRunes := []rune(request.Reason) if len(reasonRunes) < 4 || len(reasonRunes) > 200 { return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_REASON_INVALID", Message: "请填写 4 到 200 个字符的重试原因"} } request.IdempotencyKey = strings.TrimSpace(request.IdempotencyKey) if !validAlertNotificationRetryKey(request.IdempotencyKey) { return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_INVALID", Message: "重试请求键无效,请重新打开确认窗口"} } if request.ExpectedAttemptCount <= 0 || request.ExpectedAttemptCount >= alertNotificationMaxAttempts { return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_INVALID", Message: "通知尝试次数无效或已经达到上限"} } request.Actor = actor store, err := s.alertStore() if err != nil { return AlertNotificationRetryResult{}, err } return store.RetryAlertNotification(ctx, id, request) } func (s *Service) AlertNotificationRetryAudits(ctx context.Context, id int64) ([]AlertNotificationRetryAudit, error) { if err := authorizeInternalOperations(ctx, false); err != nil { return nil, err } if id <= 0 { return nil, clientError{Code: "ALERT_NOTIFICATION_ID_INVALID", Message: "通知记录 ID 无效"} } store, err := s.alertStore() if err != nil { return nil, err } return store.AlertNotificationRetryAudits(ctx, id) } 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"} } triggerType := normalizedAlertTriggerType(input.TriggerType, input.Metric) if triggerType != "metric" && triggerType != "geofence" && triggerType != "stationary" && triggerType != "offline" { return clientError{Code: "ALERT_RULE_TRIGGER_TYPE_INVALID", Message: "自动化触发类型无效"} } 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: "规则指标不能为空"} } operator := strings.ToLower(strings.TrimSpace(input.Operator)) if triggerType == "geofence" { if len(input.ScopeProtocols) != 1 { return clientError{Code: "ALERT_RULE_GEOFENCE_PROTOCOL_REQUIRED", Message: "电子围栏必须指定唯一定位协议,避免多数据源坐标漂移或重复触发"} } if valueType != "numeric" || !strings.EqualFold(input.Metric, "geofence_distance_m") { return clientError{Code: "ALERT_RULE_GEOFENCE_TYPE_INVALID", Message: "电子围栏规则配置不完整"} } if operator != "enter" && operator != "exit" && operator != "inside" && operator != "outside" { return clientError{Code: "ALERT_RULE_GEOFENCE_MODE_INVALID", Message: "电子围栏仅支持进入、离开、围栏内或围栏外"} } if strings.TrimSpace(input.FenceName) == "" || len([]rune(strings.TrimSpace(input.FenceName))) > 80 { return clientError{Code: "ALERT_RULE_GEOFENCE_NAME_INVALID", Message: "围栏名称不能为空且不能超过 80 字"} } if input.FenceLongitude < -180 || input.FenceLongitude > 180 || input.FenceLatitude < -90 || input.FenceLatitude > 90 || (input.FenceLongitude == 0 && input.FenceLatitude == 0) { return clientError{Code: "ALERT_RULE_GEOFENCE_CENTER_INVALID", Message: "围栏中心坐标无效"} } if input.FenceRadiusM < 50 || input.FenceRadiusM > 100000 { return clientError{Code: "ALERT_RULE_GEOFENCE_RADIUS_INVALID", Message: "围栏半径须在 50 米到 100 公里之间"} } if (operator == "enter" || operator == "exit") && input.DurationSec != 0 { return clientError{Code: "ALERT_RULE_GEOFENCE_DURATION_INVALID", Message: "进入或离开围栏按状态变化立即触发,不能配置持续时间"} } if input.DurationSec < 0 || input.DurationSec > 604800 || 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 } 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 !alertMetricDefinitionSupported(*metric) { return clientError{Code: "ALERT_RULE_METRIC_UNSUPPORTED", Message: "规则指标尚未接入告警评估器"} } if metric.ValueType != valueType { return clientError{Code: "ALERT_RULE_METRIC_TYPE_MISMATCH", Message: "规则值类型与指标目录不一致"} } 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 > 604800 || input.RepeatIntervalSec < 0 || input.RepeatIntervalSec > 604800 { return clientError{Code: "ALERT_RULE_INTERVAL_INVALID", Message: "持续时间或重复间隔超出允许范围"} } if triggerType == "stationary" && (!strings.EqualFold(input.Metric, "speed_kmh") || (operator != "lt" && operator != "lte") || input.Threshold < 0 || input.Threshold > 10 || input.DurationSec < 300) { return clientError{Code: "ALERT_RULE_STATIONARY_INVALID", Message: "长时间静止须使用 0–10 km/h 的速度上限且持续至少 5 分钟"} } if triggerType == "offline" && (!strings.EqualFold(input.Metric, "freshness_sec") || (operator != "gt" && operator != "gte") || input.Threshold < 60) { return clientError{Code: "ALERT_RULE_OFFLINE_INVALID", Message: "长时间离线须配置至少 60 秒的离线阈值"} } if input.Version < 0 { return clientError{Code: "ALERT_RULE_VERSION_INVALID", Message: "规则版本无效"} } return nil } func normalizedAlertTriggerType(triggerType, metric string) string { triggerType = strings.ToLower(strings.TrimSpace(triggerType)) if triggerType != "" { return triggerType } if strings.EqualFold(strings.TrimSpace(metric), "freshness_sec") { return "offline" } return "metric" } func normalizeAlertRuleTrigger(input *AlertRuleInput) { input.TriggerType = normalizedAlertTriggerType(input.TriggerType, input.Metric) switch input.TriggerType { case "geofence": input.ValueType = "numeric" input.Metric = "geofence_distance_m" input.Threshold = input.FenceRadiusM input.ThresholdHigh = 0 input.BooleanThreshold = nil input.RecoveryOperator = "" input.RecoveryThreshold = 0 if strings.EqualFold(input.Operator, "enter") || strings.EqualFold(input.Operator, "exit") { input.DurationSec = 0 } case "stationary": input.ValueType = "numeric" input.Metric = "speed_kmh" if input.Operator != "lt" && input.Operator != "lte" { input.Operator = "lte" } input.RecoveryOperator = "gt" input.RecoveryThreshold = input.Threshold case "offline": input.ValueType = "numeric" input.Metric = "freshness_sec" if input.Operator != "gt" && input.Operator != "gte" { input.Operator = "gt" } input.DurationSec = 0 input.RecoveryOperator = "lte" input.RecoveryThreshold = input.Threshold } } func alertMetricDefinitionSupported(metric MetricDefinition) bool { if _, supported := alertMetricValue(metric.Key, alertEvaluationEvidence{}); supported { return true } for protocol, source := range metric.SourceFields { switch strings.ToUpper(strings.TrimSpace(protocol)) { case "GB32960", "JT808", "YUTONG_MQTT": if strings.TrimSpace(source) != "" { return true } } } return false } 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 len(out) == 0 { return []string{} } if !seen["in_app"] { out = append([]string{"in_app"}, out...) } return out } func alertNotificationTargetIDValid(value string) bool { if len(value) < 2 || len(value) > 96 { return false } for _, char := range value { if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') { continue } switch char { case '-', '_', '.', ':': continue default: return false } } return true } func normalizeAlertNotificationTargets(input *AlertRuleInput) error { if len(input.NotificationTargets) > 20 { return clientError{Code: "ALERT_NOTIFICATION_TARGET_LIMIT", Message: "单条自动化最多配置 20 个通知目标"} } if len(input.NotificationTargets) == 0 { channels := normalizeAlertChannels(input.NotificationChannels) if len(channels) == 0 { input.NotificationChannels = []string{} input.NotificationTargets = []AlertNotificationTarget{} return nil } for _, channel := range channels { if channel != "in_app" { return clientError{Code: "ALERT_NOTIFICATION_TARGET_REQUIRED", Message: "短信、邮件和企业通讯必须选择明确的通知目标"} } } input.NotificationChannels = []string{"in_app"} input.NotificationTargets = []AlertNotificationTarget{{Channel: "in_app", RecipientID: "platform-operators", Label: "平台值班组"}} return nil } allowed := map[string]bool{"in_app": true, "sms": true, "email": true, "wecom": true} targets := make([]AlertNotificationTarget, 0, len(input.NotificationTargets)+1) seen := map[string]bool{} hasInApp := false for _, target := range input.NotificationTargets { target.Channel = strings.ToLower(strings.TrimSpace(target.Channel)) target.RecipientID = strings.TrimSpace(target.RecipientID) target.Label = strings.TrimSpace(target.Label) if !allowed[target.Channel] { return clientError{Code: "ALERT_NOTIFICATION_CHANNEL_INVALID", Message: "通知渠道无效"} } if !alertNotificationTargetIDValid(target.RecipientID) { return clientError{Code: "ALERT_NOTIFICATION_TARGET_INVALID", Message: "通知目标标识无效"} } if target.Label == "" || len([]rune(target.Label)) > 80 { return clientError{Code: "ALERT_NOTIFICATION_TARGET_LABEL_INVALID", Message: "通知目标名称不能为空且不能超过 80 字"} } key := target.Channel + "\x00" + target.RecipientID if seen[key] { continue } seen[key] = true hasInApp = hasInApp || target.Channel == "in_app" targets = append(targets, target) } if len(targets) > 0 && !hasInApp { targets = append([]AlertNotificationTarget{{Channel: "in_app", RecipientID: "platform-operators", Label: "平台值班组"}}, targets...) } channels := make([]string, 0, len(targets)) channelSeen := map[string]bool{} for _, target := range targets { if !channelSeen[target.Channel] { channelSeen[target.Channel] = true channels = append(channels, target.Channel) } } input.NotificationTargets = targets input.NotificationChannels = channels return nil } func NormalizeAlertNotificationConfig(config AlertNotificationConfig) AlertNotificationConfig { allowed := map[string]string{"in_app": "站内信", "sms": "短信", "email": "邮件", "wecom": "企业通讯"} channelConfigured := map[string]bool{"in_app": true} for _, channel := range config.Channels { key := strings.ToLower(strings.TrimSpace(channel.Channel)) if _, ok := allowed[key]; ok { channelConfigured[key] = channel.Configured || key == "in_app" } } config.Channels = make([]AlertNotificationChannelCapability, 0, len(allowed)) for _, key := range []string{"in_app", "sms", "email", "wecom"} { config.Channels = append(config.Channels, AlertNotificationChannelCapability{Channel: key, Label: allowed[key], Configured: channelConfigured[key]}) } targets := make([]AlertNotificationTargetOption, 0, len(config.Targets)+1) seen := map[string]bool{} for _, target := range config.Targets { target.ID = strings.TrimSpace(target.ID) target.Label = strings.TrimSpace(target.Label) if !alertNotificationTargetIDValid(target.ID) || target.Label == "" || len([]rune(target.Label)) > 80 || seen[target.ID] { continue } channels := make([]string, 0, len(target.Channels)) for _, raw := range target.Channels { channel := strings.ToLower(strings.TrimSpace(raw)) if _, ok := allowed[channel]; ok && !containsString(channels, channel) { channels = append(channels, channel) } } if len(channels) == 0 { continue } seen[target.ID] = true target.Channels = channels targets = append(targets, target) } if !seen["platform-operators"] { targets = append([]AlertNotificationTargetOption{{ID: "platform-operators", Label: "平台值班组", Channels: []string{"in_app"}}}, targets...) } config.Targets = targets return config } func (s *Service) validateAlertNotificationTargets(targets []AlertNotificationTarget) error { config := NormalizeAlertNotificationConfig(s.runtime.AlertNotificationConfig) capabilities := map[string]bool{} for _, channel := range config.Channels { capabilities[channel.Channel] = channel.Configured } options := map[string]map[string]bool{} for _, target := range config.Targets { options[target.ID] = map[string]bool{} for _, channel := range target.Channels { options[target.ID][channel] = true } } for _, target := range targets { if target.Channel == "in_app" { continue } if !capabilities[target.Channel] { return clientError{Code: "ALERT_NOTIFICATION_CHANNEL_UNAVAILABLE", Message: "所选外部通知渠道尚未配置发送网关"} } if len(options) > 0 && !options[target.RecipientID][target.Channel] { return clientError{Code: "ALERT_NOTIFICATION_TARGET_UNAVAILABLE", Message: "所选通知目标不支持当前渠道"} } } return nil } 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 }