662 lines
30 KiB
Go
662 lines
30 KiB
Go
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
|
||
}
|