feat(operations): add auditable source provider maintenance

This commit is contained in:
lingniu
2026-07-16 20:01:08 +08:00
parent c270140006
commit 9a5e6e0c4f
19 changed files with 430 additions and 67 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(&currentVersion); 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(&currentProvider)
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)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 <tr className={source.recommended ? 'is-recommended' : ''}>
<td><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.sourceKind || '未维护终端'}</span></td>
<td><b>{source.protocol}</b><span>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</span></td>
@@ -58,8 +66,10 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
<td className="v2-source-policy-cell">
<label><input type="checkbox" checked={enabled} disabled={!editable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} /></label>
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
<input aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder="调整原因" />
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000} onClick={() => save.mutate()}>{save.isPending ? '保存中' : '保存策略'}</button>
<input className="v2-source-provider-input" aria-label={`${source.sourceLabel} 提供方`} value={providerName} maxLength={128} disabled={!editable || save.isPending} onChange={(event) => setProviderName(event.target.value)} placeholder="提供方,如 G7s" />
<input className="v2-source-provider-evidence-input" aria-label={`${source.sourceLabel} 提供方核验依据`} value={providerEvidence} maxLength={255} disabled={!editable || save.isPending || !providerChanged} onChange={(event) => setProviderEvidence(event.target.value)} placeholder={providerChanged ? '权威终端清单、厂商确认记录等(必填)' : '修改提供方后填写核验依据'} />
<input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder="启停或优先级调整原因(可选)" />
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : '保存策略'}</button>
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
</td>
</tr>;
@@ -127,10 +137,17 @@ function SourceDiagnosticWorkspace() {
<div className="v2-source-recommendation"><strong></strong><p>{data.recommendationReason}</p><span>{data.refreshHint}</span></div>
{!editable ? <p className="v2-source-readonly"></p> : null}
<div className="v2-source-table-wrap"><table className="v2-source-table"><thead><tr><th> / </th><th></th><th>线 / </th><th> / </th><th></th><th> / </th><th></th><th></th></tr></thead><tbody>
{data.evidence.locationSources.map((source) => <SourcePolicyRow key={source.sourceRef || `${source.protocol}-${source.sourceLabel}-${source.terminalLabel}`} vin={data.evidence.vin} source={source} diagnostic={data} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => queryClient.setQueryData(['ops-source-diagnostic', data.evidence.vin], next)} />)}
{data.evidence.locationSources.map((source) => <SourcePolicyRow key={source.sourceRef || `${source.protocol}-${source.sourceLabel}-${source.terminalLabel}`} vin={data.evidence.vin} source={source} diagnostic={data} editable={editable && Boolean(source.sourceRef)} onSaved={(next) => {
queryClient.setQueryData(['ops-source-diagnostic', data.evidence.vin], next);
void Promise.all([
queryClient.invalidateQueries({ queryKey: ['access-summary'] }),
queryClient.invalidateQueries({ queryKey: ['access-vehicles'] }),
queryClient.invalidateQueries({ queryKey: ['ops-source-readiness-v2'] })
]);
}} />)}
</tbody></table></div>
<section className="v2-source-audit"><header><strong></strong><span> source_key </span></header>
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p></p>}
{data.policy.audit.length ? <ol>{data.policy.audit.map((item) => <li key={`${item.changeType}-${item.version}-${item.sourceRef}`}><b>v{item.version}</b><span>{item.summary}</span><em>{item.changeType === 'provider' ? '提供方' : '策略'} · {item.actor} · {fmt(item.changedAt)}</em></li>)}</ol> : <p></p>}
</section>
</> : null}
</section>;

View File

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

View File

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

View File

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

View File

@@ -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=<WGS-84>&latitude=<WGS-84>` 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).

View File

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

View File

@@ -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 同步。
目标: