feat(operations): support canonical source providers

This commit is contained in:
lingniu
2026-07-16 22:50:55 +08:00
parent 9a5e6e0c4f
commit 65b4e4f055
17 changed files with 1050 additions and 52 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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。选举还会综合两分钟新鲜度、漂移冲突保护和连续有效样本。",

View File

@@ -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(&currentVersion); 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, &currentPriority, &currentRemark)
if err == nil {
currentEnabled = enabled == 1
} else if err != sql.ErrNoRows {
return VehicleSourcePolicyConfig{}, err
update.VIN, update.Protocol, update.SourceKey,
).Scan(&enabled, &currentPriority, &currentRemark)
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 {

View File

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

View File

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

View File

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