feat(operations): support canonical source providers
This commit is contained in:
@@ -93,9 +93,9 @@ 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
|
||||
ON BINARY provider.vin = BINARY s.vin
|
||||
AND BINARY provider.protocol = BINARY s.protocol
|
||||
AND BINARY provider.source_key = BINARY COALESCE(NULLIF(l.source_key, ''), CONCAT(s.protocol, ':canonical'))
|
||||
LEFT JOIN (
|
||||
SELECT vin, protocol, MAX(daily_mileage_km) AS daily_mileage_km
|
||||
FROM vehicle_daily_mileage
|
||||
|
||||
@@ -229,6 +229,12 @@ func (m *MockStore) VehicleLocationSourceHistory(_ context.Context, vin string)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleSourceProvider(_ context.Context, vin string, protocol string, sourceKey string) (string, error) {
|
||||
m.sourcePolicyMu.RLock()
|
||||
defer m.sourcePolicyMu.RUnlock()
|
||||
return m.sourceProviders[vin+"\x00"+protocol+"\x00"+sourceKey], nil
|
||||
}
|
||||
|
||||
func (m *MockStore) VehicleSourcePolicy(_ context.Context, vin string) (VehicleSourcePolicyConfig, error) {
|
||||
m.sourcePolicyMu.RLock()
|
||||
defer m.sourcePolicyMu.RUnlock()
|
||||
@@ -252,7 +258,7 @@ func (m *MockStore) SaveVehicleSourcePolicy(_ context.Context, update vehicleSou
|
||||
}
|
||||
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
|
||||
policyChanged := update.PolicyConfigurable && (update.CurrentEnabled != update.Enabled || update.CurrentPriority != update.Priority || update.CurrentRemark != update.Remark)
|
||||
providerChanged := currentProvider != update.ProviderName
|
||||
if !policyChanged && !providerChanged {
|
||||
return current, nil
|
||||
|
||||
@@ -1134,14 +1134,15 @@ type VehicleSourcePolicyUpdate struct {
|
||||
|
||||
type vehicleSourcePolicyStoreUpdate struct {
|
||||
VehicleSourcePolicyUpdate
|
||||
VIN string
|
||||
Protocol string
|
||||
SourceKey string
|
||||
SourceLabel string
|
||||
CurrentEnabled bool
|
||||
CurrentPriority int
|
||||
CurrentRemark string
|
||||
CurrentProvider string
|
||||
VIN string
|
||||
Protocol string
|
||||
SourceKey string
|
||||
SourceLabel string
|
||||
CurrentEnabled bool
|
||||
CurrentPriority int
|
||||
CurrentRemark string
|
||||
CurrentProvider string
|
||||
PolicyConfigurable bool
|
||||
}
|
||||
|
||||
type vehicleLocationSourceHistory struct {
|
||||
|
||||
@@ -69,14 +69,20 @@ func (s *ProductionStore) VehicleSourceEvidence(ctx context.Context, vin string,
|
||||
}
|
||||
|
||||
func (s *ProductionStore) canonicalLocationEvidence(ctx context.Context, vin string) ([]canonicalLocationEvidence, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT protocol, COALESCE(NULLIF(source_key, ''), CONCAT(protocol, ':canonical')),
|
||||
COALESCE(NULLIF(plate, ''), ''), DATE_FORMAT(event_time, '%Y-%m-%d %H:%i:%s'),
|
||||
latitude, longitude, speed_kmh, total_mileage_km, soc_percent,
|
||||
DATE_FORMAT(received_at, '%Y-%m-%d %H:%i:%s'),
|
||||
CASE WHEN received_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 1 ELSE 0 END
|
||||
FROM vehicle_realtime_location
|
||||
WHERE vin = ?
|
||||
ORDER BY protocol`, vin)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT l.protocol,
|
||||
COALESCE(NULLIF(l.source_key, ''), CONCAT(l.protocol, ':canonical')),
|
||||
COALESCE(NULLIF(l.plate, ''), ''), DATE_FORMAT(l.event_time, '%Y-%m-%d %H:%i:%s'),
|
||||
l.latitude, l.longitude, l.speed_kmh, l.total_mileage_km, l.soc_percent,
|
||||
DATE_FORMAT(l.received_at, '%Y-%m-%d %H:%i:%s'),
|
||||
CASE WHEN l.received_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 1 ELSE 0 END,
|
||||
COALESCE(provider.provider_name, '')
|
||||
FROM vehicle_realtime_location l
|
||||
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 COALESCE(NULLIF(l.source_key, ''), CONCAT(l.protocol, ':canonical'))
|
||||
WHERE l.vin = ?
|
||||
ORDER BY l.protocol`, vin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -88,16 +94,19 @@ ORDER BY protocol`, vin)
|
||||
var eventTime, receivedAt sql.NullString
|
||||
var latitude, longitude, speed, mileage, soc sql.NullFloat64
|
||||
var online int
|
||||
var provider string
|
||||
if err := rows.Scan(
|
||||
&row.Protocol, &row.sourceKey, &plate, &eventTime,
|
||||
&latitude, &longitude, &speed, &mileage, &soc, &receivedAt, &online,
|
||||
&latitude, &longitude, &speed, &mileage, &soc, &receivedAt, &online, &provider,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.SourceLabel = row.Protocol
|
||||
row.ProviderOverride = strings.TrimSpace(provider)
|
||||
row.SourceLabel = firstNonEmpty(row.ProviderOverride, row.Protocol)
|
||||
row.SourceKind = "CANONICAL"
|
||||
row.SelectedWithinProtocol = true
|
||||
row.Enabled = true
|
||||
row.Priority = 100
|
||||
row.Online = online == 1
|
||||
row.QualityStatus = "OK"
|
||||
row.Longitude = nullFloatPointer(longitude)
|
||||
|
||||
@@ -13,6 +13,7 @@ type vehicleSourcePolicyStore interface {
|
||||
VehicleSourcePolicy(context.Context, string) (VehicleSourcePolicyConfig, error)
|
||||
SaveVehicleSourcePolicy(context.Context, vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error)
|
||||
VehicleLocationSourceHistory(context.Context, string) ([]vehicleLocationSourceHistory, error)
|
||||
VehicleSourceProvider(context.Context, string, string, string) (string, error)
|
||||
}
|
||||
|
||||
func sourceReference(vin, protocol, sourceKey string) string {
|
||||
@@ -57,9 +58,48 @@ func (s *Service) VehicleSourceDiagnostic(ctx context.Context, vin string) (Vehi
|
||||
protocolStatus[status.Protocol] = status
|
||||
}
|
||||
}
|
||||
representedProtocols := make(map[string]bool, len(evidence.LocationSources))
|
||||
for _, source := range evidence.LocationSources {
|
||||
representedProtocols[source.Protocol] = true
|
||||
}
|
||||
if access != nil {
|
||||
for _, status := range access.ProtocolStatuses {
|
||||
if !status.Connected || representedProtocols[status.Protocol] {
|
||||
continue
|
||||
}
|
||||
sourceKey := status.Protocol + ":canonical"
|
||||
providerOverride, providerErr := store.VehicleSourceProvider(ctx, evidence.VIN, status.Protocol, sourceKey)
|
||||
if providerErr != nil {
|
||||
return VehicleSourceDiagnostic{}, providerErr
|
||||
}
|
||||
source := VehicleLocationSourceEvidence{
|
||||
Protocol: status.Protocol,
|
||||
SourceLabel: firstNonEmpty(providerOverride, status.Provider, status.Protocol),
|
||||
ProviderOverride: providerOverride,
|
||||
SourceKind: "CANONICAL",
|
||||
SelectedWithinProtocol: true,
|
||||
Recommended: evidence.RecommendedLocationProtocol == status.Protocol,
|
||||
Enabled: true,
|
||||
Priority: 100,
|
||||
Online: status.OnlineState == "online",
|
||||
QualityStatus: "NO_LOCATION",
|
||||
QualityReason: "协议已形成接入快照,但当前没有可展开的独立位置来源",
|
||||
EventTime: status.LatestEventAt,
|
||||
ReceivedAt: status.LatestReceivedAt,
|
||||
FirstSeenAt: status.FirstSeenAt,
|
||||
ReportIntervalSec: status.ReportIntervalSec,
|
||||
sourceKey: sourceKey,
|
||||
}
|
||||
evidence.LocationSources = append(evidence.LocationSources, source)
|
||||
representedProtocols[status.Protocol] = true
|
||||
if source.Recommended {
|
||||
evidence.RecommendedLocationLabel = source.SourceLabel
|
||||
}
|
||||
}
|
||||
}
|
||||
for index := range evidence.LocationSources {
|
||||
source := &evidence.LocationSources[index]
|
||||
if !strings.EqualFold(source.SourceKind, "CANONICAL") {
|
||||
if source.sourceKey != "" {
|
||||
source.SourceRef = sourceReference(evidence.VIN, source.Protocol, source.sourceKey)
|
||||
}
|
||||
if item, exists := historyBySource[source.Protocol+"\x00"+source.sourceKey]; exists {
|
||||
@@ -135,7 +175,7 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd
|
||||
var selected *VehicleLocationSourceEvidence
|
||||
for index := range evidence.LocationSources {
|
||||
source := &evidence.LocationSources[index]
|
||||
if !strings.EqualFold(source.SourceKind, "CANONICAL") && sourceReference(vin, source.Protocol, source.sourceKey) == update.SourceRef {
|
||||
if source.sourceKey != "" && sourceReference(vin, source.Protocol, source.sourceKey) == update.SourceRef {
|
||||
selected = source
|
||||
break
|
||||
}
|
||||
@@ -143,6 +183,15 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd
|
||||
if selected == nil || selected.sourceKey == "" {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_NOT_FOUND", Message: "当前车辆不存在该来源,请刷新后重试"}
|
||||
}
|
||||
policyConfigurable := !strings.EqualFold(selected.SourceKind, "CANONICAL")
|
||||
if !policyConfigurable && (selected.Enabled != update.Enabled ||
|
||||
selected.Priority != update.Priority ||
|
||||
strings.TrimSpace(selected.PolicyRemark) != update.Remark) {
|
||||
return VehicleSourceDiagnostic{}, clientError{
|
||||
Code: "SOURCE_POLICY_CANONICAL_READ_ONLY",
|
||||
Message: "协议融合快照只能维护提供方,不能调整启停、优先级或策略备注",
|
||||
}
|
||||
}
|
||||
providerChanged := strings.TrimSpace(selected.ProviderOverride) != update.ProviderName
|
||||
if providerChanged && update.ProviderEvidence == "" {
|
||||
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PROVIDER_REASON_REQUIRED", Message: "维护提供方时必须填写权威资料来源或核验说明"}
|
||||
@@ -161,6 +210,7 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd
|
||||
CurrentPriority: selected.Priority,
|
||||
CurrentRemark: selected.PolicyRemark,
|
||||
CurrentProvider: selected.ProviderOverride,
|
||||
PolicyConfigurable: policyConfigurable,
|
||||
}); err != nil {
|
||||
return VehicleSourceDiagnostic{}, err
|
||||
}
|
||||
@@ -183,7 +233,7 @@ func authorizeInternalOperations(ctx context.Context, adminOnly bool) error {
|
||||
|
||||
func sourceSelectionReason(source VehicleLocationSourceEvidence) string {
|
||||
if strings.EqualFold(source.SourceKind, "CANONICAL") {
|
||||
return "这是协议融合结果快照,不是独立终端候选;需要展开实际来源后调整策略。"
|
||||
return "这是协议融合结果快照,不是独立终端候选;可维护经权威资料确认的提供方,但不能从该快照调整终端启停或优先级。"
|
||||
}
|
||||
if !source.Enabled {
|
||||
return "已由运维策略停用,不参与协议内选举。"
|
||||
@@ -207,7 +257,7 @@ func recommendationReason(evidence VehicleSourceEvidence) string {
|
||||
for _, source := range evidence.LocationSources {
|
||||
if source.Recommended {
|
||||
if strings.EqualFold(source.SourceKind, "CANONICAL") {
|
||||
return fmt.Sprintf("当前推荐 %s 的协议融合结果;本次证据未取得可配置的独立终端候选,不能从该快照反推人工优先级。", source.Protocol)
|
||||
return fmt.Sprintf("当前推荐 %s 的协议融合结果;本次证据未取得独立终端候选,只能维护权威提供方,不能从该快照反推人工优先级。", source.Protocol)
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"当前推荐 %s / %s:来源已启用、质量为 %s、协议内选中,策略优先级为 %d。选举还会综合两分钟新鲜度、漂移冲突保护和连续有效样本。",
|
||||
|
||||
@@ -31,6 +31,17 @@ ORDER BY protocol, source_key`, vin)
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleSourceProvider(ctx context.Context, vin string, protocol string, sourceKey string) (string, error) {
|
||||
var provider string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT provider_name
|
||||
FROM platform_vehicle_source_provider
|
||||
WHERE vin = ? AND protocol = ? AND source_key = ?`, vin, protocol, sourceKey).Scan(&provider)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(provider), err
|
||||
}
|
||||
|
||||
func (s *ProductionStore) VehicleSourcePolicy(ctx context.Context, vin string) (VehicleSourcePolicyConfig, error) {
|
||||
config := VehicleSourcePolicyConfig{VIN: vin, Version: 1, UpdatedBy: "system", Audit: []VehicleSourcePolicyAudit{}}
|
||||
var updatedAt sql.NullString
|
||||
@@ -95,16 +106,18 @@ WHERE vin = ? FOR UPDATE`, update.VIN).Scan(¤tVersion); err != nil {
|
||||
currentPriority := update.CurrentPriority
|
||||
currentRemark := update.CurrentRemark
|
||||
currentProvider := strings.TrimSpace(update.CurrentProvider)
|
||||
var enabled int
|
||||
err = tx.QueryRowContext(ctx, `SELECT enabled, priority, remark
|
||||
if update.PolicyConfigurable {
|
||||
var enabled int
|
||||
err = tx.QueryRowContext(ctx, `SELECT enabled, priority, remark
|
||||
FROM vehicle_location_source_policy
|
||||
WHERE vin = ? AND protocol = ? AND source_key = ?`,
|
||||
update.VIN, update.Protocol, update.SourceKey,
|
||||
).Scan(&enabled, ¤tPriority, ¤tRemark)
|
||||
if err == nil {
|
||||
currentEnabled = enabled == 1
|
||||
} else if err != sql.ErrNoRows {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
update.VIN, update.Protocol, update.SourceKey,
|
||||
).Scan(&enabled, ¤tPriority, ¤tRemark)
|
||||
if err == nil {
|
||||
currentEnabled = enabled == 1
|
||||
} else if err != sql.ErrNoRows {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
}
|
||||
err = tx.QueryRowContext(ctx, `SELECT provider_name
|
||||
FROM platform_vehicle_source_provider
|
||||
@@ -116,9 +129,9 @@ WHERE vin = ? AND protocol = ? AND source_key = ?`,
|
||||
} else if err != nil {
|
||||
return VehicleSourcePolicyConfig{}, err
|
||||
}
|
||||
policyChanged := currentEnabled != update.Enabled ||
|
||||
policyChanged := update.PolicyConfigurable && (currentEnabled != update.Enabled ||
|
||||
currentPriority != update.Priority ||
|
||||
strings.TrimSpace(currentRemark) != update.Remark
|
||||
strings.TrimSpace(currentRemark) != update.Remark)
|
||||
providerChanged := strings.TrimSpace(currentProvider) != update.ProviderName
|
||||
if !policyChanged && !providerChanged {
|
||||
if err := tx.Rollback(); err != nil {
|
||||
|
||||
@@ -61,13 +61,14 @@ func TestProductionProviderUpdateKeepsPolicyRemarkSeparate(t *testing.T) {
|
||||
Remark: "保持当前优先级",
|
||||
Actor: "平台管理员",
|
||||
},
|
||||
VIN: "VIN001",
|
||||
Protocol: "JT808",
|
||||
SourceKey: "opaque-source-key",
|
||||
SourceLabel: "JT808 终端",
|
||||
CurrentEnabled: true,
|
||||
CurrentPriority: 20,
|
||||
CurrentRemark: "保持当前优先级",
|
||||
VIN: "VIN001",
|
||||
Protocol: "JT808",
|
||||
SourceKey: "opaque-source-key",
|
||||
SourceLabel: "JT808 终端",
|
||||
CurrentEnabled: true,
|
||||
CurrentPriority: 20,
|
||||
CurrentRemark: "保持当前优先级",
|
||||
PolicyConfigurable: true,
|
||||
}
|
||||
|
||||
mock.ExpectBegin()
|
||||
@@ -120,3 +121,156 @@ WHERE vin = ? AND protocol = ? AND source_key = ?`)).
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type canonicalProviderStore struct {
|
||||
*MockStore
|
||||
}
|
||||
|
||||
func newCanonicalProviderStore() *canonicalProviderStore {
|
||||
return &canonicalProviderStore{MockStore: NewMockStore()}
|
||||
}
|
||||
|
||||
func (s *canonicalProviderStore) VehicleSourceEvidence(_ context.Context, vin string, date string) (VehicleSourceEvidence, error) {
|
||||
sourceKey := "JT808:canonical"
|
||||
providerKey := vin + "\x00JT808\x00" + sourceKey
|
||||
s.sourcePolicyMu.RLock()
|
||||
provider := s.sourceProviders[providerKey]
|
||||
s.sourcePolicyMu.RUnlock()
|
||||
return VehicleSourceEvidence{
|
||||
VIN: vin, Plate: "沪A00001", MileageDate: date,
|
||||
LocationSources: []VehicleLocationSourceEvidence{{
|
||||
Protocol: "JT808", SourceLabel: firstNonEmpty(provider, "JT808"), ProviderOverride: provider,
|
||||
SourceKind: "CANONICAL", SelectedWithinProtocol: true, Enabled: true, Priority: 100,
|
||||
Online: true, QualityStatus: "OK", sourceKey: sourceKey,
|
||||
}},
|
||||
MileageSources: []VehicleMileageSourceEvidence{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestCanonicalSourceAllowsProviderButRejectsPolicyMutation(t *testing.T) {
|
||||
store := newCanonicalProviderStore()
|
||||
service := NewService(store)
|
||||
admin := WithPrincipal(context.Background(), Principal{Name: "平台管理员", Role: "admin", UserType: "admin"})
|
||||
diagnostic, err := service.VehicleSourceDiagnostic(admin, "VIN-CANONICAL-01")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(diagnostic.Evidence.LocationSources) != 1 || len(diagnostic.Evidence.LocationSources[0].SourceRef) != 64 {
|
||||
t.Fatalf("canonical source must expose an opaque provider reference: %+v", diagnostic.Evidence.LocationSources)
|
||||
}
|
||||
source := diagnostic.Evidence.LocationSources[0]
|
||||
updated, err := service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
|
||||
Version: diagnostic.Policy.Version, SourceRef: source.SourceRef,
|
||||
ProviderName: "东方北斗", ProviderEvidence: "GPS 运维终端清单 2026-07-16",
|
||||
Enabled: true, Priority: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("canonical provider update failed: %v", err)
|
||||
}
|
||||
if updated.Policy.Version != 2 || updated.Evidence.LocationSources[0].ProviderOverride != "东方北斗" {
|
||||
t.Fatalf("canonical provider update was not persisted: %+v", updated)
|
||||
}
|
||||
_, err = service.UpdateVehicleSourcePolicy(admin, diagnostic.Evidence.VIN, VehicleSourcePolicyUpdate{
|
||||
Version: 2, SourceRef: source.SourceRef,
|
||||
ProviderName: "东方北斗", Enabled: false, Priority: 100,
|
||||
})
|
||||
clientErr, ok := asClientError(err)
|
||||
if !ok || clientErr.Code != "SOURCE_POLICY_CANONICAL_READ_ONLY" {
|
||||
t.Fatalf("canonical policy mutation should fail closed, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type accessOnlyProviderStore struct {
|
||||
*MockStore
|
||||
}
|
||||
|
||||
func (s *accessOnlyProviderStore) VehicleSourceEvidence(_ context.Context, vin string, date string) (VehicleSourceEvidence, error) {
|
||||
return VehicleSourceEvidence{
|
||||
VIN: vin, MileageDate: date,
|
||||
LocationSources: []VehicleLocationSourceEvidence{},
|
||||
MileageSources: []VehicleMileageSourceEvidence{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestDiagnosticSynthesizesProviderTargetForConnectedProtocolWithoutLocation(t *testing.T) {
|
||||
service := NewService(&accessOnlyProviderStore{MockStore: NewMockStore()})
|
||||
operator := WithPrincipal(context.Background(), Principal{Name: "运维员", Role: "operator", UserType: "operator"})
|
||||
diagnostic, err := service.VehicleSourceDiagnostic(operator, "LB9A32A24R0LS1426")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var jt808 *VehicleLocationSourceEvidence
|
||||
for index := range diagnostic.Evidence.LocationSources {
|
||||
if diagnostic.Evidence.LocationSources[index].Protocol == "JT808" {
|
||||
jt808 = &diagnostic.Evidence.LocationSources[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if jt808 == nil || jt808.SourceKind != "CANONICAL" || len(jt808.SourceRef) != 64 || jt808.Priority != 100 {
|
||||
t.Fatalf("connected protocol without location must remain provider-maintainable: %+v", diagnostic.Evidence.LocationSources)
|
||||
}
|
||||
if jt808.QualityStatus != "NO_LOCATION" || jt808.QualityReason == "" {
|
||||
t.Fatalf("synthetic provider target must explain missing location evidence: %+v", jt808)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionCanonicalProviderUpdateSkipsTerminalPolicyTable(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: 1, SourceRef: "canonical-ref", ProviderName: "东方北斗",
|
||||
ProviderEvidence: "GPS 运维终端清单 2026-07-16",
|
||||
Enabled: true, Priority: 100, Actor: "平台管理员",
|
||||
},
|
||||
VIN: "VIN-CANONICAL-01", Protocol: "JT808", SourceKey: "JT808:canonical",
|
||||
SourceLabel: "JT808", CurrentEnabled: true, CurrentPriority: 100,
|
||||
PolicyConfigurable: false,
|
||||
}
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(regexp.QuoteMeta(`INSERT IGNORE INTO platform_vehicle_source_policy_version
|
||||
(vin, version, updated_by) VALUES (?, 1, 'system')`)).
|
||||
WithArgs("VIN-CANONICAL-01").WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT version FROM platform_vehicle_source_policy_version
|
||||
WHERE vin = ? FOR UPDATE`)).
|
||||
WithArgs("VIN-CANONICAL-01").WillReturnRows(sqlmock.NewRows([]string{"version"}).AddRow(1))
|
||||
mock.ExpectQuery(regexp.QuoteMeta(`SELECT provider_name
|
||||
FROM platform_vehicle_source_provider
|
||||
WHERE vin = ? AND protocol = ? AND source_key = ?`)).
|
||||
WithArgs("VIN-CANONICAL-01", "JT808", "JT808:canonical").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"provider_name"}))
|
||||
mock.ExpectExec(`INSERT INTO platform_vehicle_source_provider`).
|
||||
WithArgs("VIN-CANONICAL-01", "JT808", "JT808:canonical", "canonical-ref", "东方北斗", "平台管理员").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec(`UPDATE platform_vehicle_source_policy_version`).
|
||||
WithArgs(2, "平台管理员", "VIN-CANONICAL-01", 1).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`INSERT INTO platform_vehicle_source_provider_audit`).
|
||||
WithArgs(
|
||||
"VIN-CANONICAL-01", 2, "JT808", "canonical-ref", "JT808:canonical",
|
||||
"", "东方北斗", "平台管理员", "GPS 运维终端清单 2026-07-16",
|
||||
"JT808:提供方 未维护→东方北斗;核验依据:GPS 运维终端清单 2026-07-16",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectCommit()
|
||||
mock.ExpectQuery(`SELECT version, updated_by`).WithArgs("VIN-CANONICAL-01").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "updated_by", "updated_at"}).AddRow(2, "平台管理员", "2026-07-16 20:10:00"))
|
||||
mock.ExpectQuery(`SELECT version, change_type, protocol, source_ref`).WithArgs("VIN-CANONICAL-01", "VIN-CANONICAL-01").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"version", "change_type", "protocol", "source_ref", "source_label", "actor", "changed_at", "summary"}).
|
||||
AddRow(2, "provider", "JT808", "canonical-ref", "东方北斗", "平台管理员", "2026-07-16 20:10:00", "JT808:提供方 未维护→东方北斗;核验依据:GPS 运维终端清单 2026-07-16"))
|
||||
|
||||
config, err := store.SaveVehicleSourcePolicy(context.Background(), update)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.Version != 2 {
|
||||
t.Fatalf("unexpected canonical provider config: %+v", config)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,3 +173,57 @@ test('fuzzy searches a vehicle and renders all source diagnosis evidence', async
|
||||
}));
|
||||
await waitFor(() => expect(mocks.vehicleSourceDiagnostic).toHaveBeenCalledWith('VIN001', expect.any(AbortSignal)));
|
||||
});
|
||||
|
||||
test('allows provider maintenance but keeps canonical source policy read only', async () => {
|
||||
seedSession();
|
||||
mocks.opsHealth.mockResolvedValue({
|
||||
linkHealth: [], kafkaLag: 0, activeConnections: 10, capacityFindings: [], redisOnlineKeys: 5,
|
||||
tdengineWritable: true, mysqlWritable: true,
|
||||
runtime: { platformRelease: 'test-release', dataMode: 'production', requestTimeoutMs: 5000, amapSecurityProxyEnabled: true, amapSecurityCodeExposed: false }
|
||||
});
|
||||
mocks.sourceReadiness.mockResolvedValue({ totalVehicles: 1, boundVehicles: 1, identityRequiredVehicles: 0, onlineVehicles: 1, sources: [] });
|
||||
mocks.vehicleCoverage.mockResolvedValue({ items: [{ vin: 'VIN-CANONICAL', plate: '沪A00001', protocols: ['JT808'], missingProtocols: [], sourceStatus: [], sourceCount: 1, onlineSourceCount: 1, online: true, lastSeen: '', bindingStatus: 'bound' }], total: 1, limit: 20, offset: 0 });
|
||||
const diagnostic = {
|
||||
evidence: {
|
||||
vin: 'VIN-CANONICAL', plate: '沪A00001', mileageDate: '', recommendedLocationProtocol: 'JT808',
|
||||
recommendedLocationLabel: 'JT808', locationConflict: false,
|
||||
locationSources: [{
|
||||
protocol: 'JT808', sourceLabel: 'JT808', providerOverride: '', terminalLabel: '', sourceKind: 'CANONICAL', sourceRef: 'b'.repeat(64),
|
||||
selectedWithinProtocol: true, recommended: true, enabled: true, priority: 100, policyRemark: '', online: true, qualityStatus: 'OK', qualityReason: '',
|
||||
longitude: 113.1, latitude: 23.1, eventTime: '2026-07-16 10:00:00', receivedAt: '2026-07-16 10:00:01',
|
||||
selectionReason: '协议融合快照只能维护提供方'
|
||||
}],
|
||||
mileageSources: [], comparison: { locationMaxDistanceM: 0, totalMileageDeltaKm: 0, dailyMileageDeltaKm: 0, reportTimeDeltaSeconds: 0 }, asOf: ''
|
||||
},
|
||||
policy: { vin: 'VIN-CANONICAL', version: 1, updatedBy: 'system', updatedAt: '', audit: [] },
|
||||
recommendationReason: '当前推荐 JT808 的协议融合结果', 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' } });
|
||||
const candidateButton = await waitFor(() => {
|
||||
const button = document.querySelector<HTMLButtonElement>('.v2-source-candidates button');
|
||||
expect(button).toBeTruthy();
|
||||
return button!;
|
||||
});
|
||||
fireEvent.click(candidateButton);
|
||||
|
||||
expect(await screen.findByLabelText('JT808 优先级')).toBeDisabled();
|
||||
expect(screen.getByLabelText('JT808 策略备注')).toBeDisabled();
|
||||
expect(screen.getByRole('checkbox', { name: '启用' })).toBeDisabled();
|
||||
expect(screen.getByLabelText('JT808 提供方')).toBeEnabled();
|
||||
fireEvent.change(screen.getByLabelText('JT808 提供方'), { target: { value: '东方北斗' } });
|
||||
fireEvent.change(screen.getByLabelText('JT808 提供方核验依据'), { target: { value: 'GPS 运维终端清单 2026-07-16' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存提供方' }));
|
||||
await waitFor(() => expect(mocks.updateVehicleSourcePolicy).toHaveBeenCalledWith('VIN-CANONICAL', {
|
||||
version: 1,
|
||||
sourceRef: 'b'.repeat(64),
|
||||
providerName: '东方北斗',
|
||||
providerEvidence: 'GPS 运维终端清单 2026-07-16',
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
remark: ''
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -53,8 +53,9 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
|
||||
onSuccess: onSaved
|
||||
});
|
||||
const providerChanged = providerName.trim() !== (source.providerOverride || '');
|
||||
const policyEditable = source.sourceKind !== 'CANONICAL';
|
||||
const policyChanged = enabled !== source.enabled || priority !== source.priority || remark.trim() !== (source.policyRemark || '');
|
||||
const changed = policyChanged || providerChanged;
|
||||
const changed = (policyEditable && 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>
|
||||
@@ -64,12 +65,12 @@ function SourcePolicyRow({ vin, source, diagnostic, editable, onSaved }: {
|
||||
<td><strong>{source.longitude == null || source.latitude == null ? '—' : `${source.longitude.toFixed(6)}, ${source.latitude.toFixed(6)}`}</strong><span>{number(source.speedKmh)} km/h · {number(source.totalMileageKm)} km</span></td>
|
||||
<td className="v2-source-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><span>{source.selectionReason || '等待选举说明'}</span></td>
|
||||
<td className="v2-source-policy-cell">
|
||||
<label><input type="checkbox" checked={enabled} disabled={!editable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} />启用</label>
|
||||
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
|
||||
<label><input type="checkbox" checked={enabled} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} />启用</label>
|
||||
<input aria-label={`${source.sourceLabel} 优先级`} type="number" min="1" max="1000" value={priority} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} />
|
||||
<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>
|
||||
<input className="v2-source-policy-remark-input" aria-label={`${source.sourceLabel} 策略备注`} value={remark} maxLength={200} disabled={!editable || !policyEditable || save.isPending} onChange={(event) => setRemark(event.target.value)} placeholder={policyEditable ? '启停或优先级调整原因(可选)' : '协议融合快照不可调整策略'} />
|
||||
<button type="button" disabled={!editable || !changed || save.isPending || !source.sourceRef || priority < 1 || priority > 1000 || (providerChanged && !providerEvidence.trim())} onClick={() => save.mutate()}>{save.isPending ? '保存中' : policyEditable ? '保存策略' : '保存提供方'}</button>
|
||||
{save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
|
||||
</td>
|
||||
</tr>;
|
||||
|
||||
Reference in New Issue
Block a user