1001 lines
37 KiB
Go
1001 lines
37 KiB
Go
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
|
||
}
|