feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,164 @@
package platform
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
func TestAccessSummaryUsesDynamicFreshnessAndDelay(t *testing.T) {
service := NewService(NewMockStore())
summary, err := service.AccessSummary(context.Background(), AccessQuery{})
if err != nil {
t.Fatalf("AccessSummary returned error: %v", err)
}
if summary.TotalVehicles != 6 || summary.OnlineVehicles != 2 || summary.OfflineVehicles != 2 {
t.Fatalf("unexpected access states: %+v", summary)
}
if summary.NeverReported != 1 || summary.UnknownVehicles != 1 || summary.LongOfflineVehicles != 2 {
t.Fatalf("summary must distinguish never/offline/unknown: %+v", summary)
}
if summary.DelayAbnormal != 1 || summary.ThresholdVersion != 1 {
t.Fatalf("summary must expose dynamic delay and threshold version: %+v", summary)
}
}
func TestAccessVehiclesFiltersAndKeepsEvidenceGapsExplicit(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessVehicles(context.Background(), AccessQuery{OnlineState: "never_reported", Limit: 10})
if err != nil {
t.Fatalf("AccessVehicles returned error: %v", err)
}
if page.Total != 1 || len(page.Items) != 1 || page.Items[0].OnlineState != "never_reported" {
t.Fatalf("unexpected never-reported page: %+v", page)
}
if page.Items[0].FirstSeenAt != "" || page.Items[0].FirstSeenEvidence == "" || page.Items[0].ReportIntervalProof == "" {
t.Fatalf("missing source evidence must stay explicit: %+v", page.Items[0])
}
delayed, err := service.AccessVehicles(context.Background(), AccessQuery{DelayState: "abnormal", Limit: 10})
if err != nil || delayed.Total != 1 || delayed.Items[0].DataDelaySec == nil || *delayed.Items[0].DataDelaySec != 45 {
t.Fatalf("delay filter should use event/receive difference: page=%+v err=%v", delayed, err)
}
}
func TestAccessVehiclesSupportsModelProviderAndTimeFilters(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessVehicles(t.Context(), AccessQuery{Model: "氢燃料", Provider: "车厂", LatestSeenFrom: time.Now().Add(-time.Minute).Format("2006-01-02T15:04"), Limit: 10})
if err != nil || page.Total != 1 || len(page.Items) != 1 || page.Items[0].VIN != "LNXNEGRR7SR318212" {
t.Fatalf("advanced access filters failed: page=%+v err=%v", page, err)
}
_, err = service.AccessVehicles(t.Context(), AccessQuery{FirstSeenFrom: "not-a-time"})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "ACCESS_TIME_INVALID" {
t.Fatalf("invalid access time must be rejected, got %v", err)
}
_, err = service.AccessSummary(t.Context(), AccessQuery{LatestSeenFrom: "2026-07-14T09:00", LatestSeenTo: "2026-07-14T08:00"})
clientErr, ok = asClientError(err)
if !ok || clientErr.Code != "ACCESS_TIME_RANGE_INVALID" {
t.Fatalf("reversed access time range must be rejected, got %v", err)
}
}
func TestAccessThresholdUpdateUsesOptimisticVersion(t *testing.T) {
store := NewMockStore()
service := NewService(store)
updated, err := service.UpdateAccessThresholds(context.Background(), AccessThresholdUpdate{
Version: 1, DefaultThresholdSec: 600, DelayThresholdSec: 60, LongOfflineSec: 3600,
Protocols: []AccessProtocolThreshold{{Protocol: "JT808", ThresholdSec: 300}}, Actor: "tester",
})
if err != nil {
t.Fatalf("UpdateAccessThresholds returned error: %v", err)
}
if updated.Version != 2 || updated.UpdatedBy != "tester" || len(updated.Audit) != 1 {
t.Fatalf("threshold update should version and audit: %+v", updated)
}
_, err = service.UpdateAccessThresholds(context.Background(), AccessThresholdUpdate{
Version: 1, DefaultThresholdSec: 600, DelayThresholdSec: 60, LongOfflineSec: 3600,
})
clientErr, ok := asClientError(err)
if !ok || clientErr.Code != "ACCESS_THRESHOLD_VERSION_CONFLICT" {
t.Fatalf("stale update should conflict, got %v", err)
}
}
func TestAccessVehicleCarriesDurableProjectionEvidenceAndMasterData(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessVehicles(context.Background(), AccessQuery{Keyword: "LB9A32A24R0LS1426", Limit: 10})
if err != nil || len(page.Items) != 1 {
t.Fatalf("access vehicle query failed: page=%+v err=%v", page, err)
}
row := page.Items[0]
if row.Company != "岭牛示范车队" || row.FirstSeenSource != "live_writer" || row.ReportSampleCount != 120 || row.ReportIntervalSec == nil {
t.Fatalf("durable access evidence/master data missing: %+v", row)
}
}
func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
mock.ExpectQuery("(?s)SELECT.*access_first_seen_at.*access_first_seen_source.*access_report_interval_ms.*vehicle_profile.*access_latest_received_at").
WithArgs(accessEvidenceLimit + 1).
WillReturnRows(sqlmock.NewRows([]string{
"vin", "plate", "oem", "model_name", "company_name", "protocol", "provider", "first_seen_at", "first_seen_source",
"event_time", "received_at", "updated_at", "report_interval_ms", "report_sample_count", "event_id",
}).AddRow("VIN001", "粤A1", "示范厂家", "纯电客车", "示范公交", "GB32960", "车厂平台", "2026-07-14 05:00:00.000", "snapshot_backfill", "2026-07-14 05:01:00.000", "2026-07-14 05:01:30.500", "2026-07-14 05:01:30.500", int64(30500), int64(3), "evt-3"))
rows, err := store.AccessEvidence(context.Background())
if err != nil || len(rows) != 1 {
t.Fatalf("AccessEvidence rows=%+v err=%v", rows, err)
}
row := rows[0]
if row.ReportIntervalSec == nil || *row.ReportIntervalSec != 31 || row.ReportSampleCount != 3 || row.Model != "纯电客车" || row.Company != "示范公交" {
t.Fatalf("unexpected durable projection row: %+v", row)
}
if row.FirstSeenEvidence != "上线基线:由实时快照回填(非历史首次接入)" || row.ReportIntervalProof == "" {
t.Fatalf("projection evidence boundary missing: %+v", row)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestAccessUnresolvedIdentitiesRemainMaskedAndProtocolScoped(t *testing.T) {
service := NewService(NewMockStore())
page, err := service.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Protocol: "jt808", Limit: 20})
if err != nil || page.Total != 1 || len(page.Items) != 1 {
t.Fatalf("unresolved identity queue failed: page=%+v err=%v", page, err)
}
if page.Items[0].IdentifierMasked != "138****0001" || page.Items[0].IssueCode != "missing_vin_jt808" || page.Items[0].RecommendedAction == "" {
t.Fatalf("unresolved evidence is not actionable and masked: %+v", page.Items[0])
}
empty, err := service.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Protocol: "GB32960"})
if err != nil || empty.Total != 0 || len(empty.Items) != 0 {
t.Fatalf("non-JT808 unresolved query must be empty: page=%+v err=%v", empty, err)
}
}
func TestProductionUnresolvedIdentityQueueExcludesBoundPhonesAndNeverReturnsRawIdentifier(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewProductionStore(db, nil, "")
mock.ExpectQuery(`(?s)SELECT COUNT\(\*\).*jt808_registration.*COALESCE.*b\.vin`).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
mock.ExpectQuery(`(?s)SELECT.*SHA2\(CONCAT\('JT808:',r\.phone\),256\).*CHAR_LENGTH\(r\.phone\).*missing_vin_jt808.*ORDER BY r\.latest_seen_at`).WithArgs(20, 0).WillReturnRows(sqlmock.NewRows([]string{
"id", "protocol", "identifier_masked", "plate", "manufacturer", "source_endpoint", "first_registered_at", "latest_registered_at", "latest_authenticated_at", "latest_seen_at", "freshness_sec", "issue_code", "recommended_action",
}).AddRow("hash-id", "JT808", "410****3543", "", "示范终端", "gateway-a", "", "", "", "2026-07-14 07:52:09", 20, "missing_vin_jt808", "核对后绑定"))
page, err := store.AccessUnresolvedIdentities(t.Context(), AccessUnresolvedIdentityQuery{Limit: 20})
if err != nil || page.Total != 1 || len(page.Items) != 1 {
t.Fatalf("production unresolved query failed: page=%+v err=%v", page, err)
}
if page.Items[0].ID != "hash-id" || page.Items[0].IdentifierMasked != "410****3543" || page.Items[0].FreshnessSec != 20 {
t.Fatalf("production unresolved evidence lost: %+v", page.Items[0])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}