Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/alert.go
2026-07-27 16:46:15 +08:00

1001 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: "长时间静止须使用 010 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
}