feat: expand vehicle data platform capabilities

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

View File

@@ -43,6 +43,36 @@ func TestAlertSchemaProbeRetriesAfterCanceledRequestAndCachesSuccess(t *testing.
}
}
func TestAlertSQLTimestampsDeclareShanghaiOffset(t *testing.T) {
if alertSQLTimestampFormat != "%Y-%m-%dT%H:%i:%s.%f+08:00" {
t.Fatalf("unexpected alert timestamp format %q", alertSQLTimestampFormat)
}
for name, query := range map[string]string{
"event": alertEventSelect,
"action": alertActionSelect,
"rule": alertRuleSelect,
"notification": alertNotificationSelect,
} {
if !strings.Contains(query, alertSQLTimestampFormat) {
t.Fatalf("%s query does not use the alert timestamp format", name)
}
if strings.Contains(query, "%fZ") {
t.Fatalf("%s query still labels local database time as UTC", name)
}
}
}
func TestNormalizeVehicleEventPublishesCanonicalContract(t *testing.T) {
event := normalizeVehicleEvent(AlertEvent{TriggerType: "geofence", Metric: "geofence_distance_m", Operator: "exit", Status: "unprocessed"})
if event.EventType != "vehicle.geofence.exited" || event.EventCategory != "geofence" || event.ExecutionState != "pending" {
t.Fatalf("unexpected canonical event: %#v", event)
}
recovered := normalizeVehicleEvent(AlertEvent{TriggerType: "offline", Metric: "freshness_sec", Status: "recovered"})
if recovered.EventType != "vehicle.connectivity.offline" || recovered.ExecutionState != "recovered" {
t.Fatalf("unexpected recovered event: %#v", recovered)
}
}
type configuredMetricStore struct {
*MockStore
definitions []MetricDefinition
@@ -145,9 +175,12 @@ func TestAlertEventsActiveStatusIncludesOnlyOpenWorkflowStates(t *testing.T) {
}
func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
service := NewService(NewMockStore())
service := NewServiceWithRuntime(NewMockStore(), RuntimeInfo{AlertNotificationConfig: AlertNotificationConfig{
Targets: []AlertNotificationTargetOption{{ID: "night-shift", Label: "夜班负责人", Channels: []string{"sms"}}},
Channels: []AlertNotificationChannelCapability{{Channel: "in_app", Label: "站内信", Configured: true}, {Channel: "sms", Label: "短信", Configured: true}},
}})
truth := true
rule, err := service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "主电源异常", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &truth, ScopeModels: []string{"纯电客车", "纯电客车"}, ScopeCompanies: []string{"示范公交"}, NotificationChannels: []string{"sms", "sms"}, Enabled: true})
rule, err := service.SaveAlertRule(t.Context(), AlertRuleInput{Name: "主电源异常", Severity: "major", ValueType: "boolean", Metric: "alarm_active", Operator: "eq", BooleanThreshold: &truth, ScopeModels: []string{"纯电客车", "纯电客车"}, ScopeCompanies: []string{"示范公交"}, NotificationTargets: []AlertNotificationTarget{{Channel: "sms", RecipientID: "night-shift", Label: "夜班负责人"}}, Enabled: true})
if err != nil {
t.Fatal(err)
}
@@ -167,6 +200,167 @@ func TestAlertRuleCreationNormalizesBooleanAndChannels(t *testing.T) {
}
}
func TestAlertRuleRollbackCreatesRevisionAndPreservesEnabledState(t *testing.T) {
store := NewMockStore()
service := NewService(store)
rules, err := service.AlertRules(t.Context())
if err != nil {
t.Fatal(err)
}
var original AlertRule
for _, rule := range rules {
if rule.ID == "rule-speeding" {
original = rule
break
}
}
changed := alertRuleInputFromRule(original)
changed.Name = "车辆持续超速"
changed.Threshold = 96
changed.Actor = "editor-a"
updated, err := service.SaveAlertRule(t.Context(), changed)
if err != nil {
t.Fatal(err)
}
disabled, err := service.SetAlertRuleEnabled(t.Context(), updated.ID, AlertRuleEnabledUpdate{Version: updated.Version, Enabled: false, Actor: "operator-b"})
if err != nil {
t.Fatal(err)
}
restored, err := service.RollbackAlertRule(t.Context(), disabled.ID, AlertRuleRollbackRequest{TargetVersion: original.Version, CurrentVersion: disabled.Version, Actor: "reviewer-c"})
if err != nil {
t.Fatal(err)
}
if restored.Name != original.Name || restored.Threshold != original.Threshold {
t.Fatalf("target configuration was not restored: %+v", restored)
}
if restored.Enabled || restored.Version != disabled.Version+1 {
t.Fatalf("rollback must preserve disabled state and create a new version: %+v", restored)
}
revisions, err := service.AlertRuleRevisions(t.Context(), restored.ID)
if err != nil {
t.Fatal(err)
}
if len(revisions) < 4 || revisions[0].Action != "rollback" || revisions[0].Version != restored.Version || revisions[0].Actor != "reviewer-c" {
t.Fatalf("rollback revision missing: %+v", revisions)
}
_, err = service.RollbackAlertRule(t.Context(), restored.ID, AlertRuleRollbackRequest{TargetVersion: original.Version, CurrentVersion: restored.Version, Actor: "reviewer-c"})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "ALERT_RULE_ROLLBACK_NO_CHANGES" {
t.Fatalf("expected no-op rollback rejection, got %v", err)
}
}
func TestAlertRuleLibraryArchivesAndRestoresDisabledRuleWithAudit(t *testing.T) {
store := NewMockStore()
service := NewService(store)
admin := WithPrincipal(t.Context(), Principal{Name: "规则管理员", Username: "rule-admin", Role: "admin", UserType: "admin"})
current, err := service.AlertRulePage(admin, AlertRuleQuery{Lifecycle: "current", Limit: 10})
if err != nil {
t.Fatal(err)
}
if current.Total != 6 || current.Summary.Current != 6 || current.Summary.Archived != 0 {
t.Fatalf("unexpected initial library: %+v", current)
}
archived, err := service.SetAlertRuleArchived(admin, "rule-alarm", true, AlertRuleLifecycleRequest{Version: 1, Reason: "安全阈值规则已由新版替代", Actor: "rule-admin"})
if err != nil {
t.Fatal(err)
}
if archived.ArchivedAt == "" || archived.ArchivedBy != "rule-admin" || archived.ArchiveReason == "" || archived.Version != 2 {
t.Fatalf("archive receipt incomplete: %+v", archived)
}
activeRules, err := service.AlertRules(admin)
if err != nil {
t.Fatal(err)
}
for _, rule := range activeRules {
if rule.ID == archived.ID {
t.Fatalf("archived rule leaked into active evaluator list: %+v", rule)
}
}
archivePage, err := service.AlertRulePage(admin, AlertRuleQuery{Lifecycle: "archived", Keyword: "新版替代", Limit: 10})
if err != nil {
t.Fatal(err)
}
if archivePage.Total != 1 || archivePage.Summary.Current != 5 || archivePage.Summary.Archived != 1 || archivePage.Items[0].ID != archived.ID {
t.Fatalf("unexpected archive page: %+v", archivePage)
}
revisions, err := service.AlertRuleRevisions(admin, archived.ID)
if err != nil {
t.Fatal(err)
}
if revisions[0].Action != "archive" || revisions[0].Reason != "安全阈值规则已由新版替代" {
t.Fatalf("archive audit missing: %+v", revisions[0])
}
restored, err := service.SetAlertRuleArchived(admin, archived.ID, false, AlertRuleLifecycleRequest{Version: archived.Version, Reason: "恢复复核通知接收人配置", Actor: "rule-admin"})
if err != nil {
t.Fatal(err)
}
if restored.ArchivedAt != "" || restored.Enabled || restored.Version != 3 {
t.Fatalf("restored rule must return disabled at next version: %+v", restored)
}
revisions, err = service.AlertRuleRevisions(admin, restored.ID)
if err != nil {
t.Fatal(err)
}
if revisions[0].Action != "restore" || revisions[0].Reason != "恢复复核通知接收人配置" {
t.Fatalf("restore audit missing: %+v", revisions[0])
}
}
func TestAlertRuleGovernanceRequiresAdministrator(t *testing.T) {
service := NewService(NewMockStore())
operator := WithPrincipal(t.Context(), Principal{Name: "规则值班员", Role: "operator", UserType: "operator"})
if _, err := service.AlertRulePage(operator, AlertRuleQuery{Limit: 10}); err == nil {
t.Fatal("operator must not list the governance rule library")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
t.Fatalf("unexpected library permission error: %v", err)
}
if _, err := service.SetAlertRuleArchived(operator, "rule-alarm", true, AlertRuleLifecycleRequest{Version: 1, Reason: "规则已经由新版替代"}); err == nil {
t.Fatal("operator must not archive automation rules")
} else if clientErr, ok := asClientError(err); !ok || clientErr.Code != "PERMISSION_DENIED" {
t.Fatalf("unexpected archive permission error: %v", err)
}
}
func TestAlertRuleRevisionRoutes(t *testing.T) {
handler := NewHandler(NewService(NewMockStore()))
list := httptest.NewRecorder()
handler.ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v2/alerts/rules/rule-speeding/revisions", nil))
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"version":2`) {
t.Fatalf("revision route status=%d body=%s", list.Code, list.Body.String())
}
rollback := httptest.NewRecorder()
handler.ServeHTTP(rollback, httptest.NewRequest(http.MethodPost, "/api/v2/alerts/rules/rule-speeding/rollback", strings.NewReader(`{"targetVersion":2,"currentVersion":2}`)))
if rollback.Code != http.StatusBadRequest || !strings.Contains(rollback.Body.String(), "ALERT_RULE_ROLLBACK_TARGET_INVALID") {
t.Fatalf("rollback validation status=%d body=%s", rollback.Code, rollback.Body.String())
}
}
func TestAlertRuleRevisionsParsesAuditSnapshots(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery("SELECT rule_id,rule_version,actor,action,COALESCE\\(reason,''\\),snapshot_json").WithArgs("rule-1").WillReturnRows(sqlmock.NewRows([]string{"rule_id", "rule_version", "actor", "action", "reason", "snapshot_json", "created_at"}).AddRow("rule-1", 3, "admin-a", "update", "", `{"id":"rule-1","name":"超速","severity":"major","valueType":"numeric","metric":"speed_kmh","operator":"gt","threshold":90,"scopeProtocols":["JT808"],"scopeVins":[],"scopeOems":[],"scopeModels":[],"scopeCompanies":[],"notificationChannels":["in_app"],"enabled":true}`, "2026-07-23T10:00:00.000000+08:00"))
revisions, err := store.AlertRuleRevisions(t.Context(), "rule-1")
if err != nil {
t.Fatal(err)
}
if len(revisions) != 1 || revisions[0].Snapshot.Version != 3 || revisions[0].Snapshot.Threshold != 90 || revisions[0].Snapshot.UpdatedBy != "admin-a" {
t.Fatalf("unexpected revisions: %+v", revisions)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAlertNotificationsReadUpdatesUnreadSummary(t *testing.T) {
service := NewService(NewMockStore())
before, err := service.AlertSummary(t.Context(), AlertQuery{})
@@ -190,6 +384,139 @@ func TestAlertNotificationsReadUpdatesUnreadSummary(t *testing.T) {
}
}
func TestAlertNotificationsFiltersTheCompleteRangeBeforePagination(t *testing.T) {
store := NewMockStore()
store.alertNotifications = append(store.alertNotifications, AlertNotification{
ID: 999, EventID: "event-delivery-failed", Title: "夜班短信失败", Content: "短信网关超时",
Severity: "major", Channel: "sms", Recipient: "夜班负责人", DeliveryStatus: "failed",
VehiclePlate: "浙A·失败", VehicleVIN: "VIN-DELIVERY-FAILED", Protocol: "JT808",
CreatedAt: time.Now().Format(time.RFC3339),
})
service := NewService(store)
page, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{
Search: "VIN-DELIVERY-FAILED", DeliveryStatus: "failed", Limit: 20,
})
if err != nil {
t.Fatal(err)
}
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != 999 {
t.Fatalf("server-wide notification filter mismatch: %+v", page)
}
delivered, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{
Search: "短信网关超时", DeliveryStatus: "delivered", Limit: 20,
})
if err != nil {
t.Fatal(err)
}
if delivered.Total != 0 || len(delivered.Items) != 0 {
t.Fatalf("failed delivery leaked into delivered filter: %+v", delivered)
}
if _, err := service.AlertNotifications(t.Context(), AlertNotificationQuery{DeliveryStatus: "unknown", Limit: 20}); err == nil {
t.Fatal("invalid delivery status should be rejected")
}
}
func TestAlertNotificationRetryIsPermissionedIdempotentAndAudited(t *testing.T) {
store := NewMockStore()
service := NewService(store)
operator := WithPrincipal(t.Context(), Principal{Name: "通知操作员", Username: "operator-a", Role: "operator", UserType: "operator"})
request := AlertNotificationRetryRequest{
ExpectedAttemptCount: 2,
Reason: "已确认短信网关恢复",
IdempotencyKey: "notification-retry-10-attempt-3",
}
result, err := service.RetryAlertNotification(operator, 10, request)
if err != nil {
t.Fatal(err)
}
if result.Idempotent || result.Notification.DeliveryStatus != "queued" || result.Notification.AttemptCount != 3 || result.Notification.RetryRequestedBy != "operator-a" {
t.Fatalf("unexpected retry result: %+v", result)
}
if result.Receipt.NotificationID != 10 || result.Receipt.AttemptCount != 3 || result.Receipt.Reason != request.Reason {
t.Fatalf("unexpected retry receipt: %+v", result.Receipt)
}
repeated, err := service.RetryAlertNotification(operator, 10, request)
if err != nil {
t.Fatal(err)
}
if !repeated.Idempotent || repeated.Notification.AttemptCount != 3 || repeated.Receipt.ID != result.Receipt.ID {
t.Fatalf("same key must reuse the original retry: %+v", repeated)
}
audit, err := service.AlertNotificationRetryAudits(operator, 10)
if err != nil || len(audit) != 1 || audit[0].ID != result.Receipt.ID {
t.Fatalf("retry audit mismatch: %+v err=%v", audit, err)
}
viewer := WithPrincipal(t.Context(), Principal{Name: "只读审计员", Username: "viewer-a", Role: "viewer", UserType: "operator"})
if _, err := service.RetryAlertNotification(viewer, 10, request); err == nil {
t.Fatal("read-only role must not retry notifications")
}
if _, err := service.RetryAlertNotification(operator, 10, AlertNotificationRetryRequest{ExpectedAttemptCount: 2, Reason: "短", IdempotencyKey: "notification-retry-invalid"}); err == nil {
t.Fatal("retry reason must be auditable")
}
}
func TestAlertNotificationsScopeCustomerToGrantedVehicles(t *testing.T) {
service := NewService(NewMockStore())
allowed := WithPrincipal(t.Context(), Principal{
Name: "客户账号", Role: "customer", UserType: "customer",
VehicleVINs: []string{"LFP23A98V2P012345"},
})
page, err := service.AlertNotifications(allowed, AlertNotificationQuery{DeliveryStatus: "failed", Limit: 20})
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].ID != 10 {
t.Fatalf("customer notification scope mismatch: %+v err=%v", page, err)
}
denied := WithPrincipal(t.Context(), Principal{Name: "无授权客户", Role: "customer", UserType: "customer"})
empty, err := service.AlertNotifications(denied, AlertNotificationQuery{Limit: 20})
if err != nil || empty.Total != 0 || len(empty.Items) != 0 {
t.Fatalf("customer without grants must receive an empty scope: %+v err=%v", empty, err)
}
}
func TestProductionAlertNotificationRetryQueuesOneAuditedAttempt(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectBegin()
mock.ExpectQuery(regexp.QuoteMeta(`SELECT channel,delivery_status,GREATEST(COALESCE(attempt_count,1),1) FROM vehicle_alert_notification WHERE id=? FOR UPDATE`)).
WithArgs(int64(42)).
WillReturnRows(sqlmock.NewRows([]string{"channel", "delivery_status", "attempt_count"}).AddRow("sms", "failed", 1))
mock.ExpectQuery(regexp.QuoteMeta(alertNotificationRetryAuditSelect + `idempotency_key=?`)).
WithArgs("notification-retry-42-attempt-2").
WillReturnRows(sqlmock.NewRows([]string{"id", "notification_id", "actor", "reason", "previous_status", "next_status", "attempt_count", "requested_at"}))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_notification_retry_audit(notification_id,idempotency_key,actor,reason,previous_status,next_status,attempt_count) VALUES(?,?,?,?,?,?,?)`)).
WithArgs(int64(42), "notification-retry-42-attempt-2", "operator-a", "短信网关已经恢复", "failed", "reserved", 2).
WillReturnResult(sqlmock.NewResult(81, 1))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_notification SET delivery_status='reserved',attempt_count=?,provider_message_id='',delivered_at=NULL,last_attempt_at=CURRENT_TIMESTAMP(3),retry_requested_by=?,retry_requested_at=CURRENT_TIMESTAMP(3) WHERE id=?`)).
WithArgs(2, "operator-a", int64(42)).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
notificationColumns := []string{"id", "event_id", "title", "content", "severity", "channel", "recipient", "recipient_ref", "delivery_status", "attempt_count", "provider_message_id", "is_read", "created_at", "delivered_at", "read_at", "last_error", "last_attempt_at", "retry_requested_by", "retry_requested_at", "plate", "vin", "protocol"}
mock.ExpectQuery(regexp.QuoteMeta(alertNotificationSelect + `n.id=?`)).
WithArgs(int64(42)).
WillReturnRows(sqlmock.NewRows(notificationColumns).AddRow(42, "event-42", "低电量短信", "SOC 低于 20%", "major", "sms", "夜班负责人", "night-shift", "reserved", 2, "", false, "2026-07-23T10:00:00+08:00", "", "", "provider_timeout", "2026-07-23T10:05:00+08:00", "operator-a", "2026-07-23T10:05:00+08:00", "粤A0042", "VIN42", "GB32960"))
result, err := store.RetryAlertNotification(t.Context(), 42, AlertNotificationRetryRequest{
ExpectedAttemptCount: 1,
Reason: "短信网关已经恢复",
IdempotencyKey: "notification-retry-42-attempt-2",
Actor: "operator-a",
})
if err != nil {
t.Fatal(err)
}
if result.Notification.DeliveryStatus != "queued" || result.Notification.AttemptCount != 2 || result.Receipt.ID != 81 || result.Receipt.NextStatus != "reserved" {
t.Fatalf("unexpected production retry result: %+v", result)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAlertEvaluatorComparisonsAndScope(t *testing.T) {
item := alertEvaluationEvidence{VIN: "VIN1", Protocol: "JT808", OEM: "宇通", Model: "纯电客车", Company: "示范公交", SpeedKmh: 96, AlarmFlag: 1}
rule := AlertRule{Metric: "speed_kmh", Operator: "gt", Threshold: 80, ScopeProtocols: []string{"JT808"}, ScopeVINs: []string{"VIN1"}}
@@ -228,10 +555,10 @@ func TestAlertEvaluatorComparisonsAndScope(t *testing.T) {
}
func TestAlertRuleSQLMatchesMasterDataScopeArguments(t *testing.T) {
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 23 {
if placeholders := strings.Count(alertRuleInsertSQL, "?"); placeholders != 29 {
t.Fatalf("alert insert placeholders=%d query=%s", placeholders, alertRuleInsertSQL)
}
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 23 {
if placeholders := strings.Count(alertRuleUpdateSQL, "?"); placeholders != 29 {
t.Fatalf("alert update placeholders=%d query=%s", placeholders, alertRuleUpdateSQL)
}
}
@@ -321,12 +648,12 @@ func TestAlertRuleDisableIsAtomicAndClearsEvaluatorState(t *testing.T) {
store := NewProductionStore(db, nil, "")
mock.ExpectQuery(regexp.QuoteMeta(`SELECT COUNT(*) FROM vehicle_alert_rule`)).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=?`)).WithArgs(false, "admin-a", "rule-1", 1).WillReturnResult(sqlmock.NewResult(0, 1))
columns := []string{"id", "name", "description", "severity", "value_type", "metric", "operator", "threshold_value", "threshold_high", "boolean_threshold", "duration_sec", "recovery_operator", "recovery_threshold", "repeat_interval_sec", "scope_protocols_json", "scope_vins_json", "scope_oems_json", "scope_models_json", "scope_companies_json", "notification_channels_json", "enabled", "version", "created_by", "updated_by", "created_at", "updated_at"}
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE id=?`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows(columns).AddRow("rule-1", "超速", "", "minor", "numeric", "speed_kmh", "gte", 0.0, 0.0, nil, 20, "", 0.0, 3600, `["JT808"]`, `["VIN1"]`, `[]`, `[]`, `[]`, `["in_app"]`, false, 2, "admin-a", "admin-a", "2026-07-14T07:00:00.000000Z", "2026-07-14T07:01:00.000000Z"))
mock.ExpectExec(regexp.QuoteMeta(`UPDATE vehicle_alert_rule SET enabled=?,version=version+1,updated_by=? WHERE id=? AND version=? AND archived_at IS NULL`)).WithArgs(false, "admin-a", "rule-1", 1).WillReturnResult(sqlmock.NewResult(0, 1))
columns := []string{"id", "name", "description", "trigger_type", "fence_name", "fence_longitude", "fence_latitude", "fence_radius_m", "severity", "value_type", "metric", "operator", "threshold_value", "threshold_high", "boolean_threshold", "duration_sec", "recovery_operator", "recovery_threshold", "repeat_interval_sec", "scope_protocols_json", "scope_vins_json", "scope_oems_json", "scope_models_json", "scope_companies_json", "notification_channels_json", "notification_targets_json", "enabled", "version", "created_by", "updated_by", "created_at", "updated_at"}
mock.ExpectQuery(regexp.QuoteMeta(alertRuleSelect + `WHERE id=?`)).WithArgs("rule-1").WillReturnRows(sqlmock.NewRows(columns).AddRow("rule-1", "超速", "", "metric", "", 0.0, 0.0, 0.0, "minor", "numeric", "speed_kmh", "gte", 0.0, 0.0, nil, 20, "", 0.0, 3600, `["JT808"]`, `["VIN1"]`, `[]`, `[]`, `[]`, `["in_app"]`, `[{"channel":"in_app","recipientId":"platform-operators","label":"平台值班组"}]`, false, 2, "admin-a", "admin-a", "2026-07-14T07:00:00.000000Z", "2026-07-14T07:01:00.000000Z"))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_candidate WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`DELETE FROM vehicle_alert_rule_state WHERE rule_id=?`)).WithArgs("rule-1").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,snapshot_json) VALUES(?,?,?,?,?)`)).WithArgs("rule-1", 2, "admin-a", "disable", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO vehicle_alert_rule_audit(rule_id,rule_version,actor,action,reason,snapshot_json) VALUES(?,?,?,?,?,?)`)).WithArgs("rule-1", 2, "admin-a", "disable", "", sqlmock.AnyArg()).WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
rule, err := store.SetAlertRuleEnabled(t.Context(), "rule-1", AlertRuleEnabledUpdate{Version: 1, Enabled: false, Actor: "admin-a"})
if err != nil {
@@ -380,7 +707,7 @@ func TestAlertEventInsertContractMatchesArguments(t *testing.T) {
if placeholders := strings.Count(query, "?"); placeholders != len(args) {
t.Fatalf("event insert placeholders=%d args=%d query=%s", placeholders, len(args), query)
}
if len(args) != 22 || !strings.Contains(query, "'unprocessed'") || !strings.Contains(query, "CURRENT_TIMESTAMP(3)") {
if len(args) != 23 || !strings.Contains(query, "'unprocessed'") || !strings.Contains(query, "CURRENT_TIMESTAMP(3)") {
t.Fatalf("unexpected event insert contract args=%#v query=%s", args, query)
}
}
@@ -408,6 +735,114 @@ func TestAlertRuleValidationCoversRangesAndStateChange(t *testing.T) {
}
}
func TestAlertRuleValidationAcceptsStreamMappedHydrogenMetric(t *testing.T) {
var hydrogen MetricDefinition
for _, definition := range metricDefinitions() {
if definition.Key == "hydrogen_concentration_percent" {
hydrogen = definition
break
}
}
if hydrogen.Key == "" {
t.Fatal("hydrogen concentration metric missing from catalog")
}
input := AlertRuleInput{
Name: "氢气浓度报警提示", Severity: "critical", ValueType: "numeric",
Metric: hydrogen.Key, Operator: "gt", Threshold: 0.05,
}
if err := validateAlertRule(input, hydrogen); err != nil {
t.Fatalf("stream-mapped hydrogen rule rejected: %v", err)
}
if unit := alertMetricUnit(hydrogen.Key); unit != "%" {
t.Fatalf("hydrogen alert unit=%q want %%", unit)
}
unmapped := hydrogen
unmapped.Key = "unmapped_dynamic_metric"
unmapped.SourceFields = nil
input.Metric = unmapped.Key
err := validateAlertRule(input, unmapped)
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "ALERT_RULE_METRIC_UNSUPPORTED" {
t.Fatalf("unmapped dynamic metric should remain rejected, got %v", err)
}
}
func TestAlertAutomationTriggerValidationAndNormalization(t *testing.T) {
geofence := AlertRuleInput{
Name: "离开临港停车场", TriggerType: "geofence", FenceName: "临港停车场",
FenceLongitude: 121.9, FenceLatitude: 30.9, FenceRadiusM: 500,
Severity: "critical", Operator: "exit", RepeatIntervalSec: 600, ScopeProtocols: []string{"JT808"},
}
normalizeAlertRuleTrigger(&geofence)
if geofence.Metric != "geofence_distance_m" || geofence.ValueType != "numeric" || geofence.Threshold != 500 {
t.Fatalf("geofence normalization incomplete: %+v", geofence)
}
if err := validateAlertRule(geofence); err != nil {
t.Fatalf("valid geofence rejected: %v", err)
}
geofence.ScopeProtocols = []string{"JT808", "GB32960"}
if err := validateAlertRule(geofence); err == nil {
t.Fatal("multi-source geofence should be rejected to prevent coordinate drift")
}
geofence.ScopeProtocols = []string{"JT808"}
geofence.FenceRadiusM = 20
if err := validateAlertRule(geofence); err == nil {
t.Fatal("unsafe geofence radius should be rejected")
}
stationary := AlertRuleInput{Name: "长时间不动", TriggerType: "stationary", Severity: "major", Operator: "lte", Threshold: 1, DurationSec: 1800}
normalizeAlertRuleTrigger(&stationary)
if err := validateAlertRule(stationary); err != nil {
t.Fatalf("valid stationary rule rejected: %v", err)
}
offline := AlertRuleInput{Name: "长时间离线", TriggerType: "offline", Severity: "major", Operator: "gt", Threshold: 36000}
normalizeAlertRuleTrigger(&offline)
if err := validateAlertRule(offline); err != nil || offline.RecoveryThreshold != 36000 {
t.Fatalf("offline rule did not normalize: rule=%+v err=%v", offline, err)
}
}
func TestGeofenceDistanceAndDirectionalTransitions(t *testing.T) {
rule := AlertRule{TriggerType: "geofence", Metric: "geofence_distance_m", Operator: "enter", FenceLongitude: 121.4737, FenceLatitude: 31.2304, FenceRadiusM: 500}
outside := alertEvaluationEvidence{Longitude: 121.49, Latitude: 31.2304, HasLocation: true}
inside := alertEvaluationEvidence{Longitude: 121.4738, Latitude: 31.2304, HasLocation: true}
outsideDistance, ok := alertRuleMetricValue(rule, outside)
if !ok || outsideDistance <= 500 {
t.Fatalf("outside distance=%f ok=%t", outsideDistance, ok)
}
insideDistance, ok := alertRuleMetricValue(rule, inside)
if !ok || insideDistance >= 500 {
t.Fatalf("inside distance=%f ok=%t", insideDistance, ok)
}
matched, state, stateful := alertRuleMatches(rule, insideDistance, alertRuleState{LastValue: 0}, true)
if !matched || !stateful || state != 1 {
t.Fatalf("enter transition not detected: matched=%t state=%f stateful=%t", matched, state, stateful)
}
rule.Operator = "exit"
matched, state, stateful = alertRuleMatches(rule, outsideDistance, alertRuleState{LastValue: 1}, true)
if !matched || !stateful || state != 0 {
t.Fatalf("exit transition not detected: matched=%t state=%f stateful=%t", matched, state, stateful)
}
}
func TestAlertNotificationPolicySupportsRecordOnlyAndHighPriority(t *testing.T) {
if channels := normalizeAlertChannels(nil); len(channels) != 0 {
t.Fatalf("record-only automation unexpectedly created channels: %+v", channels)
}
if channels := normalizeAlertChannels([]string{"sms"}); len(channels) != 2 || channels[0] != "in_app" {
t.Fatalf("reserved external channel should retain in-app evidence: %+v", channels)
}
rule := AlertRule{Name: "车辆离开围栏", TriggerType: "geofence", FenceName: "临港停车场", Operator: "exit", Severity: "critical"}
item := alertEvaluationEvidence{VIN: "VIN1", Plate: "沪A00001F"}
if title := alertNotificationTitle(rule); !strings.HasPrefix(title, "【高优先级】") {
t.Fatalf("critical notification title=%q", title)
}
if content := alertNotificationContent(rule, item, 800, "m"); !strings.Contains(content, "离开电子围栏") || !strings.Contains(content, "临港停车场") {
t.Fatalf("geofence notification content=%q", content)
}
}
func TestMetricCatalogAndAlertValidationShareStoreConfiguration(t *testing.T) {
definitions := metricDefinitions()
for index := range definitions {