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

662 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package platform
import (
"context"
"sort"
"strconv"
"strings"
"time"
)
func (m *MockStore) seedAlertCenter() {
now := time.Now()
boolTrue := true
m.alertRules = []AlertRule{
{ID: "rule-geofence", Name: "车辆驶出运营围栏", Description: "车辆从深圳运营区内移动到围栏外", TriggerType: "geofence", FenceName: "深圳运营区", FenceLongitude: 114.057868, FenceLatitude: 22.543099, FenceRadiusM: 5000, Severity: "critical", ValueType: "numeric", Metric: "geofence_distance_m", Operator: "exit", Threshold: 5000, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 3, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-2 * time.Hour).Format(time.RFC3339)},
{ID: "rule-low-soc", Name: "SOC 低于 20%", Description: "动力电池 SOC 持续低于 20%", TriggerType: "metric", Severity: "major", ValueType: "numeric", Metric: "soc_percent", Operator: "lt", Threshold: 20, DurationSec: 300, RecoveryOperator: "gte", RecoveryThreshold: 25, RepeatIntervalSec: 3600, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -1, 0).Format(time.RFC3339), UpdatedAt: now.Add(-24 * time.Hour).Format(time.RFC3339)},
{ID: "rule-offline", Name: "车辆在线状态处理", Description: "车辆离线时创建事件,恢复上报后自动完成", TriggerType: "offline", Severity: "major", ValueType: "numeric", Metric: "freshness_sec", Operator: "gt", Threshold: 3600, RecoveryOperator: "lte", RecoveryThreshold: 3600, RepeatIntervalSec: 3600, ScopeProtocols: []string{"JT808", "GB32960", "YUTONG_MQTT"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339), UpdatedAt: now.AddDate(0, -3, 0).Format(time.RFC3339)},
{ID: "rule-speeding", Name: "车辆急加速", Description: "速度变化率超过运营阈值", TriggerType: "metric", Severity: "major", ValueType: "numeric", Metric: "speed_kmh", Operator: "gt", Threshold: 80, DurationSec: 10, RecoveryOperator: "lte", RecoveryThreshold: 75, RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: true, Version: 2, CreatedBy: "system", UpdatedBy: "platform-admin", CreatedAt: now.AddDate(0, -2, 0).Format(time.RFC3339), UpdatedAt: now.Add(-48 * time.Hour).Format(time.RFC3339)},
{ID: "rule-mileage", Name: "里程数据完成日结", Description: "记录车辆每日里程结算完成事件", TriggerType: "metric", Severity: "minor", ValueType: "numeric", Metric: "daily_mileage_km", Operator: "gte", Threshold: 0, RepeatIntervalSec: 86400, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{}, Enabled: true, Version: 1, CreatedBy: "system", UpdatedBy: "system", CreatedAt: now.AddDate(0, -1, 0).Format(time.RFC3339), UpdatedAt: now.Add(-12 * time.Hour).Format(time.RFC3339)},
{ID: "rule-alarm", Name: "最高氢浓度超限", Description: "燃料电池系统最高氢浓度超过安全阈值", TriggerType: "metric", Severity: "critical", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &boolTrue, RecoveryOperator: "eq", RecoveryThreshold: 0, RepeatIntervalSec: 300, ScopeProtocols: []string{"GB32960"}, ScopeVINs: []string{}, NotificationChannels: []string{"in_app"}, Enabled: false, Version: 1, CreatedBy: "platform-admin", UpdatedBy: "platform-admin", CreatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339), UpdatedAt: now.Add(-72 * time.Hour).Format(time.RFC3339)},
}
for i := range m.alertRules {
m.alertRules[i].ScopeOEMs = []string{}
m.alertRules[i].ScopeModels = []string{}
m.alertRules[i].ScopeCompanies = []string{}
input := alertRuleInputFromRule(m.alertRules[i])
if err := normalizeAlertNotificationTargets(&input); err == nil {
m.alertRules[i].NotificationChannels = input.NotificationChannels
m.alertRules[i].NotificationTargets = input.NotificationTargets
}
}
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
for _, rule := range m.alertRules {
m.alertRuleRevisions[rule.ID] = []AlertRuleRevision{{RuleID: rule.ID, Version: rule.Version, Actor: rule.UpdatedBy, Action: "create", CreatedAt: rule.UpdatedAt, Snapshot: rule}}
}
locations := []string{"广东省深圳市南山区科技南路", "广东省广州市白云区机场高速", "广东省东莞市南城街道", "广东省佛山市顺德区伦教街道", "上海市临港新片区", "四川省成都市高新区"}
statuses := []string{"unprocessed", "unprocessed", "processing", "recovered", "closed", "ignored"}
severities := []string{"critical", "major", "major", "major", "minor", "critical"}
plates := []string{"粤AG18312", "粤B7C526", "粤C9D872", "粤E6P987", "豫A88888", "川AHTWO1"}
vins := []string{"LB9A32A24R0LS1426", "LFP23A98V2P012345", "LS5A3A5E8N0123456", "LJ12BA3R1N0456789", "LMRKH9AC2R1004087", "LNXNEGRR7SR318212"}
eventSpecs := []struct {
ruleID, name, triggerType, protocol, metric, operator, unit string
value, threshold float64
duration int
}{
{"rule-geofence", "车辆驶出运营围栏", "geofence", "JT808", "geofence_distance_m", "exit", "m", 5230, 5000, 0},
{"rule-low-soc", "SOC 低于 20%", "metric", "GB32960", "soc_percent", "lt", "%", 18, 20, 300},
{"rule-speeding", "车辆急加速", "metric", "JT808", "speed_kmh", "gt", "km/h", 92, 80, 10},
{"rule-offline", "车辆恢复在线", "offline", "YUTONG_MQTT", "freshness_sec", "gt", "s", 22, 3600, 0},
{"rule-mileage", "里程数据完成日结", "metric", "GB32960", "daily_mileage_km", "gte", "km", 186.4, 0, 0},
{"rule-alarm", "最高氢浓度超限", "metric", "GB32960", "alarm_active", "eq", "", 1, 1, 0},
}
for i := range statuses {
triggered := now.Add(-time.Duration(8+i*11) * time.Minute)
spec := eventSpecs[i]
event := AlertEvent{ID: "alert-demo-" + string(rune('1'+i)), RuleID: spec.ruleID, RuleName: spec.name, RuleVersion: 2, Severity: severities[i], TriggerType: spec.triggerType, Status: statuses[i], VIN: vins[i], Plate: plates[i], Protocol: spec.protocol, Metric: spec.metric, Operator: spec.operator, TriggerValue: spec.value, Threshold: spec.threshold, Unit: spec.unit, DurationSec: spec.duration, Location: locations[i], SourceEventID: "source-event-00" + string(rune('1'+i)), EventAt: triggered.Add(-time.Second).Format(time.RFC3339), ReceivedAt: triggered.Format(time.RFC3339), TriggeredAt: triggered.Format(time.RFC3339), Version: 1}
if statuses[i] == "processing" {
event.Handler = "张三"
}
if statuses[i] == "recovered" || statuses[i] == "closed" || statuses[i] == "ignored" {
event.RecoveredAt = triggered.Add(5 * time.Minute).Format(time.RFC3339)
}
event.Actions = []AlertAction{{ID: int64(i + 1), Action: "trigger", ToStatus: "unprocessed", Actor: "event-evaluator", Note: "标准事件已匹配自动化", CreatedAt: triggered.Format(time.RFC3339)}}
if statuses[i] != "unprocessed" {
event.Actions = append(event.Actions, AlertAction{ID: int64(20 + i), Action: statuses[i], FromStatus: "unprocessed", ToStatus: statuses[i], Actor: firstNonEmpty(event.Handler, "platform-admin"), CreatedAt: triggered.Add(time.Minute).Format(time.RFC3339)})
}
m.alertEvents = append(m.alertEvents, event)
}
m.nextAlertActionID = 100
for i := 0; i < 9; i++ {
m.alertNotifications = append(m.alertNotifications, AlertNotification{ID: int64(i + 1), EventID: m.alertEvents[i%len(m.alertEvents)].ID, Title: m.alertEvents[i%len(m.alertEvents)].RuleName, Content: plates[i%len(plates)] + " 的车辆事件已执行通知动作", Severity: severities[i%len(severities)], Channel: "in_app", DeliveryStatus: "delivered", AttemptCount: 1, MaxAttempts: alertNotificationMaxAttempts, Read: i >= 7, CreatedAt: now.Add(-time.Duration(i+1) * time.Minute).Format(time.RFC3339)})
}
m.alertNotifications = append(m.alertNotifications, AlertNotification{
ID: 10, EventID: m.alertEvents[1].ID, Title: "SOC 低电量短信通知", Content: "粤B7C526 的 SOC 已低于 20%,短信网关连接超时",
Severity: "major", Channel: "sms", Recipient: "夜班负责人", RecipientID: "night-shift", DeliveryStatus: "failed", AttemptCount: 2,
VehiclePlate: "粤B7C526", VehicleVIN: m.alertEvents[1].VIN, Protocol: m.alertEvents[1].Protocol,
CreatedAt: now.Add(-12 * time.Minute).Format(time.RFC3339), LastAttemptAt: now.Add(-10 * time.Minute).Format(time.RFC3339),
LastError: "短信网关连接超时provider_timeout", RetryAvailable: true, MaxAttempts: alertNotificationMaxAttempts,
})
m.nextNotificationID = 11
m.nextNotificationRetryAuditID = 0
}
func (m *MockStore) AlertSummary(_ context.Context, query AlertQuery) (AlertSummary, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
result := AlertSummary{AsOf: time.Now().Format(time.RFC3339)}
for _, event := range m.alertEvents {
if !keepMockAlertEvent(event, query) {
continue
}
switch event.Status {
case "unprocessed":
result.Unprocessed++
result.Active++
case "processing":
result.Processing++
result.Active++
case "recovered":
result.Recovered++
case "closed":
result.Closed++
case "ignored":
result.Ignored++
}
}
for _, item := range m.alertNotifications {
if item.Channel == "in_app" && !item.Read {
result.UnreadNotifications++
}
}
return result, nil
}
func (m *MockStore) ActiveAlertVINs(_ context.Context, protocol string) ([]string, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
seen := map[string]struct{}{}
vins := make([]string, 0)
for _, event := range m.alertEvents {
if event.Status != "unprocessed" && event.Status != "processing" {
continue
}
if protocol != "" && event.Protocol != protocol {
continue
}
if _, exists := seen[event.VIN]; exists {
continue
}
seen[event.VIN] = struct{}{}
vins = append(vins, event.VIN)
}
return vins, nil
}
func (m *MockStore) AlertEvents(_ context.Context, query AlertQuery) (Page[AlertEvent], error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
items := make([]AlertEvent, 0, len(m.alertEvents))
for _, event := range m.alertEvents {
if keepMockAlertEvent(event, query) {
event.Actions = nil
items = append(items, event)
}
}
sort.SliceStable(items, func(i, j int) bool { return items[i].TriggeredAt > items[j].TriggeredAt })
total := len(items)
start := query.Offset
if start > total {
start = total
}
end := start + query.Limit
if end > total {
end = total
}
return Page[AlertEvent]{Items: append([]AlertEvent(nil), items[start:end]...), Total: total, Limit: query.Limit, Offset: query.Offset}, nil
}
func keepMockAlertEvent(event AlertEvent, query AlertQuery) bool {
keyword := strings.ToLower(query.Keyword)
if keyword != "" && !strings.Contains(strings.ToLower(event.VIN), keyword) && !strings.Contains(strings.ToLower(event.Plate), keyword) && !strings.Contains(strings.ToLower(event.RuleName), keyword) {
return false
}
if query.Severity != "" && query.Severity != "all" && event.Severity != query.Severity {
return false
}
if query.Status == "active" && event.Status != "unprocessed" && event.Status != "processing" {
return false
}
if query.Status != "" && query.Status != "all" && query.Status != "active" && event.Status != query.Status {
return false
}
if query.RuleID != "" && event.RuleID != query.RuleID {
return false
}
if query.Protocol != "" && !strings.EqualFold(event.Protocol, query.Protocol) {
return false
}
if query.DateFrom != "" && event.TriggeredAt < query.DateFrom {
return false
}
if query.DateTo != "" && event.TriggeredAt > query.DateTo {
return false
}
return true
}
func (m *MockStore) AlertEvent(_ context.Context, id string) (AlertEvent, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
for _, event := range m.alertEvents {
if event.ID == id {
event.Actions = append([]AlertAction(nil), event.Actions...)
return event, nil
}
}
return AlertEvent{}, clientError{Code: "ALERT_EVENT_NOT_FOUND", Message: "告警事件不存在"}
}
func (m *MockStore) AlertRules(context.Context) ([]AlertRule, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
items := make([]AlertRule, 0, len(m.alertRules))
for _, rule := range m.alertRules {
if rule.ArchivedAt == "" {
items = append(items, rule)
}
}
return items, nil
}
func (m *MockStore) AlertRulePage(_ context.Context, query AlertRuleQuery) (AlertRulePage, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
summary := AlertRuleLibrarySummary{}
items := make([]AlertRule, 0, query.Limit)
search := strings.ToLower(strings.TrimSpace(query.Keyword))
for _, rule := range m.alertRules {
archived := rule.ArchivedAt != ""
if archived {
summary.Archived++
} else {
summary.Current++
if rule.Enabled {
summary.Enabled++
} else {
summary.Disabled++
}
}
if (query.Lifecycle == "archived") != archived {
continue
}
if query.Status == "enabled" && !rule.Enabled || query.Status == "disabled" && rule.Enabled {
continue
}
if query.Protocol != "" && len(rule.ScopeProtocols) > 0 {
matched := false
for _, protocol := range rule.ScopeProtocols {
if protocol == query.Protocol {
matched = true
break
}
}
if !matched {
continue
}
}
if search != "" {
haystack := strings.ToLower(strings.Join([]string{
rule.Name, rule.Description, rule.Metric, strings.Join(rule.ScopeVINs, " "),
strings.Join(rule.ScopeOEMs, " "), strings.Join(rule.ScopeModels, " "),
strings.Join(rule.ScopeCompanies, " "), rule.ArchiveReason,
}, " "))
if !strings.Contains(haystack, search) {
continue
}
}
items = append(items, rule)
}
sort.SliceStable(items, func(i, j int) bool {
if query.Lifecycle == "archived" {
if items[i].ArchivedAt != items[j].ArchivedAt {
return items[i].ArchivedAt > items[j].ArchivedAt
}
} else if items[i].Enabled != items[j].Enabled {
return items[i].Enabled
}
return items[i].ID < items[j].ID
})
total := len(items)
start := min(query.Offset, total)
end := min(start+query.Limit, total)
return AlertRulePage{Items: append([]AlertRule(nil), items[start:end]...), Total: total, Limit: query.Limit, Offset: query.Offset, Summary: summary}, nil
}
func (m *MockStore) AlertRuleRevisions(_ context.Context, id string) ([]AlertRuleRevision, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
items, ok := m.alertRuleRevisions[id]
if !ok {
return nil, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
}
return append([]AlertRuleRevision(nil), items...), nil
}
func (m *MockStore) recordAlertRuleRevision(rule AlertRule, actor, action string, reasons ...string) {
if m.alertRuleRevisions == nil {
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
}
reason := ""
if len(reasons) > 0 {
reason = reasons[0]
}
revision := AlertRuleRevision{RuleID: rule.ID, Version: rule.Version, Actor: actor, Action: action, Reason: reason, CreatedAt: rule.UpdatedAt, Snapshot: rule}
m.alertRuleRevisions[rule.ID] = append([]AlertRuleRevision{revision}, m.alertRuleRevisions[rule.ID]...)
}
func (m *MockStore) SaveAlertRule(_ context.Context, input AlertRuleInput) (AlertRule, error) {
m.alertMu.Lock()
defer m.alertMu.Unlock()
now := time.Now().Format(time.RFC3339)
for i, current := range m.alertRules {
if current.ID != input.ID {
continue
}
if current.ArchivedAt != "" {
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"}
}
if input.Version != current.Version {
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
}
next := ruleFromInput(input)
next.Version = current.Version + 1
next.CreatedAt = current.CreatedAt
next.CreatedBy = current.CreatedBy
next.UpdatedAt = now
next.UpdatedBy = input.Actor
m.alertRules[i] = next
m.recordAlertRuleRevision(next, input.Actor, firstNonEmpty(input.AuditAction, "update"))
return next, nil
}
if input.Version != 0 {
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "待更新规则不存在"}
}
next := ruleFromInput(input)
next.Version = 1
next.CreatedAt = now
next.UpdatedAt = now
next.CreatedBy = input.Actor
next.UpdatedBy = input.Actor
m.alertRules = append(m.alertRules, next)
if m.alertRuleRevisions == nil {
m.alertRuleRevisions = map[string][]AlertRuleRevision{}
}
m.recordAlertRuleRevision(next, input.Actor, "create")
return next, nil
}
func ruleFromInput(input AlertRuleInput) AlertRule {
return AlertRule{ID: input.ID, Name: input.Name, Description: input.Description, TriggerType: input.TriggerType, FenceName: input.FenceName, FenceLongitude: input.FenceLongitude, FenceLatitude: input.FenceLatitude, FenceRadiusM: input.FenceRadiusM, Severity: strings.ToLower(input.Severity), ValueType: strings.ToLower(input.ValueType), Metric: input.Metric, Operator: strings.ToLower(input.Operator), Threshold: input.Threshold, ThresholdHigh: input.ThresholdHigh, BooleanThreshold: input.BooleanThreshold, DurationSec: input.DurationSec, RecoveryOperator: strings.ToLower(input.RecoveryOperator), RecoveryThreshold: input.RecoveryThreshold, RepeatIntervalSec: input.RepeatIntervalSec, ScopeProtocols: append([]string(nil), input.ScopeProtocols...), ScopeVINs: append([]string(nil), input.ScopeVINs...), ScopeOEMs: append([]string(nil), input.ScopeOEMs...), ScopeModels: append([]string(nil), input.ScopeModels...), ScopeCompanies: append([]string(nil), input.ScopeCompanies...), NotificationChannels: append([]string(nil), input.NotificationChannels...), NotificationTargets: append([]AlertNotificationTarget(nil), input.NotificationTargets...), Enabled: input.Enabled}
}
func (m *MockStore) SetAlertRuleEnabled(_ context.Context, id string, update AlertRuleEnabledUpdate) (AlertRule, error) {
m.alertMu.Lock()
defer m.alertMu.Unlock()
for i := range m.alertRules {
if m.alertRules[i].ID == id {
if m.alertRules[i].ArchivedAt != "" {
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVED_READ_ONLY", Message: "审计归档中的自动化为只读;请先恢复到当前规则"}
}
if m.alertRules[i].Version != update.Version {
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "规则已被其他用户更新,请刷新后重试"}
}
m.alertRules[i].Enabled = update.Enabled
m.alertRules[i].Version++
m.alertRules[i].UpdatedBy = update.Actor
m.alertRules[i].UpdatedAt = time.Now().Format(time.RFC3339)
m.recordAlertRuleRevision(m.alertRules[i], update.Actor, map[bool]string{true: "enable", false: "disable"}[update.Enabled])
return m.alertRules[i], nil
}
}
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "规则不存在"}
}
func (m *MockStore) SetAlertRuleArchived(_ context.Context, id string, archived bool, request AlertRuleLifecycleRequest) (AlertRule, error) {
m.alertMu.Lock()
defer m.alertMu.Unlock()
for i := range m.alertRules {
rule := &m.alertRules[i]
if rule.ID != id {
continue
}
if rule.Version != request.Version {
return AlertRule{}, clientError{Code: "ALERT_RULE_VERSION_CONFLICT", Message: "自动化已被其他用户更新,请刷新后重试"}
}
if archived {
if rule.ArchivedAt != "" {
return AlertRule{}, clientError{Code: "ALERT_RULE_ALREADY_ARCHIVED", Message: "自动化已经归档"}
}
if rule.Enabled {
return AlertRule{}, clientError{Code: "ALERT_RULE_ARCHIVE_REQUIRES_DISABLED", Message: "请先停用自动化并确认不再产生新事件,再执行归档"}
}
rule.ArchivedAt = time.Now().Format(time.RFC3339)
rule.ArchivedBy = request.Actor
rule.ArchiveReason = request.Reason
} else {
if rule.ArchivedAt == "" {
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_ARCHIVED", Message: "自动化不在审计归档中"}
}
rule.ArchivedAt = ""
rule.ArchivedBy = ""
rule.ArchiveReason = ""
rule.Enabled = false
}
rule.Version++
rule.UpdatedBy = request.Actor
rule.UpdatedAt = time.Now().Format(time.RFC3339)
action := "restore"
if archived {
action = "archive"
}
m.recordAlertRuleRevision(*rule, request.Actor, action, request.Reason)
return *rule, nil
}
return AlertRule{}, clientError{Code: "ALERT_RULE_NOT_FOUND", Message: "自动化不存在"}
}
func (m *MockStore) ActOnAlert(_ context.Context, id string, request AlertActionRequest) (AlertEvent, error) {
m.alertMu.Lock()
defer m.alertMu.Unlock()
for i := range m.alertEvents {
event := &m.alertEvents[i]
if event.ID != id {
continue
}
if event.Version != request.Version {
return AlertEvent{}, clientError{Code: "ALERT_EVENT_VERSION_CONFLICT", Message: "事件已被其他用户更新,请刷新后重试"}
}
from := event.Status
to := from
switch request.Action {
case "acknowledge":
if from != "unprocessed" {
return AlertEvent{}, clientError{Code: "ALERT_ACTION_NOT_ALLOWED", Message: "只有未处理告警可以确认"}
}
to = "processing"
event.Handler = request.Actor
case "close":
if from != "unprocessed" && from != "processing" && from != "recovered" {
return AlertEvent{}, clientError{Code: "ALERT_ACTION_NOT_ALLOWED", Message: "当前状态不能关闭"}
}
to = "closed"
event.Handler = request.Actor
case "ignore":
if from != "unprocessed" && from != "processing" {
return AlertEvent{}, clientError{Code: "ALERT_ACTION_NOT_ALLOWED", Message: "当前状态不能忽略"}
}
to = "ignored"
event.Handler = request.Actor
}
m.nextAlertActionID++
event.Actions = append(event.Actions, AlertAction{ID: m.nextAlertActionID, Action: request.Action, FromStatus: from, ToStatus: to, Actor: request.Actor, Note: request.Note, CreatedAt: time.Now().Format(time.RFC3339)})
event.Status = to
event.Version++
return *event, nil
}
return AlertEvent{}, clientError{Code: "ALERT_EVENT_NOT_FOUND", Message: "告警事件不存在"}
}
func (m *MockStore) AlertNotifications(_ context.Context, query AlertNotificationQuery) (Page[AlertNotification], error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
items := make([]AlertNotification, 0, len(m.alertNotifications))
search := strings.ToLower(query.Search)
for _, item := range m.alertNotifications {
if len(query.AllowedVINs) > 0 {
allowed := false
for _, vin := range query.AllowedVINs {
if strings.EqualFold(strings.TrimSpace(vin), strings.TrimSpace(item.VehicleVIN)) {
allowed = true
break
}
}
if !allowed {
continue
}
}
if query.UnreadOnly && (item.Channel != "in_app" || item.Read) {
continue
}
deliveryStatus := strings.ToLower(strings.TrimSpace(item.DeliveryStatus))
if deliveryStatus == "sent" {
deliveryStatus = "delivered"
}
if deliveryStatus == "created" || deliveryStatus == "reserved" {
deliveryStatus = "queued"
}
if deliveryStatus == "" {
deliveryStatus = "delivered"
}
if query.DeliveryStatus != "" && deliveryStatus != query.DeliveryStatus {
continue
}
if search != "" && !strings.Contains(strings.ToLower(strings.Join([]string{item.Title, item.Content, item.EventID, item.VehiclePlate, item.VehicleVIN, item.Recipient, item.Protocol}, "\n")), search) {
continue
}
item.DeliveryStatus = deliveryStatus
if item.AttemptCount <= 0 {
item.AttemptCount = 1
}
item.MaxAttempts = alertNotificationMaxAttempts
item.RetryAvailable = deliveryStatus == "failed" && item.AttemptCount < alertNotificationMaxAttempts
items = append(items, item)
}
total := len(items)
start := query.Offset
if start > total {
start = total
}
end := start + query.Limit
if end > total {
end = total
}
return Page[AlertNotification]{Items: append([]AlertNotification(nil), items[start:end]...), Total: total, Limit: query.Limit, Offset: query.Offset}, nil
}
func (m *MockStore) AlertNotificationDeliveryHealth(_ context.Context) (AlertNotificationDeliveryHealth, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
health := AlertNotificationDeliveryHealth{Channels: []AlertNotificationChannelHealth{}, AsOf: time.Now().Format(time.RFC3339)}
byChannel := map[string]*AlertNotificationChannelHealth{}
for _, notification := range m.alertNotifications {
if notification.Channel == "in_app" {
continue
}
channel := byChannel[notification.Channel]
if channel == nil {
channel = &AlertNotificationChannelHealth{Channel: notification.Channel}
byChannel[notification.Channel] = channel
}
status := strings.ToLower(strings.TrimSpace(notification.DeliveryStatus))
if status == "created" || status == "reserved" || status == "queued" {
channel.Queued++
health.Queued++
if channel.OldestQueuedAt == "" || notification.CreatedAt < channel.OldestQueuedAt {
channel.OldestQueuedAt = notification.CreatedAt
}
if health.OldestQueuedAt == "" || notification.CreatedAt < health.OldestQueuedAt {
health.OldestQueuedAt = notification.CreatedAt
}
}
if status == "failed" {
channel.Failed++
health.Failed++
if notification.AttemptCount >= alertNotificationMaxAttempts {
channel.DeadLetter++
health.DeadLetter++
}
}
}
for _, key := range []string{"sms", "email", "wecom"} {
if channel := byChannel[key]; channel != nil {
health.Channels = append(health.Channels, *channel)
}
}
return health, nil
}
func (m *MockStore) MarkAlertNotificationsRead(_ context.Context, request AlertNotificationReadRequest) (int, error) {
m.alertMu.Lock()
defer m.alertMu.Unlock()
ids := map[int64]bool{}
for _, id := range request.IDs {
ids[id] = true
}
count := 0
now := time.Now().Format(time.RFC3339)
for i := range m.alertNotifications {
if ids[m.alertNotifications[i].ID] && m.alertNotifications[i].Channel == "in_app" && !m.alertNotifications[i].Read {
if len(request.AllowedVINs) > 0 {
allowed := false
for _, vin := range request.AllowedVINs {
if strings.EqualFold(strings.TrimSpace(vin), strings.TrimSpace(m.alertNotifications[i].VehicleVIN)) {
allowed = true
break
}
}
if !allowed {
continue
}
}
m.alertNotifications[i].Read = true
m.alertNotifications[i].ReadAt = now
count++
}
}
return count, nil
}
func (m *MockStore) RetryAlertNotification(_ context.Context, id int64, request AlertNotificationRetryRequest) (AlertNotificationRetryResult, error) {
m.alertMu.Lock()
defer m.alertMu.Unlock()
for _, audit := range m.alertNotificationRetryAudits {
if audit.ID == 0 {
continue
}
if audit.IdempotencyKey == request.IdempotencyKey {
if audit.NotificationID != id {
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_KEY_REUSED", Message: "重试请求键已经用于其他通知"}
}
for _, notification := range m.alertNotifications {
if notification.ID == id {
return AlertNotificationRetryResult{Notification: notification, Receipt: audit, Idempotent: true}, nil
}
}
}
}
for index := range m.alertNotifications {
notification := &m.alertNotifications[index]
if notification.ID != id {
continue
}
attemptCount := notification.AttemptCount
if attemptCount <= 0 {
attemptCount = 1
}
if notification.DeliveryStatus != "failed" {
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_RETRYABLE", Message: "通知已经离开发送失败状态,请刷新后复核"}
}
if attemptCount != request.ExpectedAttemptCount {
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_ATTEMPT_CONFLICT", Message: "通知尝试次数已经变化,请刷新后重试"}
}
if attemptCount >= alertNotificationMaxAttempts {
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_RETRY_LIMIT", Message: "通知已经达到 3 次投递上限,请检查渠道配置后人工处置"}
}
nextStatus := "queued"
auditNextStatus := "reserved"
now := time.Now().Format(time.RFC3339)
if notification.Channel == "in_app" {
nextStatus = "delivered"
auditNextStatus = "sent"
notification.DeliveredAt = now
notification.ProviderMessageID = "in_app:" + strconv.FormatInt(notification.ID, 10) + ":retry:" + strconv.Itoa(attemptCount+1)
} else {
notification.ProviderMessageID = ""
notification.DeliveredAt = ""
}
notification.DeliveryStatus = nextStatus
notification.AttemptCount = attemptCount + 1
notification.LastAttemptAt = now
notification.RetryRequestedBy = request.Actor
notification.RetryRequestedAt = now
notification.RetryAvailable = false
notification.MaxAttempts = alertNotificationMaxAttempts
m.nextNotificationRetryAuditID++
audit := AlertNotificationRetryAudit{
ID: m.nextNotificationRetryAuditID, NotificationID: id, IdempotencyKey: request.IdempotencyKey, Actor: request.Actor,
Reason: request.Reason, PreviousStatus: "failed", NextStatus: auditNextStatus,
AttemptCount: notification.AttemptCount, RequestedAt: now,
}
m.alertNotificationRetryAudits = append([]AlertNotificationRetryAudit{audit}, m.alertNotificationRetryAudits...)
return AlertNotificationRetryResult{Notification: *notification, Receipt: audit}, nil
}
return AlertNotificationRetryResult{}, clientError{Code: "ALERT_NOTIFICATION_NOT_FOUND", Message: "通知记录不存在"}
}
func (m *MockStore) AlertNotificationRetryAudits(_ context.Context, id int64) ([]AlertNotificationRetryAudit, error) {
m.alertMu.RLock()
defer m.alertMu.RUnlock()
items := make([]AlertNotificationRetryAudit, 0)
for _, audit := range m.alertNotificationRetryAudits {
if audit.NotificationID == id {
items = append(items, audit)
}
}
return items, nil
}
func (m *MockStore) EvaluateAlerts(context.Context) (AlertEvaluationResult, error) {
return AlertEvaluationResult{RulesEvaluated: len(m.alertRules), VehiclesScanned: len(m.vehicles), AsOf: time.Now().Format(time.RFC3339)}, nil
}