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(
{data.recommendationReason}
{data.refreshHint}当前账号可查看诊断证据;只有管理员可以调整启停和优先级。
: null}| 来源 / 终端 | 协议 | 在线 / 质量 | 首次 / 最近上报 | 上报周期 | 位置 / 里程 | 选举结论 | 运维策略 |
|---|
该车辆尚无人工来源策略变更。
} + {data.policy.audit.length ?该车辆尚无人工来源策略或提供方变更。
}