feat(operations): add source diagnosis workspace

This commit is contained in:
lingniu
2026-07-16 17:35:10 +08:00
parent df7d9799b3
commit 96cad7eef7
18 changed files with 881 additions and 10 deletions

View File

@@ -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"
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"`

View File

@@ -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)

View File

@@ -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 "当前没有明确推荐来源;请检查来源启用状态、质量、新鲜度和网关下一次有效上报。"
}

View File

@@ -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(&currentVersion); 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, &currentPriority, &currentRemark)
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 "停用"
}

View File

@@ -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<VehicleSourceEvidence>(`/api/v2/vehicles/${encodeURIComponent(vin)}/source-evidence${suffix}`, withSignal(undefined, signal));
},
vehicleSourceDiagnostic: (vin: string, signal?: AbortSignal) => request<VehicleSourceDiagnostic>(
`/api/v2/operations/vehicles/${encodeURIComponent(vin)}/sources`,
withSignal(undefined, signal)
),
updateVehicleSourcePolicy: (vin: string, update: VehicleSourcePolicyUpdate) => request<VehicleSourceDiagnostic>(
`/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<VehicleProfile>(`/api/v2/vehicles/${encodeURIComponent(vin)}/profile`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input)
}),

View File

@@ -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;

View File

@@ -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(<QueryClientProvider client={client}><OperationsPage /></QueryClientProvider>);
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)));
});

View File

@@ -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 <tr className={source.recommended ? 'is-recommended' : ''}>
<td><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.sourceKind || '未维护终端'}</span></td>
<td><b>{source.protocol}</b><span>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</span></td>
<td><i className={source.online ? 'is-online' : 'is-offline'} />{source.online ? '在线' : '离线'}<span>{source.qualityStatus || '未知'}{source.qualityReason ? ` · ${source.qualityReason}` : ''}</span></td>
<td><strong>{fmt(source.firstSeenAt)}</strong><span> {fmt(source.receivedAt || source.eventTime)}</span></td>
<td><strong>{source.reportIntervalSec == null ? '—' : `${source.reportIntervalSec}s`}</strong><span>{source.reportSampleCount ? `${source.reportSampleCount.toLocaleString('zh-CN')} 个里程样本` : '暂无累计样本'}</span></td>
<td><strong>{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}</strong><span>{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km</span></td>
<td className="v2-source-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><span>{source.selectionReason || '等待选举说明'}</span></td>
<td className="v2-source-policy-cell">
<label><input type="checkbox" checked={enabled} disabled={!editable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} /></label>
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
<input aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder="调整原因" />
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000} onClick={() => save.mutate()}>{save.isPending ? '保存中' : '保存策略'}</button>
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
</td>
</tr>;
}
function SourceDiagnosticWorkspace() {
const queryClient = useQueryClient();
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword.trim());
const [selected, setSelected] = useState<VehicleCoverageRow>();
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 <section className="v2-source-diagnostic">
<header>
<div><small></small><strong></strong><span> RAW访</span></div>
{selected ? <button type="button" onClick={() => diagnostic.refetch()} disabled={diagnostic.isFetching}><IconRefresh /></button> : null}
</header>
<form className="v2-source-search" onSubmit={submit}>
<label><IconSearch /><input aria-label="按车牌或 VIN 搜索诊断车辆" value={keyword} onChange={(event) => { setKeyword(event.target.value); setSelected(undefined); }} placeholder="输入车牌或 VIN支持模糊搜索" /></label>
<button type="submit" disabled={!candidates.data?.items.length}></button>
{deferredKeyword && !selected ? <div className="v2-source-candidates">
{candidates.isFetching ? <p></p> : candidates.data?.items.map((vehicle) => <button type="button" key={vehicle.vin} onMouseDown={(event) => event.preventDefault()} onClick={() => choose(vehicle)}>
<strong>{vehicle.plate || '未绑定车牌'}</strong><span>{vehicle.vin}</span><em>{vehicle.protocols.join(' / ') || '暂无来源'}</em>
</button>)}
{!candidates.isFetching && !candidates.data?.items.length ? <p></p> : null}
{(candidates.data?.total ?? 0) > 20 ? <footer><span> {Math.floor(candidateOffset / 20) + 1} / {Math.ceil((candidates.data?.total ?? 0) / 20)} </span><div><button type="button" disabled={candidateOffset === 0} onClick={() => setCandidateOffset(Math.max(0, candidateOffset - 20))}></button><button type="button" disabled={candidateOffset + 20 >= (candidates.data?.total ?? 0)} onClick={() => setCandidateOffset(candidateOffset + 20)}></button></div></footer> : null}
</div> : null}
</form>
{diagnostic.isError ? <InlineError message={diagnostic.error.message} onRetry={() => diagnostic.refetch()} /> : null}
{!selected ? <div className="v2-source-empty"><strong></strong><span></span></div> : null}
{selected && diagnostic.isPending ? <div className="v2-source-empty"><strong></strong><span></span></div> : null}
{data ? <>
<div className="v2-source-summary">
<article><small></small><strong>{data.evidence.plate || selected?.plate || '未绑定车牌'}</strong><span>{data.evidence.vin}</span></article>
<article><small></small><strong>{data.evidence.recommendedLocationLabel || '暂无推荐'}</strong><span>{data.evidence.recommendedLocationProtocol || '—'}</span></article>
<article><small></small><strong>{data.evidence.locationSources.filter((item) => item.online).length} / {data.evidence.locationSources.length} 线</strong><span>{data.evidence.locationConflict ? `位置冲突 ${number(data.evidence.conflictDistanceM)}m` : '未发现实时位置冲突'}</span></article>
<article><small></small><strong>v{data.policy.version}</strong><span>{data.policy.updatedAt ? `${data.policy.updatedBy} · ${fmt(data.policy.updatedAt)}` : '尚无人工调整'}</span></article>
</div>
<div className="v2-source-recommendation"><strong></strong><p>{data.recommendationReason}</p><span>{data.refreshHint}</span></div>
{!editable ? <p className="v2-source-readonly"></p> : null}
<div className="v2-source-table-wrap"><table className="v2-source-table"><thead><tr><th> / </th><th></th><th>线 / </th><th> / </th><th></th><th> / </th><th></th><th></th></tr></thead><tbody>
{data.evidence.locationSources.map((source) => <SourcePolicyRow key={source.sourceRef || `${source.protocol}-${source.sourceLabel}-${source.terminalLabel}`} vin={data.evidence.vin} source={source} diagnostic={data} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => queryClient.setQueryData(['ops-source-diagnostic', data.evidence.vin], next)} />)}
</tbody></table></div>
<section className="v2-source-audit"><header><strong></strong><span> source_key </span></header>
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p></p>}
</section>
</> : null}
</section>;
}
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 <div className="v2-ops-page">
<header className="v2-ops-heading"><div><h2></h2><p></p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh /></button></header>
<header className="v2-ops-heading"><div><h2></h2><p></p></div><button onClick={refresh} disabled={health.isFetching || readiness.isFetching}><IconRefresh /></button></header>
<SourceDiagnosticWorkspace />
{health.isError ? <InlineError message={health.error.message} onRetry={refresh} /> : null}
{readiness.isError ? <InlineError message={readiness.error instanceof Error ? readiness.error.message : '协议来源就绪度读取失败'} onRetry={() => readiness.refetch()} /> : null}
<section className="v2-ops-kpis">

View File

@@ -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; }

View File

@@ -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"

View File

@@ -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"

View File

@@ -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;

View File

@@ -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: