feat: expand vehicle data platform capabilities

This commit is contained in:
lingniu
2026-07-27 16:46:15 +08:00
parent e3a1f80f86
commit 3c4bece72c
650 changed files with 62155 additions and 2552 deletions

View File

@@ -4,8 +4,10 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
)
type alertStore interface {
@@ -13,17 +15,26 @@ type alertStore interface {
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 {
@@ -58,7 +69,14 @@ func (s *Service) AlertEvents(ctx context.Context, query AlertQuery) (Page[Alert
if err != nil {
return Page[AlertEvent]{}, err
}
return store.AlertEvents(ctx, normalizeAlertQuery(query))
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) {
@@ -69,7 +87,11 @@ func (s *Service) AlertEvent(ctx context.Context, id string) (AlertEvent, error)
if err != nil {
return AlertEvent{}, err
}
return store.AlertEvent(ctx, strings.TrimSpace(id))
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) {
@@ -80,7 +102,176 @@ func (s *Service) AlertRules(ctx context.Context) ([]AlertRule, error) {
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
@@ -105,7 +296,12 @@ func (s *Service) SaveAlertRule(ctx context.Context, input AlertRuleInput) (Aler
if err := validateAlertRuleScopes(input); err != nil {
return AlertRule{}, err
}
input.NotificationChannels = normalizeAlertChannels(input.NotificationChannels)
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
}
@@ -144,6 +340,9 @@ func validateAlertRuleScopes(input AlertRuleInput) error {
}
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 和版本不能为空"}
}
@@ -155,6 +354,40 @@ func (s *Service) SetAlertRuleEnabled(ctx context.Context, id string, update Ale
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 和版本不能为空"}
@@ -171,7 +404,73 @@ func (s *Service) ActOnAlert(ctx context.Context, id string, request AlertAction
if err != nil {
return AlertEvent{}, err
}
return store.ActOnAlert(ctx, strings.TrimSpace(id), request)
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) {
@@ -184,6 +483,23 @@ func (s *Service) AlertNotifications(ctx context.Context, query AlertNotificatio
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
@@ -196,6 +512,12 @@ func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertN
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
@@ -203,6 +525,106 @@ func (s *Service) MarkAlertNotificationsRead(ctx context.Context, request AlertN
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 {
@@ -237,6 +659,10 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
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"}
@@ -244,6 +670,37 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
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 {
@@ -259,13 +716,12 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
if metric == nil || !metric.Alertable {
return clientError{Code: "ALERT_RULE_METRIC_INVALID", Message: "规则指标不在可告警指标目录中"}
}
if _, supported := alertMetricValue(input.Metric, alertEvaluationEvidence{}); !supported {
if !alertMetricDefinitionSupported(*metric) {
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: "规则比较符无效"}
@@ -279,15 +735,81 @@ func validateAlertRule(input AlertRuleInput, catalog ...MetricDefinition) error
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 {
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)
@@ -299,12 +821,163 @@ func normalizeAlertChannels(values []string) []string {
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{}