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