diff --git a/vehicle-data-platform/apps/api/internal/app/auth.go b/vehicle-data-platform/apps/api/internal/app/auth.go index f1fc56cb..d3b52941 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth.go +++ b/vehicle-data-platform/apps/api/internal/app/auth.go @@ -232,6 +232,12 @@ func requiredMenu(r *http.Request) string { } func requiredRole(r *http.Request) string { + if strings.HasPrefix(r.URL.Path, "/api/v2/operations/") { + if r.Method == http.MethodGet || r.Method == http.MethodHead { + return "operator" + } + return "admin" + } if (r.Method == http.MethodGet || r.Method == http.MethodHead) && strings.HasPrefix(r.URL.Path, "/api/v2/exports") { return "viewer" } diff --git a/vehicle-data-platform/apps/api/internal/app/auth_test.go b/vehicle-data-platform/apps/api/internal/app/auth_test.go index 1a5affda..efe5e6f6 100644 --- a/vehicle-data-platform/apps/api/internal/app/auth_test.go +++ b/vehicle-data-platform/apps/api/internal/app/auth_test.go @@ -94,6 +94,18 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) { if operatorProfileSync.Code != http.StatusForbidden { t.Fatalf("operator profile sync should be forbidden, status=%d", operatorProfileSync.Code) } + viewerSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", viewerToken) + if viewerSourceDiagnostic.Code != http.StatusForbidden { + t.Fatalf("viewer source diagnostic should be forbidden, status=%d", viewerSourceDiagnostic.Code) + } + operatorSourceDiagnostic := authRequest(t, cfg, http.MethodGet, "/api/v2/operations/vehicles/VIN001/sources", operatorToken) + if operatorSourceDiagnostic.Code != http.StatusNoContent { + t.Fatalf("operator source diagnostic status=%d", operatorSourceDiagnostic.Code) + } + operatorSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", operatorToken) + if operatorSourcePolicy.Code != http.StatusForbidden { + t.Fatalf("operator source policy mutation should be forbidden, status=%d", operatorSourcePolicy.Code) + } adminProfile := authRequest(t, cfg, http.MethodPut, "/api/v2/vehicles/VIN001/profile", adminToken) if adminProfile.Code != http.StatusNoContent || adminProfile.Header().Get("X-Principal") != "admin-a:admin" { t.Fatalf("admin profile mutation status=%d principal=%s", adminProfile.Code, adminProfile.Header().Get("X-Principal")) @@ -106,6 +118,10 @@ func TestAPIAuthEnforcesTokensAndRoleBoundaries(t *testing.T) { if adminThreshold.Code != http.StatusNoContent { t.Fatalf("admin threshold status=%d body=%s", adminThreshold.Code, adminThreshold.Body.String()) } + adminSourcePolicy := authRequest(t, cfg, http.MethodPut, "/api/v2/operations/vehicles/VIN001/sources/ref", adminToken) + if adminSourcePolicy.Code != http.StatusNoContent { + t.Fatalf("admin source policy status=%d body=%s", adminSourcePolicy.Code, adminSourcePolicy.Body.String()) + } } func TestAPIAuthSessionAndDisabledMode(t *testing.T) { diff --git a/vehicle-data-platform/apps/api/internal/platform/handler.go b/vehicle-data-platform/apps/api/internal/platform/handler.go index efba52ae..8d7f3335 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler.go @@ -61,6 +61,8 @@ func (h *Handler) routes() { h.mux.HandleFunc("PUT /api/v2/vehicles/{vin}/profile", h.handleSaveVehicleProfile) h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/telemetry/latest", h.handleLatestTelemetry) h.mux.HandleFunc("GET /api/v2/vehicles/{vin}/source-evidence", h.handleVehicleSourceEvidence) + h.mux.HandleFunc("GET /api/v2/operations/vehicles/{vin}/sources", h.handleVehicleSourceDiagnostic) + h.mux.HandleFunc("PUT /api/v2/operations/vehicles/{vin}/sources/{sourceRef}", h.handleUpdateVehicleSourcePolicy) h.mux.HandleFunc("POST /api/v2/vehicle-profiles/sync", h.handleSyncVehicleProfiles) h.mux.HandleFunc("GET /api/v2/tracks", h.handleTrackPlayback) h.mux.HandleFunc("GET /api/v2/metrics", h.handleMetricCatalog) @@ -111,6 +113,22 @@ func (h *Handler) handleVehicleSourceEvidence(w http.ResponseWriter, r *http.Req h.write(w, r, data, err) } +func (h *Handler) handleVehicleSourceDiagnostic(w http.ResponseWriter, r *http.Request) { + data, err := h.service.VehicleSourceDiagnostic(r.Context(), r.PathValue("vin")) + h.write(w, r, data, err) +} + +func (h *Handler) handleUpdateVehicleSourcePolicy(w http.ResponseWriter, r *http.Request) { + var update VehicleSourcePolicyUpdate + if !decodeJSONBody(w, r, &update) { + return + } + update.SourceRef = r.PathValue("sourceRef") + update.Actor = ActorFromContext(r.Context()) + data, err := h.service.UpdateVehicleSourcePolicy(r.Context(), r.PathValue("vin"), update) + h.write(w, r, data, err) +} + func (h *Handler) handleSaveVehicleProfile(w http.ResponseWriter, r *http.Request) { var input VehicleProfileInput if !decodeJSONBody(w, r, &input) { diff --git a/vehicle-data-platform/apps/api/internal/platform/handler_test.go b/vehicle-data-platform/apps/api/internal/platform/handler_test.go index 1e547b12..efd84024 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -2,7 +2,9 @@ package platform import ( "bytes" + "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -420,6 +422,31 @@ func TestHandlerVehicleSourceEvidenceRejectsInvalidDate(t *testing.T) { } } +func TestHandlerVehicleSourceDiagnosticAndPolicyUpdate(t *testing.T) { + handler := NewHandler(NewService(NewMockStore())) + ctx := WithPrincipal(context.Background(), Principal{Name: "平台管理员", Role: "admin", UserType: "admin"}) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v2/operations/vehicles/LB9A32A24R0LS1426/sources", nil).WithContext(ctx) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("diagnostic status=%d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Data VehicleSourceDiagnostic `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + source := body.Data.Evidence.LocationSources[1] + payload := fmt.Sprintf(`{"version":%d,"enabled":false,"priority":%d,"remark":"人工核验"}`, body.Data.Policy.Version, source.Priority) + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, "/api/v2/operations/vehicles/LB9A32A24R0LS1426/sources/"+source.SourceRef, strings.NewReader(payload)).WithContext(ctx) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"version":2`) { + t.Fatalf("policy update status=%d body=%s", rec.Code, rec.Body.String()) + } +} + func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) { handler := NewHandler(NewService(NewMockStore())) rec := httptest.NewRecorder() diff --git a/vehicle-data-platform/apps/api/internal/platform/mock_store.go b/vehicle-data-platform/apps/api/internal/platform/mock_store.go index 75f5fcc1..2378bbb5 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -15,6 +15,8 @@ type MockStore struct { locations []RealtimeLocationRow accessMu sync.RWMutex accessThresholds AccessThresholdConfig + sourcePolicyMu sync.RWMutex + sourcePolicies map[string]VehicleSourcePolicyConfig profileMu sync.RWMutex profiles map[string]VehicleProfile alertMu sync.RWMutex @@ -36,6 +38,7 @@ func NewMockStore() *MockStore { store := &MockStore{ vehicles: vehicles, accessThresholds: defaultAccessThresholds(time.Now()), + sourcePolicies: map[string]VehicleSourcePolicyConfig{}, profiles: map[string]VehicleProfile{ "LB9A32A24R0LS1426": {VIN: "LB9A32A24R0LS1426", ModelName: "新能源运营车", VehicleType: "乘用车", CompanyName: "岭牛示范车队", OperationStatus: "active", AccessProvider: "G7", FirstAccessAt: "2026-03-01T08:00:00+08:00", RuntimeSeconds: int64Pointer(1263600), SourceSystem: "manual", Version: 1, UpdatedBy: "demo-admin", UpdatedAt: "2026-07-03T20:12:10+08:00"}, }, @@ -51,6 +54,54 @@ func NewMockStore() *MockStore { func int64Pointer(value int64) *int64 { return &value } +func (m *MockStore) VehicleLocationSourceHistory(_ context.Context, vin string) ([]vehicleLocationSourceHistory, error) { + if vin != "LB9A32A24R0LS1426" { + return []vehicleLocationSourceHistory{}, nil + } + return []vehicleLocationSourceHistory{ + {Protocol: "GB32960", sourceKey: "GB32960:canonical", FirstSeenAt: "2026-03-01 08:00:00", LastSeenAt: "2026-07-16 16:20:08", ReportSampleCount: 108000}, + {Protocol: "JT808", sourceKey: "jt808:g7", FirstSeenAt: "2026-04-01 09:00:00", LastSeenAt: "2026-07-16 16:20:06", ReportSampleCount: 432000}, + {Protocol: "JT808", sourceKey: "jt808:beidou", FirstSeenAt: "2026-05-01 09:00:00", LastSeenAt: "2026-07-16 16:19:58", ReportSampleCount: 216000}, + }, nil +} + +func (m *MockStore) VehicleSourcePolicy(_ context.Context, vin string) (VehicleSourcePolicyConfig, error) { + m.sourcePolicyMu.RLock() + defer m.sourcePolicyMu.RUnlock() + config, exists := m.sourcePolicies[vin] + if !exists { + return VehicleSourcePolicyConfig{VIN: vin, Version: 1, UpdatedBy: "system", Audit: []VehicleSourcePolicyAudit{}}, nil + } + config.Audit = append([]VehicleSourcePolicyAudit(nil), config.Audit...) + return config, nil +} + +func (m *MockStore) SaveVehicleSourcePolicy(_ context.Context, update vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error) { + m.sourcePolicyMu.Lock() + defer m.sourcePolicyMu.Unlock() + current, exists := m.sourcePolicies[update.VIN] + if !exists { + current = VehicleSourcePolicyConfig{VIN: update.VIN, Version: 1, UpdatedBy: "system", Audit: []VehicleSourcePolicyAudit{}} + } + if current.Version != update.Version { + return VehicleSourcePolicyConfig{}, clientError{Code: "SOURCE_POLICY_VERSION_CONFLICT", Message: "来源策略已被其他用户更新,请刷新后重试"} + } + if update.CurrentEnabled == update.Enabled && update.CurrentPriority == update.Priority && update.CurrentRemark == update.Remark { + return current, nil + } + now := time.Now().Format(time.RFC3339) + current.Version++ + current.UpdatedBy = update.Actor + current.UpdatedAt = now + current.Audit = append([]VehicleSourcePolicyAudit{{ + Version: current.Version, Protocol: update.Protocol, SourceRef: update.SourceRef, + SourceLabel: update.SourceLabel, Actor: update.Actor, ChangedAt: now, + Summary: update.SourceLabel + ":" + enabledLabel(update.CurrentEnabled) + "→" + enabledLabel(update.Enabled), + }}, current.Audit...) + m.sourcePolicies[update.VIN] = current + return current, nil +} + func (m *MockStore) VehicleProfile(_ context.Context, vin string) (VehicleProfile, bool, error) { m.profileMu.RLock() defer m.profileMu.RUnlock() diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index da0d7006..8310508e 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -944,6 +944,7 @@ type VehicleLocationSourceEvidence struct { SourceLabel string `json:"sourceLabel"` TerminalLabel string `json:"terminalLabel"` SourceKind string `json:"sourceKind"` + SourceRef string `json:"sourceRef,omitempty"` SelectedWithinProtocol bool `json:"selectedWithinProtocol"` Recommended bool `json:"recommended"` Enabled bool `json:"enabled"` @@ -958,6 +959,10 @@ type VehicleLocationSourceEvidence struct { SOCPercent *float64 `json:"socPercent,omitempty"` EventTime string `json:"eventTime"` ReceivedAt string `json:"receivedAt"` + FirstSeenAt string `json:"firstSeenAt,omitempty"` + ReportIntervalSec *int `json:"reportIntervalSec,omitempty"` + ReportSampleCount int64 `json:"reportSampleCount,omitempty"` + SelectionReason string `json:"selectionReason,omitempty"` sourceKey string } @@ -988,6 +993,60 @@ type VehicleSourceEvidenceComparison struct { ReportTimeDeltaSeconds float64 `json:"reportTimeDeltaSeconds"` } +type VehicleSourceDiagnostic struct { + Evidence VehicleSourceEvidence `json:"evidence"` + Access *AccessVehicleRow `json:"access,omitempty"` + Policy VehicleSourcePolicyConfig `json:"policy"` + RecommendationReason string `json:"recommendationReason"` + RefreshHint string `json:"refreshHint"` +} + +type VehicleSourcePolicyConfig struct { + VIN string `json:"vin"` + Version int `json:"version"` + UpdatedBy string `json:"updatedBy"` + UpdatedAt string `json:"updatedAt"` + Audit []VehicleSourcePolicyAudit `json:"audit"` +} + +type VehicleSourcePolicyAudit struct { + Version int `json:"version"` + Protocol string `json:"protocol"` + SourceRef string `json:"sourceRef"` + SourceLabel string `json:"sourceLabel"` + Actor string `json:"actor"` + ChangedAt string `json:"changedAt"` + Summary string `json:"summary"` +} + +type VehicleSourcePolicyUpdate struct { + Version int `json:"version"` + SourceRef string `json:"sourceRef"` + Enabled bool `json:"enabled"` + Priority int `json:"priority"` + Remark string `json:"remark"` + Actor string `json:"actor"` +} + +type vehicleSourcePolicyStoreUpdate struct { + VehicleSourcePolicyUpdate + VIN string + Protocol string + SourceKey string + SourceLabel string + CurrentEnabled bool + CurrentPriority int + CurrentRemark string +} + +type vehicleLocationSourceHistory struct { + Protocol string + sourceKey string + FirstSeenAt string + LastSeenAt string + ReportSampleCount int64 +} + type VehicleServiceOverview struct { VIN string `json:"vin"` Plate string `json:"plate"` diff --git a/vehicle-data-platform/apps/api/internal/platform/service_test.go b/vehicle-data-platform/apps/api/internal/platform/service_test.go index 9f074daa..147e6af6 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -216,6 +216,59 @@ func TestCustomerSourceEvidenceUsesOnlyCompleteAuthorizedDays(t *testing.T) { } } +func TestVehicleSourceDiagnosticExposesOpaqueReferencesAndElectionReasons(t *testing.T) { + service := NewService(NewMockStore()) + ctx := WithPrincipal(context.Background(), Principal{Name: "运维员", Role: "operator", UserType: "operator"}) + diagnostic, err := service.VehicleSourceDiagnostic(ctx, "LB9A32A24R0LS1426") + if err != nil { + t.Fatalf("VehicleSourceDiagnostic returned error: %v", err) + } + if diagnostic.Policy.Version != 1 || diagnostic.Access == nil || diagnostic.Evidence.RecommendedLocationProtocol != "JT808" { + t.Fatalf("diagnostic lost access or policy evidence: %+v", diagnostic) + } + if diagnostic.RecommendationReason == "" || diagnostic.RefreshHint == "" { + t.Fatalf("diagnostic must explain recommendation and refresh boundary: %+v", diagnostic) + } + for _, source := range diagnostic.Evidence.LocationSources { + if len(source.SourceRef) != 64 || source.SelectionReason == "" || source.FirstSeenAt == "" { + t.Fatalf("source diagnostic is incomplete: %+v", source) + } + if strings.Contains(source.SourceRef, "jt808") || strings.Contains(source.SourceRef, "g7") { + t.Fatalf("source reference must stay opaque: %+v", source) + } + } +} + +func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testing.T) { + service := NewService(NewMockStore()) + operator := WithPrincipal(context.Background(), Principal{Name: "运维员", Role: "operator", UserType: "operator"}) + diagnostic, err := service.VehicleSourceDiagnostic(operator, "LB9A32A24R0LS1426") + if err != nil { + t.Fatal(err) + } + source := diagnostic.Evidence.LocationSources[1] + if _, err := service.UpdateVehicleSourcePolicy(operator, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{ + Version: diagnostic.Policy.Version, SourceRef: source.SourceRef, Enabled: false, Priority: source.Priority, Remark: "人工核验", + }); err == nil { + t.Fatal("operator must not modify source policy") + } + admin := WithPrincipal(context.Background(), Principal{Name: "平台管理员", Role: "admin", UserType: "admin"}) + updated, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{ + Version: diagnostic.Policy.Version, SourceRef: source.SourceRef, Enabled: false, Priority: source.Priority, Remark: "人工核验", + }) + if err != nil { + t.Fatalf("admin source policy update failed: %v", err) + } + if updated.Policy.Version != 2 || len(updated.Policy.Audit) != 1 || updated.Policy.Audit[0].Actor != "平台管理员" { + t.Fatalf("source policy update must version and audit: %+v", updated.Policy) + } + if _, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{ + Version: 1, SourceRef: source.SourceRef, Enabled: true, Priority: source.Priority, + }); err == nil { + t.Fatal("stale source policy update must conflict") + } +} + func (s *countingStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) { s.overviewBatchCalls++ return s.MockStore.VehicleServiceOverviews(ctx, query) diff --git a/vehicle-data-platform/apps/api/internal/platform/source_policy.go b/vehicle-data-platform/apps/api/internal/platform/source_policy.go new file mode 100644 index 00000000..5c51eb6a --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/source_policy.go @@ -0,0 +1,208 @@ +package platform + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +type vehicleSourcePolicyStore interface { + VehicleSourcePolicy(context.Context, string) (VehicleSourcePolicyConfig, error) + SaveVehicleSourcePolicy(context.Context, vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error) + VehicleLocationSourceHistory(context.Context, string) ([]vehicleLocationSourceHistory, error) +} + +func sourceReference(vin, protocol, sourceKey string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(vin) + "\x00" + strings.TrimSpace(protocol) + "\x00" + sourceKey)) + return hex.EncodeToString(sum[:]) +} + +func (s *Service) VehicleSourceDiagnostic(ctx context.Context, vin string) (VehicleSourceDiagnostic, error) { + if err := authorizeInternalOperations(ctx, false); err != nil { + return VehicleSourceDiagnostic{}, err + } + evidence, err := s.VehicleSourceEvidence(ctx, vin, "") + if err != nil { + return VehicleSourceDiagnostic{}, err + } + store, ok := s.store.(vehicleSourcePolicyStore) + if !ok { + return VehicleSourceDiagnostic{}, errors.New("vehicle source policy store is not configured") + } + history, err := store.VehicleLocationSourceHistory(ctx, evidence.VIN) + if err != nil { + return VehicleSourceDiagnostic{}, err + } + historyBySource := make(map[string]vehicleLocationSourceHistory, len(history)) + for _, item := range history { + historyBySource[item.Protocol+"\x00"+item.sourceKey] = item + } + var access *AccessVehicleRow + page, accessErr := s.AccessVehicles(ctx, AccessQuery{Keyword: evidence.VIN, Limit: 10}) + if accessErr == nil { + for index := range page.Items { + if page.Items[index].VIN == evidence.VIN { + row := page.Items[index] + access = &row + break + } + } + } + protocolStatus := map[string]AccessProtocolStatus{} + if access != nil { + for _, status := range access.ProtocolStatuses { + protocolStatus[status.Protocol] = status + } + } + for index := range evidence.LocationSources { + source := &evidence.LocationSources[index] + if !strings.EqualFold(source.SourceKind, "CANONICAL") { + source.SourceRef = sourceReference(evidence.VIN, source.Protocol, source.sourceKey) + } + if item, exists := historyBySource[source.Protocol+"\x00"+source.sourceKey]; exists { + source.FirstSeenAt = item.FirstSeenAt + source.ReportSampleCount = item.ReportSampleCount + } + if status, exists := protocolStatus[source.Protocol]; exists { + source.ReportIntervalSec = status.ReportIntervalSec + if source.FirstSeenAt == "" { + source.FirstSeenAt = status.FirstSeenAt + } + } + source.SelectionReason = sourceSelectionReason(*source) + } + policy, err := store.VehicleSourcePolicy(ctx, evidence.VIN) + if err != nil { + return VehicleSourceDiagnostic{}, err + } + return VehicleSourceDiagnostic{ + Evidence: evidence, + Access: access, + Policy: policy, + RecommendationReason: recommendationReason(evidence), + RefreshHint: "策略保存后由车辆下一次有效上报触发网关重新选举;页面不会伪造即时切换结果。", + }, nil +} + +func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, update VehicleSourcePolicyUpdate) (VehicleSourceDiagnostic, error) { + if err := authorizeInternalOperations(ctx, true); err != nil { + return VehicleSourceDiagnostic{}, err + } + vin = strings.ToUpper(strings.TrimSpace(vin)) + update.SourceRef = strings.ToLower(strings.TrimSpace(update.SourceRef)) + update.Remark = strings.TrimSpace(update.Remark) + if vin == "" { + return VehicleSourceDiagnostic{}, clientError{Code: "VEHICLE_VIN_REQUIRED", Message: "车辆 VIN 不能为空"} + } + if update.Version < 1 { + return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_POLICY_VERSION_INVALID", Message: "来源策略版本无效,请刷新后重试"} + } + if len(update.SourceRef) != sha256.Size*2 { + return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_REF_INVALID", Message: "来源引用无效"} + } + if _, err := hex.DecodeString(update.SourceRef); err != nil { + return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_REF_INVALID", Message: "来源引用无效"} + } + if update.Priority < 1 || update.Priority > 1000 { + return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PRIORITY_INVALID", Message: "来源优先级必须在 1 到 1000 之间"} + } + if len([]rune(update.Remark)) > 200 { + return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_REMARK_TOO_LONG", Message: "策略备注不能超过 200 个字符"} + } + update.Actor = strings.TrimSpace(update.Actor) + if update.Actor == "" { + update.Actor = ActorFromContext(ctx) + } + evidenceStore, ok := s.store.(VehicleSourceEvidenceStore) + if !ok { + return VehicleSourceDiagnostic{}, errors.New("vehicle source evidence store is not configured") + } + evidence, err := evidenceStore.VehicleSourceEvidence(ctx, vin, "") + if err != nil { + return VehicleSourceDiagnostic{}, err + } + var selected *VehicleLocationSourceEvidence + for index := range evidence.LocationSources { + source := &evidence.LocationSources[index] + if !strings.EqualFold(source.SourceKind, "CANONICAL") && sourceReference(vin, source.Protocol, source.sourceKey) == update.SourceRef { + selected = source + break + } + } + if selected == nil || selected.sourceKey == "" { + return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_NOT_FOUND", Message: "当前车辆不存在该来源,请刷新后重试"} + } + store, ok := s.store.(vehicleSourcePolicyStore) + if !ok { + return VehicleSourceDiagnostic{}, errors.New("vehicle source policy store is not configured") + } + if _, err := store.SaveVehicleSourcePolicy(ctx, vehicleSourcePolicyStoreUpdate{ + VehicleSourcePolicyUpdate: update, + VIN: vin, + Protocol: selected.Protocol, + SourceKey: selected.sourceKey, + SourceLabel: selected.SourceLabel, + CurrentEnabled: selected.Enabled, + CurrentPriority: selected.Priority, + }); err != nil { + return VehicleSourceDiagnostic{}, err + } + return s.VehicleSourceDiagnostic(ctx, vin) +} + +func authorizeInternalOperations(ctx context.Context, adminOnly bool) error { + principal, ok := PrincipalFromContext(ctx) + if !ok { + return nil + } + if principal.UserType == "customer" || principal.Role == "customer" { + return clientError{Code: "PERMISSION_DENIED", Message: "客户账号无权访问内部来源策略"} + } + if adminOnly && principal.Role != "admin" { + return clientError{Code: "PERMISSION_DENIED", Message: "只有管理员可以修改来源策略"} + } + return nil +} + +func sourceSelectionReason(source VehicleLocationSourceEvidence) string { + if strings.EqualFold(source.SourceKind, "CANONICAL") { + return "这是协议融合结果快照,不是独立终端候选;需要展开实际来源后调整策略。" + } + if !source.Enabled { + return "已由运维策略停用,不参与协议内选举。" + } + if source.QualityStatus != "" && !strings.EqualFold(source.QualityStatus, "OK") { + return fmt.Sprintf("质量状态为 %s,当前不应作为首选;%s", source.QualityStatus, firstNonEmpty(source.QualityReason, "请核对原始证据")) + } + if source.Recommended { + return fmt.Sprintf("当前融合推荐;协议内已选中,策略优先级 %d,并通过在线与质量检查。", source.Priority) + } + if source.SelectedWithinProtocol { + return fmt.Sprintf("当前为 %s 协议内候选,策略优先级 %d;跨协议融合当前推荐其他来源。", source.Protocol, source.Priority) + } + if !source.Online { + return fmt.Sprintf("来源当前离线,策略优先级 %d;在线来源会优先参与选举。", source.Priority) + } + return fmt.Sprintf("来源可用,策略优先级 %d;当前候选在新鲜度、连续有效样本或稳定性排序中未胜出。", source.Priority) +} + +func recommendationReason(evidence VehicleSourceEvidence) string { + for _, source := range evidence.LocationSources { + if source.Recommended { + if strings.EqualFold(source.SourceKind, "CANONICAL") { + return fmt.Sprintf("当前推荐 %s 的协议融合结果;本次证据未取得可配置的独立终端候选,不能从该快照反推人工优先级。", source.Protocol) + } + return fmt.Sprintf( + "当前推荐 %s / %s:来源已启用、质量为 %s、协议内选中,策略优先级为 %d。选举还会综合两分钟新鲜度、漂移冲突保护和连续有效样本。", + source.Protocol, source.SourceLabel, firstNonEmpty(source.QualityStatus, "未知"), source.Priority, + ) + } + } + if len(evidence.LocationSources) == 0 { + return "当前车辆没有可用于位置选举的来源证据。" + } + return "当前没有明确推荐来源;请检查来源启用状态、质量、新鲜度和网关下一次有效上报。" +} diff --git a/vehicle-data-platform/apps/api/internal/platform/source_policy_store.go b/vehicle-data-platform/apps/api/internal/platform/source_policy_store.go new file mode 100644 index 00000000..cb7599aa --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/platform/source_policy_store.go @@ -0,0 +1,162 @@ +package platform + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +func (s *ProductionStore) VehicleLocationSourceHistory(ctx context.Context, vin string) ([]vehicleLocationSourceHistory, error) { + rows, err := s.db.QueryContext(ctx, `SELECT protocol, source_key, +COALESCE(DATE_FORMAT(MIN(first_event_time), '%Y-%m-%d %H:%i:%s'), ''), +COALESCE(DATE_FORMAT(MAX(latest_event_time), '%Y-%m-%d %H:%i:%s'), ''), +COALESCE(SUM(sample_count), 0) +FROM vehicle_daily_mileage_source +WHERE vin = ? +GROUP BY protocol, source_key +ORDER BY protocol, source_key`, vin) + if err != nil { + return nil, err + } + defer rows.Close() + items := make([]vehicleLocationSourceHistory, 0, 8) + for rows.Next() { + var item vehicleLocationSourceHistory + if err := rows.Scan(&item.Protocol, &item.sourceKey, &item.FirstSeenAt, &item.LastSeenAt, &item.ReportSampleCount); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +func (s *ProductionStore) VehicleSourcePolicy(ctx context.Context, vin string) (VehicleSourcePolicyConfig, error) { + config := VehicleSourcePolicyConfig{VIN: vin, Version: 1, UpdatedBy: "system"} + var updatedAt sql.NullString + err := s.db.QueryRowContext(ctx, `SELECT version, updated_by, +DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') +FROM platform_vehicle_source_policy_version WHERE vin = ?`, vin).Scan(&config.Version, &config.UpdatedBy, &updatedAt) + if err != nil && err != sql.ErrNoRows { + return VehicleSourcePolicyConfig{}, err + } + if updatedAt.Valid { + config.UpdatedAt = normalizeAccessTime(updatedAt.String) + } + rows, err := s.db.QueryContext(ctx, `SELECT version, protocol, source_ref, source_label, actor, +DATE_FORMAT(changed_at, '%Y-%m-%d %H:%i:%s'), summary +FROM platform_vehicle_source_policy_audit +WHERE vin = ? +ORDER BY changed_at DESC, id DESC +LIMIT 20`, vin) + if err != nil { + return VehicleSourcePolicyConfig{}, err + } + defer rows.Close() + for rows.Next() { + var item VehicleSourcePolicyAudit + if err := rows.Scan(&item.Version, &item.Protocol, &item.SourceRef, &item.SourceLabel, &item.Actor, &item.ChangedAt, &item.Summary); err != nil { + return VehicleSourcePolicyConfig{}, err + } + item.ChangedAt = normalizeAccessTime(item.ChangedAt) + config.Audit = append(config.Audit, item) + } + return config, rows.Err() +} + +func (s *ProductionStore) SaveVehicleSourcePolicy(ctx context.Context, update vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error) { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{}) + if err != nil { + return VehicleSourcePolicyConfig{}, err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `INSERT IGNORE INTO platform_vehicle_source_policy_version +(vin, version, updated_by) VALUES (?, 1, 'system')`, update.VIN); err != nil { + return VehicleSourcePolicyConfig{}, err + } + var currentVersion int + if err := tx.QueryRowContext(ctx, `SELECT version FROM platform_vehicle_source_policy_version +WHERE vin = ? FOR UPDATE`, update.VIN).Scan(¤tVersion); err != nil { + return VehicleSourcePolicyConfig{}, err + } + if currentVersion != update.Version { + return VehicleSourcePolicyConfig{}, clientError{Code: "SOURCE_POLICY_VERSION_CONFLICT", Message: "来源策略已被其他用户更新,请刷新后重试"} + } + currentEnabled := update.CurrentEnabled + currentPriority := update.CurrentPriority + currentRemark := update.CurrentRemark + var enabled int + err = tx.QueryRowContext(ctx, `SELECT enabled, priority, remark +FROM vehicle_location_source_policy +WHERE vin = ? AND protocol = ? AND source_key = ?`, + update.VIN, update.Protocol, update.SourceKey, + ).Scan(&enabled, ¤tPriority, ¤tRemark) + if err == nil { + currentEnabled = enabled == 1 + } else if err != sql.ErrNoRows { + return VehicleSourcePolicyConfig{}, err + } + if currentEnabled == update.Enabled && currentPriority == update.Priority && strings.TrimSpace(currentRemark) == update.Remark { + if err := tx.Rollback(); err != nil { + return VehicleSourcePolicyConfig{}, err + } + return s.VehicleSourcePolicy(ctx, update.VIN) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO vehicle_location_source_policy +(vin, protocol, source_key, enabled, priority, remark) +VALUES (?, ?, ?, ?, ?, ?) +ON DUPLICATE KEY UPDATE enabled = VALUES(enabled), priority = VALUES(priority), +remark = VALUES(remark), updated_at = CURRENT_TIMESTAMP`, + update.VIN, update.Protocol, update.SourceKey, boolInt(update.Enabled), update.Priority, update.Remark, + ); err != nil { + return VehicleSourcePolicyConfig{}, err + } + nextVersion := currentVersion + 1 + result, err := tx.ExecContext(ctx, `UPDATE platform_vehicle_source_policy_version +SET version = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP +WHERE vin = ? AND version = ?`, nextVersion, update.Actor, update.VIN, currentVersion) + if err != nil { + return VehicleSourcePolicyConfig{}, err + } + if affected, err := result.RowsAffected(); err != nil || affected != 1 { + return VehicleSourcePolicyConfig{}, clientError{Code: "SOURCE_POLICY_VERSION_CONFLICT", Message: "来源策略更新冲突,请刷新后重试"} + } + summary := fmt.Sprintf( + "%s:%s→%s,优先级 %d→%d", + update.SourceLabel, + enabledLabel(currentEnabled), enabledLabel(update.Enabled), + currentPriority, update.Priority, + ) + if update.Remark != "" { + summary += ";" + update.Remark + } + if _, err := tx.ExecContext(ctx, `INSERT INTO platform_vehicle_source_policy_audit +(vin, version, protocol, source_ref, source_key, source_label, +old_enabled, new_enabled, old_priority, new_priority, old_remark, new_remark, +actor, summary) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + update.VIN, nextVersion, update.Protocol, update.SourceRef, update.SourceKey, update.SourceLabel, + boolInt(currentEnabled), boolInt(update.Enabled), currentPriority, update.Priority, currentRemark, update.Remark, + update.Actor, summary, + ); err != nil { + return VehicleSourcePolicyConfig{}, err + } + if err := tx.Commit(); err != nil { + return VehicleSourcePolicyConfig{}, err + } + return s.VehicleSourcePolicy(ctx, update.VIN) +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func enabledLabel(value bool) string { + if value { + return "启用" + } + return "停用" +} diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index bf2ded2b..da3502a2 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -56,7 +56,9 @@ import type { VehicleIdentityResolution, VehicleServiceOverview, VehicleServiceSummary, + VehicleSourceDiagnostic, VehicleSourceEvidence, + VehicleSourcePolicyUpdate, VehicleRow } from './types'; import { getAccessToken, notifyUnauthorizedSession } from '../v2/auth/session'; @@ -273,6 +275,23 @@ export const api = { const suffix = params.toString() ? `?${params.toString()}` : ''; return request(`/api/v2/vehicles/${encodeURIComponent(vin)}/source-evidence${suffix}`, withSignal(undefined, signal)); }, + vehicleSourceDiagnostic: (vin: string, signal?: AbortSignal) => request( + `/api/v2/operations/vehicles/${encodeURIComponent(vin)}/sources`, + withSignal(undefined, signal) + ), + updateVehicleSourcePolicy: (vin: string, update: VehicleSourcePolicyUpdate) => request( + `/api/v2/operations/vehicles/${encodeURIComponent(vin)}/sources/${encodeURIComponent(update.sourceRef)}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + version: update.version, + enabled: update.enabled, + priority: update.priority, + remark: update.remark + }) + } + ), updateVehicleProfile: (vin: string, input: VehicleProfileInput) => request(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input) }), diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index 2885a279..5aab4885 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -521,6 +521,7 @@ export interface VehicleLocationSourceEvidence { sourceLabel: string; terminalLabel: string; sourceKind: string; + sourceRef?: string; selectedWithinProtocol: boolean; recommended: boolean; enabled: boolean; @@ -535,6 +536,10 @@ export interface VehicleLocationSourceEvidence { socPercent?: number; eventTime: string; receivedAt: string; + firstSeenAt?: string; + reportIntervalSec?: number; + reportSampleCount?: number; + selectionReason?: string; } export interface VehicleMileageSourceEvidence { @@ -563,6 +568,40 @@ export interface VehicleSourceEvidenceComparison { reportTimeDeltaSeconds: number; } +export interface VehicleSourcePolicyAudit { + version: number; + protocol: string; + sourceRef: string; + sourceLabel: string; + actor: string; + changedAt: string; + summary: string; +} + +export interface VehicleSourcePolicyConfig { + vin: string; + version: number; + updatedBy: string; + updatedAt: string; + audit: VehicleSourcePolicyAudit[]; +} + +export interface VehicleSourceDiagnostic { + evidence: VehicleSourceEvidence; + access?: AccessVehicleRow; + policy: VehicleSourcePolicyConfig; + recommendationReason: string; + refreshHint: string; +} + +export interface VehicleSourcePolicyUpdate { + version: number; + sourceRef: string; + enabled: boolean; + priority: number; + remark: string; +} + export interface VehicleServiceOverview { vin: string; plate: string; diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx index abddc98c..279111b0 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.test.tsx @@ -1,14 +1,22 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, expect, test, vi } from 'vitest'; import OperationsPage from './OperationsPage'; -const mocks = vi.hoisted(() => ({ opsHealth: vi.fn(), sourceReadiness: vi.fn() })); +const mocks = vi.hoisted(() => ({ + opsHealth: vi.fn(), sourceReadiness: vi.fn(), session: vi.fn(), vehicleCoverage: vi.fn(), + vehicleSourceDiagnostic: vi.fn(), updateVehicleSourcePolicy: vi.fn() +})); vi.mock('../../api/client', () => ({ api: mocks })); -afterEach(() => { cleanup(); mocks.opsHealth.mockReset(); mocks.sourceReadiness.mockReset(); }); +afterEach(() => { cleanup(); Object.values(mocks).forEach((mock) => mock.mockReset()); }); + +function seedSession() { + mocks.session.mockResolvedValue({ name: '平台管理员', role: 'admin', userType: 'admin', authMode: 'enforce', menuKeys: ['operations'] }); +} test('reconciles service identities with bound and identity-required vehicles', async () => { + seedSession(); mocks.opsHealth.mockResolvedValue({ linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5, tdengineWritable: true, mysqlWritable: true, @@ -34,6 +42,7 @@ test('reconciles service identities with bound and identity-required vehicles', }); test('keeps health evidence visible when source readiness fails and retries that section', async () => { + seedSession(); mocks.opsHealth.mockResolvedValue({ linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5, tdengineWritable: true, mysqlWritable: true, @@ -54,3 +63,39 @@ test('keeps health evidence visible when source readiness fails and retries that expect(screen.queryByText('来源就绪度暂时不可用')).not.toBeInTheDocument(); expect(mocks.sourceReadiness).toHaveBeenCalledTimes(2); }); + +test('fuzzy searches a vehicle and renders all source diagnosis evidence', async () => { + seedSession(); + mocks.opsHealth.mockResolvedValue({ + linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5, + tdengineWritable: true, mysqlWritable: true, + runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false } + }); + mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] }); + mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'VIN001', plate: '粤A00001', protocols: ['JT808'], missingProtocols: [], sourceStatus: [], sourceCount: 2, onlineSourceCount: 2, online: true, lastSeen: '', bindingStatus: 'bound' }], total: 1, limit: 20, offset: 0 }); + mocks.vehicleSourceDiagnostic.mockResolvedValue({ + evidence: { + vin: 'VIN001', plate: '粤A00001', mileageDate: '2026-07-16', recommendedLocationProtocol: 'JT808', + recommendedLocationLabel: 'G7', locationConflict: true, conflictDistanceM: 233, + locationSources: [{ + protocol: 'JT808', sourceLabel: 'G7', terminalLabel: '终端 133****0001', sourceKind: 'PLATFORM', sourceRef: 'a'.repeat(64), + selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, online: true, qualityStatus: 'OK', qualityReason: '', + longitude: 113.1, latitude: 23.1, speedKmh: 30, totalMileageKm: 1000, eventTime: '2026-07-16 10:00:00', + receivedAt: '2026-07-16 10:00:01', firstSeenAt: '2026-03-01 08:00:00', reportIntervalSec: 10, reportSampleCount: 1000, + selectionReason: '当前融合推荐' + }], + mileageSources: [], comparison: { locationMaxDistanceM: 233, totalMileageDeltaKm: 0, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 0 }, asOf: '' + }, + policy: { vin: 'VIN001', version: 1, updatedBy: 'system', updatedAt: '', audit: [] }, + recommendationReason: '当前推荐 G7', refreshHint: '下一次有效上报后重新选举' + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '粤A' } }); + expect(await screen.findByText('粤A00001')).toBeInTheDocument(); + fireEvent.click(screen.getByText('粤A00001')); + expect(await screen.findByText('当前推荐 G7')).toBeInTheDocument(); + expect(screen.getByText('终端 133****0001')).toBeInTheDocument(); + expect(screen.getByText('10s')).toBeInTheDocument(); + await waitFor(() => expect(mocks.vehicleSourceDiagnostic).toHaveBeenCalledWith('VIN001', expect.any(AbortSignal))); +}); diff --git a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx index a4dbff88..ec8a7be1 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -1,6 +1,8 @@ -import { IconRefresh } from '@douyinfe/semi-icons'; -import { useQuery } from '@tanstack/react-query'; +import { IconRefresh, IconSearch } from '@douyinfe/semi-icons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { FormEvent, useDeferredValue, useEffect, useState } from 'react'; import { api } from '../../api/client'; +import type { VehicleCoverageRow, VehicleLocationSourceEvidence, VehicleSourceDiagnostic } from '../../api/types'; import { InlineError } from '../shared/AsyncState'; import { LIVE_QUERY_POLICY, QUERY_MEMORY } from '../queryPolicy'; @@ -8,13 +10,139 @@ function statusLabel(status: string) { return { ok: '正常', warning: '关注', error: '异常' }[status] ?? status; } +function fmt(value?: string) { + if (!value) return '—'; + const parsed = new Date(value.replace(' ', 'T')); + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN', { hour12: false }); +} + +function number(value?: number, digits = 1) { + return value == null || !Number.isFinite(value) ? '—' : value.toLocaleString('zh-CN', { maximumFractionDigits: digits }); +} + +function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: { + vin: string; + source: VehicleLocationSourceEvidence; + diagnostic: VehicleSourceDiagnostic; + editable: boolean; + onSaved: (next: VehicleSourceDiagnostic) => void; +}) { + const [enabled, setEnabled] = useState(source.enabled); + const [priority, setPriority] = useState(source.priority); + const [remark, setRemark] = useState(''); + useEffect(() => { + setEnabled(source.enabled); + setPriority(source.priority); + setRemark(''); + }, [source.enabled, source.priority, source.sourceRef]); + const save = useMutation({ + mutationFn: () => api.updateVehicleSourcePolicy(vin, { + version: diagnostic.policy.version, + sourceRef: source.sourceRef ?? '', + enabled, + priority, + remark + }), + onSuccess: onSaved + }); + const changed = enabled !== source.enabled || priority !== source.priority || remark.trim() !== ''; + return + {source.sourceLabel}{source.terminalLabel || source.sourceKind || '未维护终端'} + {source.protocol}{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'} + {source.online ? '在线' : '离线'}{source.qualityStatus || '未知'}{source.qualityReason ? ` · ${source.qualityReason}` : ''} + {fmt(source.firstSeenAt)}最近 {fmt(source.receivedAt || source.eventTime)} + {source.reportIntervalSec == null ? '—' : `${source.reportIntervalSec}s`}{source.reportSampleCount ? `${source.reportSampleCount.toLocaleString('zh-CN')} 个里程样本` : '暂无累计样本'} + {source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km + {source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}{source.selectionReason || '等待选举说明'} + + + setPriority(Number(event.target.value))} /> + setRemark(event.target.value)} placeholder="调整原因" /> + + {save.isError ? {save.error instanceof Error ? save.error.message : '保存失败'} : null} + + ; +} + +function SourceDiagnosticWorkspace() { + const queryClient = useQueryClient(); + const [keyword, setKeyword] = useState(''); + const deferredKeyword = useDeferredValue(keyword.trim()); + const [selected, setSelected] = useState(); + const [candidateOffset, setCandidateOffset] = useState(0); + useEffect(() => setCandidateOffset(0), [deferredKeyword]); + const candidates = useQuery({ + queryKey: ['ops-source-candidates', deferredKeyword, candidateOffset], + queryFn: ({ signal }) => api.vehicleCoverage(new URLSearchParams({ keyword: deferredKeyword, bindingStatus: 'bound', limit: '20', offset: String(candidateOffset) }), signal), + enabled: deferredKeyword.length > 0, + staleTime: 15_000, + gcTime: QUERY_MEMORY.optionGcTime + }); + const session = useQuery({ queryKey: ['ops-session'], queryFn: ({ signal }) => api.session(signal), staleTime: 60_000 }); + const diagnostic = useQuery({ + queryKey: ['ops-source-diagnostic', selected?.vin], + queryFn: ({ signal }) => api.vehicleSourceDiagnostic(selected!.vin, signal), + enabled: Boolean(selected?.vin), + staleTime: 5_000, + gcTime: QUERY_MEMORY.highVolumeGcTime + }); + const choose = (vehicle: VehicleCoverageRow) => { + setSelected(vehicle); + setKeyword(vehicle.plate || vehicle.vin); + }; + const submit = (event: FormEvent) => { + event.preventDefault(); + const first = candidates.data?.items[0]; + if (first) choose(first); + }; + const data = diagnostic.data; + const editable = session.data?.role === 'admin'; + return
+
+
专业运维工作台单车多来源诊断按需查询,不加载全量 RAW;普通客户无权访问。
+ {selected ? : null} +
+
+ + + {deferredKeyword && !selected ?
+ {candidates.isFetching ?

正在查询车辆…

: candidates.data?.items.map((vehicle) => )} + {!candidates.isFetching && !candidates.data?.items.length ?

没有匹配的已绑定车辆

: null} + {(candidates.data?.total ?? 0) > 20 ?
第 {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} 页
: null} +
: null} +
+ {diagnostic.isError ? diagnostic.refetch()} /> : null} + {!selected ?
先选择一辆车将展示所有协议和同协议多终端、推荐原因、上报周期与可审计策略。
: null} + {selected && diagnostic.isPending ?
正在读取来源证据只查询当前车辆,不会扫描整车队。
: null} + {data ? <> +
+
车辆{data.evidence.plate || selected?.plate || '未绑定车牌'}{data.evidence.vin}
+
当前推荐{data.evidence.recommendedLocationLabel || '暂无推荐'}{data.evidence.recommendedLocationProtocol || '—'}
+
来源状态{data.evidence.locationSources.filter((item) => item.online).length} / {data.evidence.locationSources.length} 在线{data.evidence.locationConflict ? `位置冲突 ${number(data.evidence.conflictDistanceM)}m` : '未发现实时位置冲突'}
+
策略版本v{data.policy.version}{data.policy.updatedAt ? `${data.policy.updatedBy} · ${fmt(data.policy.updatedAt)}` : '尚无人工调整'}
+
+
推荐说明

{data.recommendationReason}

{data.refreshHint}
+ {!editable ?

当前账号可查看诊断证据;只有管理员可以调整启停和优先级。

: null} +
+ {data.evidence.locationSources.map((source) => queryClient.setQueryData(['ops-source-diagnostic', data.evidence.vin], next)} />)} +
来源 / 终端协议在线 / 质量首次 / 最近上报上报周期位置 / 里程选举结论运维策略
+
最近策略审计仅记录实际变更,原始 source_key 不对前端暴露
+ {data.policy.audit.length ?
    {data.policy.audit.map((item) =>
  1. v{item.version}{item.summary}{item.actor} · {fmt(item.changedAt)}
  2. )}
:

该车辆尚无人工来源策略变更。

} +
+ : null} +
; +} + export default function OperationsPage() { const health = useQuery({ queryKey: ['ops-health-v2'], queryFn: ({ signal }) => api.opsHealth(signal), refetchInterval: 15_000, staleTime: 8_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY }); const readiness = useQuery({ queryKey: ['ops-source-readiness-v2'], queryFn: ({ signal }) => api.sourceReadiness(signal), refetchInterval: 30_000, staleTime: 15_000, gcTime: QUERY_MEMORY.summaryGcTime, ...LIVE_QUERY_POLICY }); const data = health.data; const sources = readiness.data; const refresh = () => Promise.all([health.refetch(), readiness.refetch()]); return
-

运维质量

服务身份、主车辆档案和实时位置采用不同可核对口径,不用前端估算。

+

运维质量

先定位单车多来源问题,再核对服务和协议全局健康;所有结论来自服务端证据。

+ {health.isError ? : null} {readiness.isError ? readiness.refetch()} /> : null}
diff --git a/vehicle-data-platform/apps/web/src/v2/styles/v2.css b/vehicle-data-platform/apps/web/src/v2/styles/v2.css index 2e1dcd98..4e08a8d5 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -391,6 +391,10 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-ops-links article { display: grid; min-height: 46px; grid-template-columns: 8px minmax(0,1fr) auto; align-items: center; gap: 9px; border-bottom: 1px solid #eef2f7; padding: 7px 13px; }.v2-ops-links article > i, .v2-ops-sources article i { width: 7px; height: 7px; border-radius: 50%; background: #94a3b8; }.v2-ops-links article > i.is-ok, .v2-ops-sources article i.is-ok { background: var(--v2-green); }.v2-ops-links article > i.is-warning, .v2-ops-sources article i.is-warning { background: var(--v2-orange); }.v2-ops-links article > i.is-error, .v2-ops-sources article i.is-error { background: var(--v2-red); }.v2-ops-links article strong { font-size: 9px; }.v2-ops-links article p { margin: 3px 0 0; color: var(--v2-muted); font-size: 8px; }.v2-ops-links article > span { border-radius: 10px; background: #f1f5f9; padding: 3px 7px; color: #64748b; font-size: 8px; } .v2-ops-runtime dl { margin: 0; padding: 7px 13px; }.v2-ops-runtime dl div { display: flex; min-height: 31px; align-items: center; justify-content: space-between; border-bottom: 1px solid #eef2f7; font-size: 9px; }.v2-ops-runtime dt { color: var(--v2-muted); }.v2-ops-runtime dd { margin: 0; }.v2-ops-clear, .v2-ops-findings { margin: 4px 13px 12px; border-radius: 6px; background: #f1fbf7; padding: 8px; color: #17815d; font-size: 8px; }.v2-ops-findings { background: #fff7ed; color: #b45309; }.v2-ops-findings p { margin: 3px 0; } .v2-ops-sources > div { display: grid; grid-template-columns: repeat(3,1fr); }.v2-ops-sources article { min-width: 0; padding: 12px 14px; }.v2-ops-sources article + article { border-left: 1px solid var(--v2-border); }.v2-ops-sources article > div { display: flex; align-items: center; gap: 7px; }.v2-ops-sources article strong { font-size: 10px; }.v2-ops-sources article span { margin-left: auto; color: var(--v2-muted); font-size: 8px; }.v2-ops-sources article b { display: block; margin-top: 9px; font-size: 14px; }.v2-ops-sources article p { margin: 6px 0 0; color: #68768a; font-size: 8px; line-height: 1.45; }.v2-ops-sources article em { display: block; margin-top: 7px; color: var(--v2-blue); font-size: 8px; font-style: normal; }.is-ok { color: var(--v2-green) !important; }.is-warning { color: #b87900 !important; }.is-error { color: var(--v2-red) !important; } +.v2-source-diagnostic { position: relative; border: 1px solid #cfdbea; border-radius: 12px; background: #fff; box-shadow: 0 8px 28px rgba(31,53,80,.08); overflow: hidden; }.v2-source-diagnostic > header { display: flex; min-height: 58px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e5ebf3; padding: 9px 14px; background: linear-gradient(100deg,#f8fbff,#f4f8ff); }.v2-source-diagnostic > header div { display: grid; gap: 2px; }.v2-source-diagnostic > header small { color: #547297; font-size: 9px; }.v2-source-diagnostic > header strong { font-size: 15px; }.v2-source-diagnostic > header span { color: var(--v2-muted); font-size: 9px; }.v2-source-diagnostic > header button, .v2-source-search > button, .v2-source-policy-cell button { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 5px; border: 1px solid #cbd8e8; border-radius: 7px; background: #fff; padding: 0 12px; color: #355272; cursor: pointer; font-size: 10px; }.v2-source-diagnostic button:disabled { cursor: not-allowed; opacity: .5; } +.v2-source-search { position: relative; display: flex; gap: 8px; padding: 12px 14px; }.v2-source-search > label { display: flex; min-width: 280px; flex: 1; height: 36px; align-items: center; gap: 8px; border: 1px solid #cbd8e8; border-radius: 8px; padding: 0 11px; color: #6c7c91; }.v2-source-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font: inherit; font-size: 11px; }.v2-source-search > button { height: 36px; border-color: #2f6fe4; background: #2f6fe4; color: #fff; }.v2-source-candidates { position: absolute; z-index: 12; top: 51px; right: 114px; left: 14px; max-height: 310px; overflow: auto; border: 1px solid #d6e0ec; border-radius: 9px; background: #fff; box-shadow: 0 12px 32px rgba(31,53,80,.18); }.v2-source-candidates > button { display: grid; width: 100%; grid-template-columns: 150px minmax(180px,1fr) auto; gap: 10px; border: 0; border-bottom: 1px solid #edf1f6; background: #fff; padding: 10px 12px; text-align: left; cursor: pointer; }.v2-source-candidates > button:hover { background: #f5f9ff; }.v2-source-candidates strong { font-size: 11px; }.v2-source-candidates span, .v2-source-candidates em, .v2-source-candidates p { color: var(--v2-muted); font-size: 9px; font-style: normal; }.v2-source-candidates p { margin: 0; padding: 14px; }.v2-source-candidates footer { position: sticky; bottom: 0; display: flex; align-items: center; justify-content: space-between; border-top: 1px solid #e5ebf3; background: #fff; padding: 7px 10px; }.v2-source-candidates footer div { display: flex; gap: 5px; }.v2-source-candidates footer button { border: 1px solid #d2dce8; border-radius: 5px; background: #fff; padding: 4px 8px; color: #52657d; font-size: 8px; } +.v2-source-empty { display: grid; min-height: 110px; place-content: center; gap: 6px; color: var(--v2-muted); text-align: center; }.v2-source-empty strong { color: #42556e; font-size: 13px; }.v2-source-empty span { font-size: 10px; }.v2-source-summary { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); border-block: 1px solid #e7edf4; background: #fafcff; }.v2-source-summary article { min-width: 0; padding: 11px 14px; }.v2-source-summary article + article { border-left: 1px solid #e7edf4; }.v2-source-summary small, .v2-source-summary span { display: block; overflow: hidden; color: var(--v2-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-summary strong { display: block; margin: 5px 0; overflow: hidden; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }.v2-source-recommendation { margin: 12px 14px 8px; border-left: 3px solid #2f6fe4; border-radius: 6px; background: #f3f7ff; padding: 9px 11px; }.v2-source-recommendation strong { font-size: 10px; }.v2-source-recommendation p { margin: 4px 0; color: #405774; font-size: 9px; line-height: 1.5; }.v2-source-recommendation span, .v2-source-readonly { color: #728299; font-size: 8px; }.v2-source-readonly { margin: 0 14px 8px; border-radius: 6px; background: #fff8e8; padding: 7px 9px; color: #966500; } +.v2-source-table-wrap { margin: 0 14px 12px; overflow: auto; border: 1px solid #dfe7f0; border-radius: 8px; }.v2-source-table { width: 100%; min-width: 1320px; border-collapse: collapse; table-layout: fixed; }.v2-source-table th { height: 34px; background: #f6f8fb; color: #63748a; font-size: 8px; text-align: left; }.v2-source-table th, .v2-source-table td { border-bottom: 1px solid #e8edf3; padding: 7px 9px; vertical-align: top; }.v2-source-table tbody tr:last-child td { border-bottom: 0; }.v2-source-table tbody tr.is-recommended { background: #f4fbf8; }.v2-source-table td { color: #3e5067; font-size: 9px; line-height: 1.45; }.v2-source-table td > strong, .v2-source-table td > span { display: block; }.v2-source-table td > span { margin-top: 3px; color: var(--v2-muted); }.v2-source-table td > i { display: inline-block; width: 7px; height: 7px; margin-right: 5px; border-radius: 50%; background: #9aa7b8; }.v2-source-table td > i.is-online { background: var(--v2-green); }.v2-source-table td > i.is-offline { background: #94a3b8; }.v2-source-reason span { max-width: 230px; white-space: normal !important; }.v2-source-policy-cell { display: grid; grid-template-columns: auto 64px; gap: 5px; }.v2-source-policy-cell label { display: flex; align-items: center; gap: 4px; }.v2-source-policy-cell input[type=number], .v2-source-policy-cell input[type=text], .v2-source-policy-cell > input:not([type]) { min-width: 0; height: 26px; border: 1px solid #ccd7e5; border-radius: 5px; padding: 0 6px; font-size: 9px; }.v2-source-policy-cell > input:last-of-type { grid-column: 1 / -1; }.v2-source-policy-cell button { grid-column: 1 / -1; height: 27px; }.v2-source-policy-cell em { grid-column: 1 / -1; color: var(--v2-red); font-size: 8px; font-style: normal; }.v2-source-audit { margin: 0 14px 14px; border: 1px solid #e0e7f0; border-radius: 8px; }.v2-source-audit > header { display: flex; height: 34px; align-items: center; justify-content: space-between; border-bottom: 1px solid #e7edf4; padding: 0 10px; }.v2-source-audit > header strong { font-size: 10px; }.v2-source-audit > header span, .v2-source-audit > p { color: var(--v2-muted); font-size: 8px; }.v2-source-audit > p { margin: 0; padding: 12px; }.v2-source-audit ol { max-height: 170px; margin: 0; overflow: auto; padding: 0; list-style: none; }.v2-source-audit li { display: grid; min-height: 34px; grid-template-columns: 38px minmax(0,1fr) auto; align-items: center; gap: 8px; border-bottom: 1px solid #edf1f6; padding: 5px 10px; font-size: 9px; }.v2-source-audit li em { color: var(--v2-muted); font-size: 8px; font-style: normal; } .v2-vehicle-search-page, .v2-not-found { display: grid; min-height: 100%; place-items: center; padding: 28px; } .v2-vehicle-search-card { width: min(660px, 100%); border: 1px solid var(--v2-border); border-radius: 16px; background: #fff; padding: 54px; text-align: center; box-shadow: var(--v2-shadow); } @@ -1121,6 +1125,7 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-alert-rules { display: flex; flex-direction: column; } .v2-alert-rule-list { max-height: 300px; flex: none; } .v2-ops-kpis { grid-template-columns: repeat(3,1fr); }.v2-ops-grid { grid-template-columns: 1fr; }.v2-ops-sources > div { grid-template-columns: 1fr; }.v2-ops-sources article + article { border-top: 1px solid var(--v2-border); border-left: 0; } + .v2-source-summary { grid-template-columns: repeat(2,1fr); }.v2-source-summary article:nth-child(3) { border-left: 0; }.v2-source-summary article:nth-child(n+3) { border-top: 1px solid #e7edf4; } } .v2-mileage-page { display: flex; min-height: 100%; flex-direction: column; gap: 12px; padding: 18px 20px 20px; } @@ -1435,6 +1440,7 @@ button, a { -webkit-tap-highlight-color: transparent; } .v2-alert-rule-editor > footer { align-items: stretch; flex-direction: column; } .v2-alert-notifications > footer { flex-direction: column; gap: 5px; } .v2-ops-page { padding: 8px; }.v2-ops-heading { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-ops-kpis { grid-template-columns: 1fr 1fr; }.v2-ops-kpis article + article::before { display: none; }.v2-ops-kpis article { border-bottom: 1px solid var(--v2-border); } + .v2-source-diagnostic > header { align-items: flex-start; flex-direction: column; gap: 8px; }.v2-source-diagnostic > header button { width: 100%; }.v2-source-search { flex-direction: column; }.v2-source-search > label { width: auto; min-width: 0; height: 42px; }.v2-source-search > button { width: 100%; }.v2-source-candidates { top: 61px; right: 14px; }.v2-source-candidates > button { grid-template-columns: 1fr; gap: 3px; }.v2-source-summary { grid-template-columns: 1fr; }.v2-source-summary article + article, .v2-source-summary article:nth-child(3) { border-top: 1px solid #e7edf4; border-left: 0; }.v2-source-audit > header { height: auto; align-items: flex-start; flex-direction: column; gap: 3px; padding-block: 7px; }.v2-source-audit li { grid-template-columns: 35px minmax(0,1fr); }.v2-source-audit li em { grid-column: 2; } .v2-mileage-page { gap: 10px; padding: 12px 8px 18px; } .v2-mileage-heading { align-items: flex-start; flex-direction: row; gap: 10px; } .v2-mileage-heading h2 { font-size: 18px; } diff --git a/vehicle-data-platform/deploy/install-web-release.sh b/vehicle-data-platform/deploy/install-web-release.sh index 1777c870..69b02412 100755 --- a/vehicle-data-platform/deploy/install-web-release.sh +++ b/vehicle-data-platform/deploy/install-web-release.sh @@ -85,6 +85,10 @@ if test -f "$old/lingniu-vehicle-platform.service"; then cp "$old/lingniu-vehicle-platform.service" "$next/lingniu-vehicle-platform.service" fi cp -a "$old/deploy" "$next/deploy" +if test -d "$SCRIPT_DIR/migrations"; then + mkdir -p "$next/deploy/migrations" + cp "$SCRIPT_DIR"/migrations/*.sql "$next/deploy/migrations/" +fi cp "$SCRIPT_DIR/prepare-web-release-tree.sh" "$next/deploy/prepare-web-release-tree.sh" cp "$SCRIPT_DIR/prune-release-history.py" "$next/deploy/prune-release-history.py" cp "$SCRIPT_DIR/verify-customer-demo.py" "$next/deploy/verify-customer-demo.py" diff --git a/vehicle-data-platform/deploy/install-web-release.test.sh b/vehicle-data-platform/deploy/install-web-release.test.sh index 019206b6..c1cbfec3 100755 --- a/vehicle-data-platform/deploy/install-web-release.test.sh +++ b/vehicle-data-platform/deploy/install-web-release.test.sh @@ -42,6 +42,7 @@ test -f "$root/current/web/assets/new.js" test -f "$root/current/web/assets/old.js" test -f "$root/current/web/.compatibility-manifests/1.assets" test -x "$root/current/deploy/verify-customer-demo.py" +test -f "$root/current/deploy/migrations/015_vehicle_source_policy_audit.sql" test ! -e "$root/current/lingniu-vehicle-platform.service" test "$(find "$root/releases" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3 grep -q 'web_release_install=ok release=new-release previous=old-release' "$fixture/install.out" diff --git a/vehicle-data-platform/deploy/migrations/015_vehicle_source_policy_audit.sql b/vehicle-data-platform/deploy/migrations/015_vehicle_source_policy_audit.sql new file mode 100644 index 00000000..eef908fe --- /dev/null +++ b/vehicle-data-platform/deploy/migrations/015_vehicle_source_policy_audit.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS platform_vehicle_source_policy_version ( + vin VARCHAR(32) NOT NULL PRIMARY KEY, + version INT NOT NULL DEFAULT 1, + updated_by VARCHAR(128) NOT NULL DEFAULT 'system', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS platform_vehicle_source_policy_audit ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + vin VARCHAR(32) NOT NULL, + version INT NOT NULL, + protocol VARCHAR(32) NOT NULL, + source_ref CHAR(64) NOT NULL, + source_key VARCHAR(256) NOT NULL, + source_label VARCHAR(160) NOT NULL, + old_enabled TINYINT(1) NOT NULL, + new_enabled TINYINT(1) NOT NULL, + old_priority INT NOT NULL, + new_priority INT NOT NULL, + old_remark VARCHAR(255) NOT NULL DEFAULT '', + new_remark VARCHAR(255) NOT NULL DEFAULT '', + actor VARCHAR(128) NOT NULL, + summary VARCHAR(500) NOT NULL, + changed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_vehicle_source_policy_version (vin, version), + KEY idx_vehicle_source_policy_audit_time (vin, changed_at), + KEY idx_vehicle_source_policy_source_ref (source_ref) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index 35d0a6aa..b50d05ee 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -151,8 +151,8 @@ Before a customer demonstration, run `deploy/verify-customer-demo.py` with the p | Role | Permissions | | --- | --- | | `viewer` | Read pages, query data and inspect evidence; may create/download only its own history exports when the menu is available | -| `operator` | Viewer permissions plus alert actions and notification reads; export access remains owner-bound | -| `admin` | Operator permissions plus rule and access-threshold configuration | +| `operator` | Viewer permissions plus alert actions, notification reads and read-only single-vehicle source diagnosis; export access remains owner-bound | +| `admin` | Operator permissions plus rule, access-threshold and per-vehicle source-policy configuration | After editing the environment file, run `chmod 600 /opt/lingniu-vehicle-platform/env/platform.env`. Never put a real token in Git, static JavaScript, shell history or deployment logs. @@ -185,10 +185,11 @@ test -n "$MYSQL_DSN" /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/011_alert_stream_invalid_evidence.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/012_business_scope_projection.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/013_platform_identity_access.sql \ - /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/014_customer_vehicle_grant_time.sql + /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/014_customer_vehicle_grant_time.sql \ + /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/015_vehicle_source_policy_audit.sql ``` -The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. Migration `014` backfills active vehicle-grant start times, creates the grant-interval history table and adds the active time lookup index; apply it before starting an API binary that writes grant history. +The API guards the access-threshold tables for compatibility, while alert APIs deliberately require the alert migrations to exist. Run every numbered migration explicitly before switching traffic so DDL permission and index creation failures are caught early. The migration journal records filename and SHA-256 and refuses a changed file; duplicate forward `ADD COLUMN` and `CREATE INDEX` statements are tolerated only when resuming partially executed MySQL DDL. Full-line SQL comments are removed before statement splitting, so punctuation in a comment cannot become executable SQL. Migration `008` adds forward-compatible access evidence columns and an index to the gateway-owned realtime snapshot table without changing its `(protocol, vin)` primary key. Migration `009` creates the per-group/topic/partition event-time checkpoint used to make MySQL effects authoritative before Kafka offsets are committed. Migration `014` backfills active vehicle-grant start times, creates the grant-interval history table and adds the active time lookup index; apply it before starting an API binary that writes grant history. Migration `015` adds platform-owned optimistic versions and immutable audits for per-vehicle location-source policy changes. It does not alter the gateway election SQL or expose the gateway `source_key`; the API resolves an opaque `sourceRef` server-side and the gateway applies the saved policy on the next valid vehicle report. Install and start the evaluator as a separate unit only after API smoke checks and a small enabled-rule review: