From 9a5e6e0c4fe4bf12a582d3806432a34594b1d561 Mon Sep 17 00:00:00 2001 From: lingniu Date: Thu, 16 Jul 2026 20:01:08 +0800 Subject: [PATCH] feat(operations): add auditable source provider maintenance --- .../api/internal/platform/access_store.go | 6 +- .../api/internal/platform/handler_test.go | 9 ++ .../apps/api/internal/platform/mock_store.go | 56 ++++++-- .../apps/api/internal/platform/model.go | 18 ++- .../api/internal/platform/service_test.go | 30 +++++ .../platform/source_evidence_store.go | 14 +- .../api/internal/platform/source_policy.go | 14 ++ .../internal/platform/source_policy_store.go | 121 ++++++++++++++---- .../internal/platform/source_policy_test.go | 83 +++++++++++- .../apps/web/src/api/client.ts | 2 + .../apps/web/src/api/types.ts | 5 + .../web/src/v2/pages/OperationsPage.test.tsx | 25 +++- .../apps/web/src/v2/pages/OperationsPage.tsx | 33 +++-- .../VehicleSourceEvidencePanel.test.tsx | 4 +- .../apps/web/src/v2/styles/v2.css | 2 +- .../019_vehicle_source_provider.sql | 31 +++++ vehicle-data-platform/docs/api-contract.md | 22 ++++ vehicle-data-platform/docs/deployment.md | 7 +- .../vehicle-data-platform-meeting-todo.md | 15 ++- 19 files changed, 430 insertions(+), 67 deletions(-) create mode 100644 vehicle-data-platform/deploy/migrations/019_vehicle_source_provider.sql diff --git a/vehicle-data-platform/apps/api/internal/platform/access_store.go b/vehicle-data-platform/apps/api/internal/platform/access_store.go index 3b78b6a4..cc1c8b93 100644 --- a/vehicle-data-platform/apps/api/internal/platform/access_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/access_store.go @@ -69,7 +69,7 @@ COALESCE(NULLIF(b.oem, ''), '') AS oem, COALESCE(p.model_name, '') AS model_name, COALESCE(p.company_name, '') AS company_name, COALESCE(s.protocol, '') AS protocol, -COALESCE(NULLIF(TRIM(s.platform_name), ''), NULLIF(TRIM(ls.source_code), ''), '') AS provider, +COALESCE(NULLIF(TRIM(provider.provider_name), ''), NULLIF(TRIM(s.platform_name), ''), NULLIF(TRIM(ls.source_code), ''), '') AS provider, COALESCE(DATE_FORMAT(s.access_first_seen_at, '%Y-%m-%d %H:%i:%s.%f'), '') AS first_seen_at, COALESCE(s.access_first_seen_source, '') AS first_seen_source, COALESCE(DATE_FORMAT(s.event_time, '%Y-%m-%d %H:%i:%s.%f'), '') AS event_time, @@ -92,6 +92,10 @@ LEFT JOIN vehicle_profile p ON p.vin = v.vin LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin LEFT JOIN vehicle_realtime_location l ON l.vin = s.vin AND l.protocol = s.protocol LEFT JOIN vehicle_realtime_location_source ls ON ls.vin = l.vin AND ls.protocol = l.protocol AND ls.source_key = l.source_key +LEFT JOIN platform_vehicle_source_provider provider + ON BINARY provider.vin = BINARY l.vin + AND BINARY provider.protocol = BINARY l.protocol + AND BINARY provider.source_key = BINARY l.source_key LEFT JOIN ( SELECT vin, protocol, MAX(daily_mileage_km) AS daily_mileage_km FROM vehicle_daily_mileage 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 f580ead0..058b2ae4 100644 --- a/vehicle-data-platform/apps/api/internal/platform/handler_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/handler_test.go @@ -475,6 +475,15 @@ func TestHandlerVehicleSourceDiagnosticAndPolicyUpdate(t *testing.T) { 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()) } + payload = fmt.Sprintf(`{"version":2,"providerName":"G7s","providerEvidence":"GPS 运维终端清单","enabled":true,"priority":%d,"remark":"人工核验"}`, 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":3`) || + !strings.Contains(rec.Body.String(), `"providerOverride":"G7s"`) || + strings.Contains(rec.Body.String(), `"sourceKey"`) { + t.Fatalf("provider update status=%d body=%s", rec.Code, rec.Body.String()) + } } func TestHandlerVehicleServiceCanonicalEndpoint(t *testing.T) { 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 da6a71dd..cc856275 100644 --- a/vehicle-data-platform/apps/api/internal/platform/mock_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/mock_store.go @@ -17,6 +17,8 @@ type MockStore struct { accessThresholds AccessThresholdConfig sourcePolicyMu sync.RWMutex sourcePolicies map[string]VehicleSourcePolicyConfig + sourceProviders map[string]string + sourcePolicyRemarks map[string]string profileMu sync.RWMutex profiles map[string]VehicleProfile alertMu sync.RWMutex @@ -38,9 +40,11 @@ func NewMockStore() *MockStore { {VIN: "LB9A32A24P0LS1230", Plate: "粤AFF7936", Phone: "13307795426", OEM: "广安车联", Protocol: "JT808", Online: false, LastSeen: "2026-07-03 19:58:00", LocationText: "广东省佛山市", BindingScore: 88}, } store := &MockStore{ - vehicles: vehicles, - accessThresholds: defaultAccessThresholds(time.Now()), - sourcePolicies: map[string]VehicleSourcePolicyConfig{}, + vehicles: vehicles, + accessThresholds: defaultAccessThresholds(time.Now()), + sourcePolicies: map[string]VehicleSourcePolicyConfig{}, + sourceProviders: map[string]string{}, + sourcePolicyRemarks: map[string]string{}, 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"}, }, @@ -246,18 +250,37 @@ func (m *MockStore) SaveVehicleSourcePolicy(_ context.Context, update vehicleSou 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 { + providerKey := update.VIN + "\x00" + update.Protocol + "\x00" + update.SourceKey + currentProvider := m.sourceProviders[providerKey] + policyChanged := update.CurrentEnabled != update.Enabled || update.CurrentPriority != update.Priority || update.CurrentRemark != update.Remark + providerChanged := currentProvider != update.ProviderName + if !policyChanged && !providerChanged { 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...) + if policyChanged { + m.sourcePolicyRemarks[providerKey] = update.Remark + current.Audit = append([]VehicleSourcePolicyAudit{{ + Version: current.Version, ChangeType: "policy", 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...) + } + if providerChanged { + if update.ProviderName == "" { + delete(m.sourceProviders, providerKey) + } else { + m.sourceProviders[providerKey] = update.ProviderName + } + current.Audit = append([]VehicleSourcePolicyAudit{{ + Version: current.Version, ChangeType: "provider", Protocol: update.Protocol, SourceRef: update.SourceRef, + SourceLabel: firstNonEmpty(update.ProviderName, currentProvider, "未维护提供方"), Actor: update.Actor, ChangedAt: now, + Summary: update.SourceLabel + ":提供方 " + providerLabel(currentProvider) + "→" + providerLabel(update.ProviderName) + ";核验依据:" + update.ProviderEvidence, + }}, current.Audit...) + } m.sourcePolicies[update.VIN] = current return current, nil } @@ -282,7 +305,7 @@ func (m *MockStore) VehicleSourceEvidence(_ context.Context, vin string, date st lonJT2, latJT2, speedJT2, mileageJT2 := 113.2675, 23.1315, 40.2, 48794.1 firstGB, latestGB, dailyGB := 48782.3, 48798.9, 16.6 firstJT, latestJT, dailyJT := 48781.7, 48797.6, 15.9 - return VehicleSourceEvidence{ + evidence := VehicleSourceEvidence{ VIN: vin, Plate: "粤AG18312", MileageDate: date, LocationSources: []VehicleLocationSourceEvidence{ {Protocol: "GB32960", SourceLabel: "车厂 32960", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 10, Online: true, QualityStatus: "OK", Longitude: &lonGB, Latitude: &latGB, SpeedKmh: &speedGB, TotalMileageKm: &mileageGB, SOCPercent: &socGB, EventTime: "2026-07-16 16:20:08", ReceivedAt: "2026-07-16 16:20:09", sourceKey: "GB32960:canonical"}, @@ -293,7 +316,18 @@ func (m *MockStore) VehicleSourceEvidence(_ context.Context, vin string, date st {Protocol: "GB32960", SourceLabel: "车厂 32960", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 10, QualityStatus: "OK", FirstTotalMileageKm: &firstGB, LatestTotalMileageKm: &latestGB, DailyMileageKm: &dailyGB, SampleCount: 1080, FirstEventTime: "2026-07-16 00:00:10", LatestEventTime: "2026-07-16 16:20:08", sourceKey: "gb:factory"}, {Protocol: "JT808", SourceLabel: "G7", TerminalLabel: "终端 133****5425", SourceKind: "PLATFORM", SelectedWithinProtocol: true, Enabled: true, Priority: 20, QualityStatus: "OK", FirstTotalMileageKm: &firstJT, LatestTotalMileageKm: &latestJT, DailyMileageKm: &dailyJT, SampleCount: 4320, FirstEventTime: "2026-07-16 00:00:03", LatestEventTime: "2026-07-16 16:20:06", sourceKey: "jt808:g7"}, }, - }, nil + } + m.sourcePolicyMu.RLock() + for index := range evidence.LocationSources { + source := &evidence.LocationSources[index] + if provider := m.sourceProviders[vin+"\x00"+source.Protocol+"\x00"+source.sourceKey]; provider != "" { + source.ProviderOverride = provider + source.SourceLabel = provider + } + source.PolicyRemark = m.sourcePolicyRemarks[vin+"\x00"+source.Protocol+"\x00"+source.sourceKey] + } + m.sourcePolicyMu.RUnlock() + return evidence, nil } func (m *MockStore) SaveVehicleProfile(_ context.Context, vin string, input VehicleProfileInput) (VehicleProfile, error) { diff --git a/vehicle-data-platform/apps/api/internal/platform/model.go b/vehicle-data-platform/apps/api/internal/platform/model.go index 30ed6b91..df64e813 100644 --- a/vehicle-data-platform/apps/api/internal/platform/model.go +++ b/vehicle-data-platform/apps/api/internal/platform/model.go @@ -1041,6 +1041,7 @@ type VehicleSourceEvidence struct { type VehicleLocationSourceEvidence struct { Protocol string `json:"protocol"` SourceLabel string `json:"sourceLabel"` + ProviderOverride string `json:"providerOverride"` TerminalLabel string `json:"terminalLabel"` SourceKind string `json:"sourceKind"` SourceRef string `json:"sourceRef,omitempty"` @@ -1048,6 +1049,7 @@ type VehicleLocationSourceEvidence struct { Recommended bool `json:"recommended"` Enabled bool `json:"enabled"` Priority int `json:"priority"` + PolicyRemark string `json:"policyRemark"` Online bool `json:"online"` QualityStatus string `json:"qualityStatus"` QualityReason string `json:"qualityReason"` @@ -1110,6 +1112,7 @@ type VehicleSourcePolicyConfig struct { type VehicleSourcePolicyAudit struct { Version int `json:"version"` + ChangeType string `json:"changeType"` Protocol string `json:"protocol"` SourceRef string `json:"sourceRef"` SourceLabel string `json:"sourceLabel"` @@ -1119,12 +1122,14 @@ type VehicleSourcePolicyAudit struct { } 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"` + Version int `json:"version"` + SourceRef string `json:"sourceRef"` + ProviderName string `json:"providerName"` + ProviderEvidence string `json:"providerEvidence"` + Enabled bool `json:"enabled"` + Priority int `json:"priority"` + Remark string `json:"remark"` + Actor string `json:"actor"` } type vehicleSourcePolicyStoreUpdate struct { @@ -1136,6 +1141,7 @@ type vehicleSourcePolicyStoreUpdate struct { CurrentEnabled bool CurrentPriority int CurrentRemark string + CurrentProvider string } type vehicleLocationSourceHistory struct { 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 e7ce1b36..f4fae55b 100644 --- a/vehicle-data-platform/apps/api/internal/platform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/service_test.go @@ -262,6 +262,36 @@ func TestVehicleSourcePolicyUpdateRequiresAdminAndUsesOptimisticVersion(t *testi 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: 2, SourceRef: source.SourceRef, ProviderName: "G7s", + Enabled: false, Priority: source.Priority, + }); err == nil { + t.Fatal("provider maintenance must require an authoritative evidence note") + } + updated, err = service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{ + Version: 2, SourceRef: source.SourceRef, ProviderName: "G7s", + ProviderEvidence: "GPS 运维终端清单 2026-07-16", + Enabled: false, Priority: source.Priority, Remark: "人工核验", + }) + if err != nil { + t.Fatalf("admin provider update failed: %v", err) + } + if updated.Policy.Version != 3 || updated.Policy.Audit[0].ChangeType != "provider" { + t.Fatalf("provider update must share optimistic versioning and audit: %+v", updated.Policy) + } + if !strings.Contains(updated.Policy.Audit[0].Summary, "核验依据:GPS 运维终端清单 2026-07-16") || + strings.Contains(updated.Policy.Audit[0].Summary, "人工核验") { + t.Fatalf("provider evidence and policy remark must remain separate: %+v", updated.Policy.Audit[0]) + } + providerFound := false + for _, item := range updated.Evidence.LocationSources { + if item.SourceRef == source.SourceRef && item.ProviderOverride == "G7s" && item.SourceLabel == "G7s" { + providerFound = true + } + } + if !providerFound { + t.Fatalf("provider update must immediately affect source evidence: %+v", updated.Evidence.LocationSources) + } if _, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{ Version: 1, SourceRef: source.SourceRef, Enabled: true, Priority: source.Priority, }); err == nil { diff --git a/vehicle-data-platform/apps/api/internal/platform/source_evidence_store.go b/vehicle-data-platform/apps/api/internal/platform/source_evidence_store.go index 6eebbe70..b7171902 100644 --- a/vehicle-data-platform/apps/api/internal/platform/source_evidence_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/source_evidence_store.go @@ -117,16 +117,24 @@ func (s *ProductionStore) locationSourceEvidence(ctx context.Context, vin string for _, source := range canonical { selected[source.row.Protocol] = source.row.sourceKey } - rows, err := s.db.QueryContext(ctx, `SELECT s.protocol, s.source_key, s.source_code, s.source_kind, s.phone, s.device_id, + rows, err := s.db.QueryContext(ctx, `SELECT s.protocol, s.source_key, +COALESCE(NULLIF(TRIM(provider.provider_name), ''), NULLIF(TRIM(s.source_code), ''), ''), +COALESCE(provider.provider_name, ''), +s.source_kind, s.phone, s.device_id, DATE_FORMAT(s.event_time, '%Y-%m-%d %H:%i:%s'), s.latitude, s.longitude, s.speed_kmh, s.total_mileage_km, s.soc_percent, DATE_FORMAT(s.received_at, '%Y-%m-%d %H:%i:%s'), s.quality_status, s.quality_reason, COALESCE(p.enabled, 1), COALESCE(p.priority, CASE s.source_kind WHEN 'DIRECT' THEN 20 WHEN 'PLATFORM' THEN 30 ELSE 40 END), +COALESCE(p.remark, ''), CASE WHEN s.received_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 1 ELSE 0 END FROM vehicle_realtime_location_source s LEFT JOIN vehicle_location_source_policy p ON p.vin = s.vin AND p.protocol = s.protocol AND p.source_key = s.source_key +LEFT JOIN platform_vehicle_source_provider provider + ON BINARY provider.vin = BINARY s.vin + AND BINARY provider.protocol = BINARY s.protocol + AND BINARY provider.source_key = BINARY s.source_key WHERE s.vin = ? ORDER BY s.protocol, COALESCE(p.enabled, 1) DESC, COALESCE(p.priority, CASE s.source_kind WHEN 'DIRECT' THEN 20 WHEN 'PLATFORM' THEN 30 ELSE 40 END), @@ -144,9 +152,9 @@ LIMIT 50`, vin) var latitude, longitude, speed, mileage, soc sql.NullFloat64 var enabled, online int if err := rows.Scan( - &row.Protocol, &row.sourceKey, &sourceCode, &row.SourceKind, &phone, &deviceID, + &row.Protocol, &row.sourceKey, &sourceCode, &row.ProviderOverride, &row.SourceKind, &phone, &deviceID, &eventTime, &latitude, &longitude, &speed, &mileage, &soc, &receivedAt, - &row.QualityStatus, &qualityReason, &enabled, &row.Priority, &online, + &row.QualityStatus, &qualityReason, &enabled, &row.Priority, &row.PolicyRemark, &online, ); err != nil { return nil, err } diff --git a/vehicle-data-platform/apps/api/internal/platform/source_policy.go b/vehicle-data-platform/apps/api/internal/platform/source_policy.go index 5c51eb6a..7725f0ca 100644 --- a/vehicle-data-platform/apps/api/internal/platform/source_policy.go +++ b/vehicle-data-platform/apps/api/internal/platform/source_policy.go @@ -93,6 +93,8 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd } vin = strings.ToUpper(strings.TrimSpace(vin)) update.SourceRef = strings.ToLower(strings.TrimSpace(update.SourceRef)) + update.ProviderName = strings.TrimSpace(update.ProviderName) + update.ProviderEvidence = strings.TrimSpace(update.ProviderEvidence) update.Remark = strings.TrimSpace(update.Remark) if vin == "" { return VehicleSourceDiagnostic{}, clientError{Code: "VEHICLE_VIN_REQUIRED", Message: "车辆 VIN 不能为空"} @@ -112,6 +114,12 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd if len([]rune(update.Remark)) > 200 { return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_REMARK_TOO_LONG", Message: "策略备注不能超过 200 个字符"} } + if len([]rune(update.ProviderName)) > 128 { + return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PROVIDER_TOO_LONG", Message: "提供方名称不能超过 128 个字符"} + } + if len([]rune(update.ProviderEvidence)) > 255 { + return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PROVIDER_EVIDENCE_TOO_LONG", Message: "提供方核验依据不能超过 255 个字符"} + } update.Actor = strings.TrimSpace(update.Actor) if update.Actor == "" { update.Actor = ActorFromContext(ctx) @@ -135,6 +143,10 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd if selected == nil || selected.sourceKey == "" { return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_NOT_FOUND", Message: "当前车辆不存在该来源,请刷新后重试"} } + providerChanged := strings.TrimSpace(selected.ProviderOverride) != update.ProviderName + if providerChanged && update.ProviderEvidence == "" { + return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PROVIDER_REASON_REQUIRED", Message: "维护提供方时必须填写权威资料来源或核验说明"} + } store, ok := s.store.(vehicleSourcePolicyStore) if !ok { return VehicleSourceDiagnostic{}, errors.New("vehicle source policy store is not configured") @@ -147,6 +159,8 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd SourceLabel: selected.SourceLabel, CurrentEnabled: selected.Enabled, CurrentPriority: selected.Priority, + CurrentRemark: selected.PolicyRemark, + CurrentProvider: selected.ProviderOverride, }); err != nil { return VehicleSourceDiagnostic{}, err } 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 index fc232022..708a2a98 100644 --- a/vehicle-data-platform/apps/api/internal/platform/source_policy_store.go +++ b/vehicle-data-platform/apps/api/internal/platform/source_policy_store.go @@ -43,19 +43,28 @@ FROM platform_vehicle_source_policy_version WHERE vin = ?`, vin).Scan(&config.Ve if updatedAt.Valid { config.UpdatedAt = normalizeAccessTime(updatedAt.String) } - rows, err := s.db.QueryContext(ctx, `SELECT version, protocol, source_ref, source_label, actor, + rows, err := s.db.QueryContext(ctx, `SELECT version, change_type, 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 = ? +FROM ( + SELECT version, 'policy' AS change_type, protocol, source_ref, source_label, actor, changed_at, summary, id + FROM platform_vehicle_source_policy_audit + WHERE vin = ? + UNION ALL + SELECT version, 'provider' AS change_type, protocol, source_ref, + COALESCE(NULLIF(new_provider, ''), NULLIF(old_provider, ''), '未维护提供方') AS source_label, + actor, changed_at, summary, id + FROM platform_vehicle_source_provider_audit + WHERE vin = ? +) combined_audit ORDER BY changed_at DESC, id DESC -LIMIT 20`, vin) +LIMIT 20`, vin, 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 { + if err := rows.Scan(&item.Version, &item.ChangeType, &item.Protocol, &item.SourceRef, &item.SourceLabel, &item.Actor, &item.ChangedAt, &item.Summary); err != nil { return VehicleSourcePolicyConfig{}, err } item.ChangedAt = normalizeAccessTime(item.ChangedAt) @@ -85,6 +94,7 @@ WHERE vin = ? FOR UPDATE`, update.VIN).Scan(¤tVersion); err != nil { currentEnabled := update.CurrentEnabled currentPriority := update.CurrentPriority currentRemark := update.CurrentRemark + currentProvider := strings.TrimSpace(update.CurrentProvider) var enabled int err = tx.QueryRowContext(ctx, `SELECT enabled, priority, remark FROM vehicle_location_source_policy @@ -96,20 +106,54 @@ WHERE vin = ? AND protocol = ? AND source_key = ?`, } else if err != sql.ErrNoRows { return VehicleSourcePolicyConfig{}, err } - if currentEnabled == update.Enabled && currentPriority == update.Priority && strings.TrimSpace(currentRemark) == update.Remark { + err = tx.QueryRowContext(ctx, `SELECT provider_name +FROM platform_vehicle_source_provider +WHERE vin = ? AND protocol = ? AND source_key = ?`, + update.VIN, update.Protocol, update.SourceKey, + ).Scan(¤tProvider) + if err == sql.ErrNoRows { + currentProvider = "" + } else if err != nil { + return VehicleSourcePolicyConfig{}, err + } + policyChanged := currentEnabled != update.Enabled || + currentPriority != update.Priority || + strings.TrimSpace(currentRemark) != update.Remark + providerChanged := strings.TrimSpace(currentProvider) != update.ProviderName + if !policyChanged && !providerChanged { 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 + if policyChanged { + 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 + update.VIN, update.Protocol, update.SourceKey, boolInt(update.Enabled), update.Priority, update.Remark, + ); err != nil { + return VehicleSourcePolicyConfig{}, err + } + } + if providerChanged { + if update.ProviderName == "" { + if _, err := tx.ExecContext(ctx, `DELETE FROM platform_vehicle_source_provider +WHERE vin = ? AND protocol = ? AND source_key = ?`, + update.VIN, update.Protocol, update.SourceKey, + ); err != nil { + return VehicleSourcePolicyConfig{}, err + } + } else if _, err := tx.ExecContext(ctx, `INSERT INTO platform_vehicle_source_provider +(vin, protocol, source_key, source_ref, provider_name, updated_by) +VALUES (?, ?, ?, ?, ?, ?) +ON DUPLICATE KEY UPDATE source_ref = VALUES(source_ref), provider_name = VALUES(provider_name), +updated_by = VALUES(updated_by), updated_at = CURRENT_TIMESTAMP(3)`, + update.VIN, update.Protocol, update.SourceKey, update.SourceRef, update.ProviderName, update.Actor, + ); err != nil { + return VehicleSourcePolicyConfig{}, err + } } nextVersion := currentVersion + 1 result, err := tx.ExecContext(ctx, `UPDATE platform_vehicle_source_policy_version @@ -121,25 +165,45 @@ WHERE vin = ? AND version = ?`, nextVersion, update.Actor, update.VIN, currentVe 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 + if policyChanged { + 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 + 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 providerChanged { + summary := fmt.Sprintf( + "%s:提供方 %s→%s", + update.SourceLabel, + providerLabel(currentProvider), providerLabel(update.ProviderName), + ) + if update.ProviderEvidence != "" { + summary += ";核验依据:" + update.ProviderEvidence + } + if _, err := tx.ExecContext(ctx, `INSERT INTO platform_vehicle_source_provider_audit +(vin, version, protocol, source_ref, source_key, old_provider, new_provider, actor, note, summary) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + update.VIN, nextVersion, update.Protocol, update.SourceRef, update.SourceKey, + currentProvider, update.ProviderName, update.Actor, update.ProviderEvidence, summary, + ); err != nil { + return VehicleSourcePolicyConfig{}, err + } } if err := tx.Commit(); err != nil { return VehicleSourcePolicyConfig{}, err @@ -160,3 +224,10 @@ func enabledLabel(value bool) string { } return "停用" } + +func providerLabel(value string) string { + if strings.TrimSpace(value) == "" { + return "未维护" + } + return strings.TrimSpace(value) +} diff --git a/vehicle-data-platform/apps/api/internal/platform/source_policy_test.go b/vehicle-data-platform/apps/api/internal/platform/source_policy_test.go index 1315dd8d..9a5b21ad 100644 --- a/vehicle-data-platform/apps/api/internal/platform/source_policy_test.go +++ b/vehicle-data-platform/apps/api/internal/platform/source_policy_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "regexp" "testing" "github.com/DATA-DOG/go-sqlmock" @@ -18,8 +19,8 @@ func TestProductionVehicleSourcePolicyUsesStableEmptyAuditArray(t *testing.T) { store := NewProductionStore(db, nil, "") mock.ExpectQuery(`SELECT version, updated_by`).WithArgs("VIN001"). WillReturnRows(sqlmock.NewRows([]string{"version", "updated_by", "updated_at"})) - mock.ExpectQuery(`SELECT version, protocol, source_ref`).WithArgs("VIN001"). - WillReturnRows(sqlmock.NewRows([]string{"version", "protocol", "source_ref", "source_label", "actor", "changed_at", "summary"})) + mock.ExpectQuery(`SELECT version, change_type, protocol, source_ref`).WithArgs("VIN001", "VIN001"). + WillReturnRows(sqlmock.NewRows([]string{"version", "change_type", "protocol", "source_ref", "source_label", "actor", "changed_at", "summary"})) config, err := store.VehicleSourcePolicy(context.Background(), "VIN001") if err != nil { t.Fatal(err) @@ -41,3 +42,81 @@ func TestProductionVehicleSourcePolicyUsesStableEmptyAuditArray(t *testing.T) { t.Fatal(err) } } + +func TestProductionProviderUpdateKeepsPolicyRemarkSeparate(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + store := NewProductionStore(db, nil, "") + update := vehicleSourcePolicyStoreUpdate{ + VehicleSourcePolicyUpdate: VehicleSourcePolicyUpdate{ + Version: 2, + SourceRef: "a-ref", + ProviderName: "G7s", + ProviderEvidence: "GPS 运维终端清单 2026-07-16", + Enabled: true, + Priority: 20, + Remark: "保持当前优先级", + Actor: "平台管理员", + }, + VIN: "VIN001", + Protocol: "JT808", + SourceKey: "opaque-source-key", + SourceLabel: "JT808 终端", + CurrentEnabled: true, + CurrentPriority: 20, + CurrentRemark: "保持当前优先级", + } + + mock.ExpectBegin() + mock.ExpectExec(regexp.QuoteMeta(`INSERT IGNORE INTO platform_vehicle_source_policy_version +(vin, version, updated_by) VALUES (?, 1, 'system')`)). + WithArgs("VIN001").WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM platform_vehicle_source_policy_version +WHERE vin = ? FOR UPDATE`)). + WithArgs("VIN001").WillReturnRows(sqlmock.NewRows([]string{"version"}).AddRow(2)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT enabled, priority, remark +FROM vehicle_location_source_policy +WHERE vin = ? AND protocol = ? AND source_key = ?`)). + WithArgs("VIN001", "JT808", "opaque-source-key"). + WillReturnRows(sqlmock.NewRows([]string{"enabled", "priority", "remark"}).AddRow(1, 20, "保持当前优先级")) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT provider_name +FROM platform_vehicle_source_provider +WHERE vin = ? AND protocol = ? AND source_key = ?`)). + WithArgs("VIN001", "JT808", "opaque-source-key"). + WillReturnRows(sqlmock.NewRows([]string{"provider_name"})) + mock.ExpectExec(`INSERT INTO platform_vehicle_source_provider`). + WithArgs("VIN001", "JT808", "opaque-source-key", "a-ref", "G7s", "平台管理员"). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectExec(`UPDATE platform_vehicle_source_policy_version`). + WithArgs(3, "平台管理员", "VIN001", 2). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec(`INSERT INTO platform_vehicle_source_provider_audit`). + WithArgs( + "VIN001", 3, "JT808", "a-ref", "opaque-source-key", + "", "G7s", "平台管理员", "GPS 运维终端清单 2026-07-16", + "JT808 终端:提供方 未维护→G7s;核验依据:GPS 运维终端清单 2026-07-16", + ). + WillReturnResult(sqlmock.NewResult(1, 1)) + mock.ExpectCommit() + mock.ExpectQuery(`SELECT version, updated_by`). + WithArgs("VIN001"). + WillReturnRows(sqlmock.NewRows([]string{"version", "updated_by", "updated_at"}).AddRow(3, "平台管理员", "2026-07-16 20:00:00")) + mock.ExpectQuery(`SELECT version, change_type, protocol, source_ref`). + WithArgs("VIN001", "VIN001"). + WillReturnRows(sqlmock.NewRows([]string{"version", "change_type", "protocol", "source_ref", "source_label", "actor", "changed_at", "summary"}). + AddRow(3, "provider", "JT808", "a-ref", "G7s", "平台管理员", "2026-07-16 20:00:00", "JT808 终端:提供方 未维护→G7s;核验依据:GPS 运维终端清单 2026-07-16")) + + config, err := store.SaveVehicleSourcePolicy(context.Background(), update) + if err != nil { + t.Fatal(err) + } + if config.Version != 3 || len(config.Audit) != 1 || config.Audit[0].ChangeType != "provider" { + t.Fatalf("unexpected provider audit result: %+v", config) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} diff --git a/vehicle-data-platform/apps/web/src/api/client.ts b/vehicle-data-platform/apps/web/src/api/client.ts index 371c9065..6f1dfedb 100644 --- a/vehicle-data-platform/apps/web/src/api/client.ts +++ b/vehicle-data-platform/apps/web/src/api/client.ts @@ -289,6 +289,8 @@ export const api = { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: update.version, + providerName: update.providerName, + providerEvidence: update.providerEvidence, enabled: update.enabled, priority: update.priority, remark: update.remark diff --git a/vehicle-data-platform/apps/web/src/api/types.ts b/vehicle-data-platform/apps/web/src/api/types.ts index c2499b6d..b6bb1a67 100644 --- a/vehicle-data-platform/apps/web/src/api/types.ts +++ b/vehicle-data-platform/apps/web/src/api/types.ts @@ -546,6 +546,7 @@ export interface VehicleSourceEvidence { export interface VehicleLocationSourceEvidence { protocol: string; sourceLabel: string; + providerOverride: string; terminalLabel: string; sourceKind: string; sourceRef?: string; @@ -553,6 +554,7 @@ export interface VehicleLocationSourceEvidence { recommended: boolean; enabled: boolean; priority: number; + policyRemark?: string; online: boolean; qualityStatus: string; qualityReason: string; @@ -597,6 +599,7 @@ export interface VehicleSourceEvidenceComparison { export interface VehicleSourcePolicyAudit { version: number; + changeType: 'policy' | 'provider'; protocol: string; sourceRef: string; sourceLabel: string; @@ -624,6 +627,8 @@ export interface VehicleSourceDiagnostic { export interface VehicleSourcePolicyUpdate { version: number; sourceRef: string; + providerName: string; + providerEvidence: string; enabled: boolean; priority: number; remark: 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 2b58dd91..ed7785c4 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 @@ -126,13 +126,13 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async }); 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({ + const diagnostic = { 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: '', + protocol: 'JT808', sourceLabel: 'G7', providerOverride: '', terminalLabel: '终端 133****0001', sourceKind: 'PLATFORM', sourceRef: 'a'.repeat(64), + selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, policyRemark: '保持当前优先级', 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: '当前融合推荐' @@ -141,7 +141,9 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async }, policy: { vin: 'VIN001', version: 1, updatedBy: 'system', updatedAt: '', audit: [] }, recommendationReason: '当前推荐 G7', refreshHint: '下一次有效上报后重新选举' - }); + }; + mocks.vehicleSourceDiagnostic.mockResolvedValue(diagnostic); + mocks.updateVehicleSourcePolicy.mockResolvedValue(diagnostic); const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render(); fireEvent.change(screen.getByLabelText('按车牌或 VIN 搜索诊断车辆'), { target: { value: '粤A' } }); @@ -154,5 +156,20 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async expect(await screen.findByText('当前推荐 G7')).toBeInTheDocument(); expect(screen.getByText('终端 133****0001')).toBeInTheDocument(); expect(screen.getByText('10s')).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText('G7 提供方'), { target: { value: 'G7s' } }); + const save = screen.getByRole('button', { name: '保存策略' }); + expect(save).toBeDisabled(); + expect(screen.getByLabelText('G7 策略备注')).toHaveValue('保持当前优先级'); + fireEvent.change(screen.getByLabelText('G7 提供方核验依据'), { target: { value: 'GPS 运维终端清单 2026-07-16' } }); + fireEvent.click(save); + await waitFor(() => expect(mocks.updateVehicleSourcePolicy).toHaveBeenCalledWith('VIN001', { + version: 1, + sourceRef: 'a'.repeat(64), + providerName: 'G7s', + providerEvidence: 'GPS 运维终端清单 2026-07-16', + enabled: true, + priority: 20, + remark: '保持当前优先级' + })); 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 3239da4f..0b160b8d 100644 --- a/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx +++ b/vehicle-data-platform/apps/web/src/v2/pages/OperationsPage.tsx @@ -30,23 +30,31 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: { }) { const [enabled, setEnabled] = useState(source.enabled); const [priority, setPriority] = useState(source.priority); - const [remark, setRemark] = useState(''); + const [providerName, setProviderName] = useState(source.providerOverride || ''); + const [providerEvidence, setProviderEvidence] = useState(''); + const [remark, setRemark] = useState(source.policyRemark || ''); useEffect(() => { setEnabled(source.enabled); setPriority(source.priority); - setRemark(''); - }, [source.enabled, source.priority, source.sourceRef]); + setProviderName(source.providerOverride || ''); + setProviderEvidence(''); + setRemark(source.policyRemark || ''); + }, [source.enabled, source.priority, source.providerOverride, source.policyRemark, source.sourceRef]); const save = useMutation({ mutationFn: () => api.updateVehicleSourcePolicy(vin, { version: diagnostic.policy.version, sourceRef: source.sourceRef ?? '', + providerName, + providerEvidence, enabled, priority, remark }), onSuccess: onSaved }); - const changed = enabled !== source.enabled || priority !== source.priority || remark.trim() !== ''; + const providerChanged = providerName.trim() !== (source.providerOverride || ''); + const policyChanged = enabled !== source.enabled || priority !== source.priority || remark.trim() !== (source.policyRemark || ''); + const changed = policyChanged || providerChanged; return {source.sourceLabel}{source.terminalLabel || source.sourceKind || '未维护终端'} {source.protocol}{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'} @@ -58,8 +66,10 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: { setPriority(Number(event.target.value))} /> - setRemark(event.target.value)} placeholder="调整原因" /> - + setProviderName(event.target.value)} placeholder="提供方,如 G7s" /> + setProviderEvidence(event.target.value)} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} /> + setRemark(event.target.value)} placeholder="启停或优先级调整原因(可选)" /> + {save.isError ? {save.error instanceof Error ? save.error.message : '保存失败'} : null} ; @@ -127,10 +137,17 @@ function SourceDiagnosticWorkspace() {
推荐说明

{data.recommendationReason}

{data.refreshHint}
{!editable ?

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

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

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

} + {data.policy.audit.length ?
    {data.policy.audit.map((item) =>
  1. v{item.version}{item.summary}{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · {fmt(item.changedAt)}
  2. )}
:

该车辆尚无人工来源策略或提供方变更。

}
: null} ; diff --git a/vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.test.tsx b/vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.test.tsx index e842fe3b..7025ba1f 100644 --- a/vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.test.tsx +++ b/vehicle-data-platform/apps/web/src/v2/shared/VehicleSourceEvidencePanel.test.tsx @@ -15,13 +15,13 @@ const evidence: VehicleSourceEvidence = { conflictDistanceM: 328, locationSources: [ { - protocol: 'JT808', sourceLabel: 'G7', terminalLabel: '终端 133****5425', sourceKind: 'PLATFORM', + protocol: 'JT808', sourceLabel: 'G7', providerOverride: '', terminalLabel: '终端 133****5425', sourceKind: 'PLATFORM', selectedWithinProtocol: true, recommended: true, enabled: true, priority: 20, online: true, qualityStatus: 'OK', qualityReason: '', longitude: 113.26, latitude: 23.13, speedKmh: 20, totalMileageKm: 1000, eventTime: '2026-07-16 10:00:00', receivedAt: '2026-07-16 10:00:01' }, { - protocol: 'JT808', sourceLabel: '北斗平台', terminalLabel: '终端 139****1208', sourceKind: 'PLATFORM', + protocol: 'JT808', sourceLabel: '北斗平台', providerOverride: '', terminalLabel: '终端 139****1208', sourceKind: 'PLATFORM', selectedWithinProtocol: false, recommended: false, enabled: true, priority: 30, online: true, qualityStatus: 'OK', qualityReason: '', longitude: 113.27, latitude: 23.14, speedKmh: 18, totalMileageKm: 998, eventTime: '2026-07-16 09:59:55', receivedAt: '2026-07-16 09:59:57' 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 ac766042..d502f851 100644 --- a/vehicle-data-platform/apps/web/src/v2/styles/v2.css +++ b/vehicle-data-platform/apps/web/src/v2/styles/v2.css @@ -416,7 +416,7 @@ button, a { -webkit-tap-highlight-color: transparent; } .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-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 > .v2-source-provider-input, .v2-source-policy-cell > .v2-source-provider-evidence-input, .v2-source-policy-cell > .v2-source-policy-remark-input { 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); } diff --git a/vehicle-data-platform/deploy/migrations/019_vehicle_source_provider.sql b/vehicle-data-platform/deploy/migrations/019_vehicle_source_provider.sql new file mode 100644 index 00000000..6c4d458f --- /dev/null +++ b/vehicle-data-platform/deploy/migrations/019_vehicle_source_provider.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS platform_vehicle_source_provider ( + vin VARCHAR(64) NOT NULL, + protocol VARCHAR(32) NOT NULL, + source_key VARCHAR(256) NOT NULL, + source_ref CHAR(64) NOT NULL, + provider_name VARCHAR(128) NOT NULL, + updated_by VARCHAR(128) NOT NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (vin, protocol, source_key), + UNIQUE KEY uk_vehicle_source_provider_ref (source_ref), + INDEX idx_vehicle_source_provider_name (provider_name), + INDEX idx_vehicle_source_provider_updated (updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS platform_vehicle_source_provider_audit ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + vin VARCHAR(64) NOT NULL, + version INT NOT NULL, + protocol VARCHAR(32) NOT NULL, + source_ref CHAR(64) NOT NULL, + source_key VARCHAR(256) NOT NULL, + old_provider VARCHAR(128) NOT NULL DEFAULT '', + new_provider VARCHAR(128) NOT NULL DEFAULT '', + actor VARCHAR(128) NOT NULL, + note VARCHAR(255) NOT NULL, + summary VARCHAR(500) NOT NULL, + changed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + UNIQUE KEY uk_vehicle_source_provider_audit_version (vin, version), + INDEX idx_vehicle_source_provider_audit_time (vin, changed_at), + INDEX idx_vehicle_source_provider_audit_ref (source_ref) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/vehicle-data-platform/docs/api-contract.md b/vehicle-data-platform/docs/api-contract.md index dd1584c7..80d33c64 100644 --- a/vehicle-data-platform/docs/api-contract.md +++ b/vehicle-data-platform/docs/api-contract.md @@ -323,6 +323,28 @@ Access rows expose both event and receive time, `dataDelaySec`, `freshnessSec`, Threshold configuration is stored in `vehicle_access_threshold_config`; every successful update increments `version`, requires the caller's previous version, and writes `vehicle_access_threshold_audit`. Stale updates return `ACCESS_THRESHOLD_VERSION_CONFLICT`. Valid ranges are bounded server-side. +### V2 Source Diagnostic and Provider Maintenance + +```http +GET /api/v2/operations/vehicles/{vin}/sources + +PUT /api/v2/operations/vehicles/{vin}/sources/{sourceRef} +Content-Type: application/json + +{ + "version": 2, + "providerName": "G7s", + "providerEvidence": "GPS 运维终端清单 2026-07-16", + "enabled": true, + "priority": 20, + "remark": "保持当前优先级" +} +``` + +The diagnostic response exposes every current location candidate, including the current verified `providerOverride` and gateway-policy `policyRemark`, but never exposes the raw source key. `sourceRef` is a SHA-256 reference resolved only by the server. Operator principals may read this diagnostic; only administrators may write. + +`providerEvidence` is mandatory whenever `providerName` changes, including removal. It is written to the immutable provider audit and is deliberately separate from `remark`, which belongs only to source enable/priority policy. A provider-only update must preserve the existing policy remark. Both change types share the vehicle-level optimistic `version`; stale writes return `SOURCE_POLICY_VERSION_CONFLICT`. + ## Map Reverse Geocoding `GET /api/map/reverse-geocode?longitude=&latitude=` is an authenticated server-side AMap Web Service adapter. It validates the source coordinate, converts WGS-84 to GCJ-02 exactly once, keeps the server API key out of the browser, and requests only the `base` reverse-geocode response documented by [AMap](https://lbs.amap.com/api/webservice/guide/api/georegeo). diff --git a/vehicle-data-platform/docs/deployment.md b/vehicle-data-platform/docs/deployment.md index 47b54280..b4a48141 100644 --- a/vehicle-data-platform/docs/deployment.md +++ b/vehicle-data-platform/docs/deployment.md @@ -203,10 +203,11 @@ test -n "$MYSQL_DSN" /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/015_vehicle_source_policy_audit.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/016_business_scope_dimensions.sql \ /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/017_reconciliation_center.sql \ - /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/018_vehicle_oem_audit.sql + /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/018_vehicle_oem_audit.sql \ + /opt/lingniu-vehicle-platform/releases/$PLATFORM_RELEASE/deploy/migrations/019_vehicle_source_provider.sql ``` -The API guards the access-threshold tables for compatibility, while alert and reconciliation APIs deliberately require their 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 `012` creates the atomic, versioned business Scope projection; its reserved `row_number` column is quoted for production MySQL compatibility. 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. Migration `016` adds customer name, department and responsible-person dimensions plus bounded lookup indexes to the platform-owned Scope projection. Migration `017` creates the reconciliation run, issue and immutable action tables; the unique fingerprint index is the database-level duplicate-work-item guard. Migration `018` records every OneOS-sourced brand fill before the platform identity OEM field changes; it does not grant access to OneOS or run a cross-database sync. +The API guards the access-threshold tables for compatibility, while alert and reconciliation APIs deliberately require their 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 `012` creates the atomic, versioned business Scope projection; its reserved `row_number` column is quoted for production MySQL compatibility. 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. Migration `016` adds customer name, department and responsible-person dimensions plus bounded lookup indexes to the platform-owned Scope projection. Migration `017` creates the reconciliation run, issue and immutable action tables; the unique fingerprint index is the database-level duplicate-work-item guard. Migration `018` records every OneOS-sourced brand fill before the platform identity OEM field changes; it does not grant access to OneOS or run a cross-database sync. Migration `019` stores administrator-maintained provider names by exact vehicle/protocol/source key and writes an immutable versioned audit. The browser still receives only the SHA-256 `sourceRef`; raw source keys remain server-side. `docs/oneos-brand-backfill.sql` is an explicit one-time calibration, not a service dependency. It reads OneOS vehicle/model master data, inserts immutable source evidence, and fills only empty `vehicle_identity_binding.oem` values. Review the pre-run count, apply migration `018`, run the script once through the migration runner or a transaction-capable MySQL client, and verify its final audit/missing counts. Never overwrite a non-empty platform brand and never schedule this script; future updates belong in the formal OneOS API. @@ -214,6 +215,8 @@ Production release `reconciliation-summary-fix-20260716191153` applied migration Production release `vehicle-count-authority-exact-20260716193428` made `vehicle_identity_binding` the authoritative denominator for vehicle coverage, coverage summaries, missing-source statistics and service readiness. Authenticated production smoke returned 1,024 vehicles from vehicle query, monitor, access management and source readiness, while keeping the 11 unbound realtime-only VINs as a separate `identityRequiredVehicles` operational metric and 11 `UNBOUND_SOURCE` reconciliation issues. Do not reintroduce the realtime snapshot union into formal vehicle totals; an unbound source becomes a vehicle only after the identity-binding workflow succeeds. +Production release `source-provider-collation-hotfix-20260716195830` applied migration `019` and added platform-owned provider maintenance by opaque `sourceRef`. Provider changes require a separate authoritative evidence note, use the same optimistic vehicle-source version, preserve the existing gateway-policy remark, and write an immutable provider audit. Diagnostic and access projections prefer the verified override while raw `source_key` remains server-side. The first release exposed a production-only collation difference between gateway-owned source tables and platform-owned tables; the hotfix uses exact binary VIN/protocol/source-key joins, avoiding fuzzy matching and restoring both source diagnostic and access detail. Authenticated smoke on `LB9A32A21R0LS1464` verified rejected missing evidence without a version change, admin-only writes, operator read-only access, viewer denial, `v1→v2`, unchanged policy remark, immediate `JT808 / G7s` access evidence, and source-key redaction. The platform, both alert evaluators and reconciliation timer remained active. + Release `oneos-api-client-20260716184601` applied migrations `012` and `016` and deployed the switchable API/database sync binary. The projection is intentionally empty (`active_version` unset) until a verified OneOS endpoint is configured. A production fail-closed smoke with `ONEOS_SCOPE_SOURCE=api` and no endpoint exited nonzero without publishing any row. Do not install or enable the timer until the endpoint, service token, signing secret, source-IP restriction and a representative snapshot have passed the contract checks below. Production release `source-diagnosis-stable-20260716173740` applied migration `015` before switching API traffic. The release gate verified 23 current assets and 42 compatibility assets. The authenticated diagnostic smoke used a real multi-source vehicle, confirmed that `source_key` was absent, and exercised the admin PUT route with values identical to the current policy; version, audit count and recommended source remained unchanged. Viewer/operator/admin access returned 403/200/200, median response time across 20 reads was approximately 70 ms (P95 79 ms), and the platform plus both alert evaluators remained active. diff --git a/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md b/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md index 983c2cab..cc175a90 100644 --- a/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md +++ b/vehicle-data-platform/docs/vehicle-data-platform-meeting-todo.md @@ -374,10 +374,21 @@ - 11 个未绑定来源仍保留在数据差异中心的 11 条 `UNBOUND_SOURCE` 待处理工单中;它们只能通过身份绑定流程进入正式车辆集,不能继续造成 1,035 辆的错误口径。 - Go 全量测试、前端 49 个文件/259 项测试、TypeScript/Vite 生产构建、Web 资产烟测及四个 systemd 单元状态检查通过。 +提供方维护能力(release `source-provider-collation-hotfix-20260716195830`): + +- 运维诊断页允许管理员按具体车辆、协议和独立终端维护提供方名称;前端仍只提交 SHA-256 `sourceRef`,原始 `source_key` 仅在服务端解析和保存。 +- 提供方名称变更必须单独填写权威终端清单、厂商确认记录等核验依据。核验依据与启停/优先级的策略备注是两个独立字段和审计事实;只改提供方不会清空或改写已有策略备注。 +- 迁移 `019_vehicle_source_provider.sql` 已在生产应用,保存当前提供方投影和不可变版本审计。来源诊断与接入管理优先采用该人工核验值,再回退实时平台名或当前来源编码。 +- operator 可查看单车来源诊断但不能写入,viewer 被拒绝,只有 admin 可维护;缺少核验依据的请求返回 `SOURCE_PROVIDER_REASON_REQUIRED`,且乐观版本不会变化。 +- 首次发布在生产真实请求中发现网关来源表与平台表排序规则不同,已改为 VIN/协议/source key 的二进制精确关联;修复后单车诊断和接入明细均恢复,未通过模糊或降级匹配绕过。 +- 生产烟测以 `LB9A32A21R0LS1464` 的既有 `g7s` 识别结果写入同义规范值 `G7s`:策略版本 `v1→v2`,提供方审计包含核验依据,原策略备注保持不变,接入明细立即显示 `JT808 / G7s`,响应中不存在 `sourceKey`。平台、两类告警评估器和对账定时器均为 active。 +- Go 全量测试、前端 49 个文件/259 项测试、TypeScript/Vite 生产构建和 23 个当前 Web 资源门禁通过。 + 剩余业务资料依赖: -- 当前仍有 111 辆 JT808 提供方缺失。65 辆注册报文携带厂商码 `70504`,但生产中该编码同时对应 G7s、东方北斗和赛格,不能唯一映射;110 辆在 OneOS 只标记为“氢气智能管理平台”,这是聚合来源而非终端提供方;非当前来源中也没有额外 `source_code` 可补齐。 -- 因此这 111 辆继续留在待处理队列,需 GPS 运维/厂家提供 phone/终端到提供方的权威清单。平台不会根据终端号、IP、位置或厂商码猜测提供方。 +- 当前动态复核仍有 104 辆 JT808 提供方缺失。注册报文厂商码在生产中同时对应 G7s、东方北斗和赛格,不能唯一映射;OneOS 只标记为“氢气智能管理平台”,这是聚合来源而非终端提供方。 +- 进一步只读核对 `lingniu_prod` 后确认:旧 `tab_truck_device_info`、`view_addgpsdata`、`historical_data` 设备厂家表当前均无有效数据;仅存的 `v_vehicle_daily_stats.source=G7S/TBOX/NONE` 最晚截至 2025-12-08,属于历史里程来源,不能证明 2026 年当前终端提供方。 +- 因此这 104 辆继续留在待处理队列,需 GPS 运维/厂家提供当前 phone/终端到提供方的权威清单。平台不会根据终端号、IP、位置、旧里程来源或厂商码猜测提供方。 - 另有 8 辆 OneOS 车型主数据本身无品牌,需资产资料责任人补录后再通过正式 API 同步。 目标: