Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
1021 lines
38 KiB
Go
1021 lines
38 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) {
|
|
query.AllowedVINs = principalVehicleVINScope(ctx)
|
|
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) {
|
|
query.AllowedVINs = principalVehicleVINScope(ctx)
|
|
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
|
|
}
|
|
if err := authorizeVehicleVIN(ctx, event.VIN); 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
|
|
}
|
|
if p, ok := PrincipalFromContext(ctx); ok && p.HasVehicleScope() {
|
|
existing, err := store.AlertEvent(ctx, strings.TrimSpace(id))
|
|
if err != nil {
|
|
return AlertEvent{}, err
|
|
}
|
|
if err := authorizeVehicleVIN(ctx, existing.VIN); err != nil {
|
|
return AlertEvent{}, err
|
|
}
|
|
}
|
|
event, err := store.ActOnAlert(ctx, strings.TrimSpace(id), request)
|
|
if err != nil {
|
|
return AlertEvent{}, err
|
|
}
|
|
if err := authorizeVehicleVIN(ctx, event.VIN); 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)
|
|
if event.RuleID == nativeAlarmRuleID {
|
|
event.EventCategory, event.EventType = "safety", "vehicle.safety.gb32960_alarm"
|
|
}
|
|
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.HasVehicleScope() {
|
|
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.HasVehicleScope() {
|
|
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
|
|
}
|