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 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 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 LEFT JOIN platform_vehicle_source_provider provider
ON BINARY provider.vin = BINARY l.vin ON BINARY provider.vin = BINARY s.vin
AND BINARY provider.protocol = BINARY l.protocol AND BINARY provider.protocol = BINARY s.protocol
AND BINARY provider.source_key = BINARY l.source_key AND BINARY provider.source_key = BINARY COALESCE(NULLIF(l.source_key, ''), CONCAT(s.protocol, ':canonical'))
LEFT JOIN ( LEFT JOIN (
SELECT vin, protocol, MAX(daily_mileage_km) AS daily_mileage_km SELECT vin, protocol, MAX(daily_mileage_km) AS daily_mileage_km
FROM vehicle_daily_mileage FROM vehicle_daily_mileage

View File

@@ -229,6 +229,12 @@ func (m *MockStore) VehicleLocationSourceHistory(_ context.Context, vin string)
}, nil }, 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) { func (m *MockStore) VehicleSourcePolicy(_ context.Context, vin string) (VehicleSourcePolicyConfig, error) {
m.sourcePolicyMu.RLock() m.sourcePolicyMu.RLock()
defer m.sourcePolicyMu.RUnlock() 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 providerKey := update.VIN + "\x00" + update.Protocol + "\x00" + update.SourceKey
currentProvider := m.sourceProviders[providerKey] 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 providerChanged := currentProvider != update.ProviderName
if !policyChanged && !providerChanged { if !policyChanged && !providerChanged {
return current, nil return current, nil

View File

@@ -1142,6 +1142,7 @@ type vehicleSourcePolicyStoreUpdate struct {
CurrentPriority int CurrentPriority int
CurrentRemark string CurrentRemark string
CurrentProvider string CurrentProvider string
PolicyConfigurable bool
} }
type vehicleLocationSourceHistory struct { 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) { 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')), rows, err := s.db.QueryContext(ctx, `SELECT l.protocol,
COALESCE(NULLIF(plate, ''), ''), DATE_FORMAT(event_time, '%Y-%m-%d %H:%i:%s'), COALESCE(NULLIF(l.source_key, ''), CONCAT(l.protocol, ':canonical')),
latitude, longitude, speed_kmh, total_mileage_km, soc_percent, COALESCE(NULLIF(l.plate, ''), ''), DATE_FORMAT(l.event_time, '%Y-%m-%d %H:%i:%s'),
DATE_FORMAT(received_at, '%Y-%m-%d %H:%i:%s'), l.latitude, l.longitude, l.speed_kmh, l.total_mileage_km, l.soc_percent,
CASE WHEN received_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 1 ELSE 0 END DATE_FORMAT(l.received_at, '%Y-%m-%d %H:%i:%s'),
FROM vehicle_realtime_location CASE WHEN l.received_at >= DATE_SUB(NOW(), INTERVAL 2 MINUTE) THEN 1 ELSE 0 END,
WHERE vin = ? COALESCE(provider.provider_name, '')
ORDER BY protocol`, vin) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -88,16 +94,19 @@ ORDER BY protocol`, vin)
var eventTime, receivedAt sql.NullString var eventTime, receivedAt sql.NullString
var latitude, longitude, speed, mileage, soc sql.NullFloat64 var latitude, longitude, speed, mileage, soc sql.NullFloat64
var online int var online int
var provider string
if err := rows.Scan( if err := rows.Scan(
&row.Protocol, &row.sourceKey, &plate, &eventTime, &row.Protocol, &row.sourceKey, &plate, &eventTime,
&latitude, &longitude, &speed, &mileage, &soc, &receivedAt, &online, &latitude, &longitude, &speed, &mileage, &soc, &receivedAt, &online, &provider,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
row.SourceLabel = row.Protocol row.ProviderOverride = strings.TrimSpace(provider)
row.SourceLabel = firstNonEmpty(row.ProviderOverride, row.Protocol)
row.SourceKind = "CANONICAL" row.SourceKind = "CANONICAL"
row.SelectedWithinProtocol = true row.SelectedWithinProtocol = true
row.Enabled = true row.Enabled = true
row.Priority = 100
row.Online = online == 1 row.Online = online == 1
row.QualityStatus = "OK" row.QualityStatus = "OK"
row.Longitude = nullFloatPointer(longitude) row.Longitude = nullFloatPointer(longitude)

View File

@@ -13,6 +13,7 @@ type vehicleSourcePolicyStore interface {
VehicleSourcePolicy(context.Context, string) (VehicleSourcePolicyConfig, error) VehicleSourcePolicy(context.Context, string) (VehicleSourcePolicyConfig, error)
SaveVehicleSourcePolicy(context.Context, vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error) SaveVehicleSourcePolicy(context.Context, vehicleSourcePolicyStoreUpdate) (VehicleSourcePolicyConfig, error)
VehicleLocationSourceHistory(context.Context, string) ([]vehicleLocationSourceHistory, error) VehicleLocationSourceHistory(context.Context, string) ([]vehicleLocationSourceHistory, error)
VehicleSourceProvider(context.Context, string, string, string) (string, error)
} }
func sourceReference(vin, protocol, sourceKey string) string { 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 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 { for index := range evidence.LocationSources {
source := &evidence.LocationSources[index] source := &evidence.LocationSources[index]
if !strings.EqualFold(source.SourceKind, "CANONICAL") { if source.sourceKey != "" {
source.SourceRef = sourceReference(evidence.VIN, source.Protocol, source.sourceKey) source.SourceRef = sourceReference(evidence.VIN, source.Protocol, source.sourceKey)
} }
if item, exists := historyBySource[source.Protocol+"\x00"+source.sourceKey]; exists { 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 var selected *VehicleLocationSourceEvidence
for index := range evidence.LocationSources { for index := range evidence.LocationSources {
source := &evidence.LocationSources[index] 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 selected = source
break break
} }
@@ -143,6 +183,15 @@ func (s *Service) UpdateVehicleSourcePolicy(ctx context.Context, vin string, upd
if selected == nil || selected.sourceKey == "" { if selected == nil || selected.sourceKey == "" {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_NOT_FOUND", Message: "当前车辆不存在该来源,请刷新后重试"} 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 providerChanged := strings.TrimSpace(selected.ProviderOverride) != update.ProviderName
if providerChanged && update.ProviderEvidence == "" { if providerChanged && update.ProviderEvidence == "" {
return VehicleSourceDiagnostic{}, clientError{Code: "SOURCE_PROVIDER_REASON_REQUIRED", Message: "维护提供方时必须填写权威资料来源或核验说明"} 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, CurrentPriority: selected.Priority,
CurrentRemark: selected.PolicyRemark, CurrentRemark: selected.PolicyRemark,
CurrentProvider: selected.ProviderOverride, CurrentProvider: selected.ProviderOverride,
PolicyConfigurable: policyConfigurable,
}); err != nil { }); err != nil {
return VehicleSourceDiagnostic{}, err return VehicleSourceDiagnostic{}, err
} }
@@ -183,7 +233,7 @@ func authorizeInternalOperations(ctx context.Context, adminOnly bool) error {
func sourceSelectionReason(source VehicleLocationSourceEvidence) string { func sourceSelectionReason(source VehicleLocationSourceEvidence) string {
if strings.EqualFold(source.SourceKind, "CANONICAL") { if strings.EqualFold(source.SourceKind, "CANONICAL") {
return "这是协议融合结果快照,不是独立终端候选;需要展开实际来源后调整策略。" return "这是协议融合结果快照,不是独立终端候选;可维护经权威资料确认的提供方,但不能从该快照调整终端启停或优先级。"
} }
if !source.Enabled { if !source.Enabled {
return "已由运维策略停用,不参与协议内选举。" return "已由运维策略停用,不参与协议内选举。"
@@ -207,7 +257,7 @@ func recommendationReason(evidence VehicleSourceEvidence) string {
for _, source := range evidence.LocationSources { for _, source := range evidence.LocationSources {
if source.Recommended { if source.Recommended {
if strings.EqualFold(source.SourceKind, "CANONICAL") { if strings.EqualFold(source.SourceKind, "CANONICAL") {
return fmt.Sprintf("当前推荐 %s 的协议融合结果;本次证据未取得可配置的独立终端候选,不能从该快照反推人工优先级。", source.Protocol) return fmt.Sprintf("当前推荐 %s 的协议融合结果;本次证据未取得独立终端候选,只能维护权威提供方,不能从该快照反推人工优先级。", source.Protocol)
} }
return fmt.Sprintf( return fmt.Sprintf(
"当前推荐 %s / %s来源已启用、质量为 %s、协议内选中策略优先级为 %d。选举还会综合两分钟新鲜度、漂移冲突保护和连续有效样本。", "当前推荐 %s / %s来源已启用、质量为 %s、协议内选中策略优先级为 %d。选举还会综合两分钟新鲜度、漂移冲突保护和连续有效样本。",

View File

@@ -31,6 +31,17 @@ ORDER BY protocol, source_key`, vin)
return items, rows.Err() 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) { func (s *ProductionStore) VehicleSourcePolicy(ctx context.Context, vin string) (VehicleSourcePolicyConfig, error) {
config := VehicleSourcePolicyConfig{VIN: vin, Version: 1, UpdatedBy: "system", Audit: []VehicleSourcePolicyAudit{}} config := VehicleSourcePolicyConfig{VIN: vin, Version: 1, UpdatedBy: "system", Audit: []VehicleSourcePolicyAudit{}}
var updatedAt sql.NullString var updatedAt sql.NullString
@@ -95,6 +106,7 @@ WHERE vin = ? FOR UPDATE`, update.VIN).Scan(&currentVersion); err != nil {
currentPriority := update.CurrentPriority currentPriority := update.CurrentPriority
currentRemark := update.CurrentRemark currentRemark := update.CurrentRemark
currentProvider := strings.TrimSpace(update.CurrentProvider) currentProvider := strings.TrimSpace(update.CurrentProvider)
if update.PolicyConfigurable {
var enabled int var enabled int
err = tx.QueryRowContext(ctx, `SELECT enabled, priority, remark err = tx.QueryRowContext(ctx, `SELECT enabled, priority, remark
FROM vehicle_location_source_policy FROM vehicle_location_source_policy
@@ -106,6 +118,7 @@ WHERE vin = ? AND protocol = ? AND source_key = ?`,
} else if err != sql.ErrNoRows { } else if err != sql.ErrNoRows {
return VehicleSourcePolicyConfig{}, err return VehicleSourcePolicyConfig{}, err
} }
}
err = tx.QueryRowContext(ctx, `SELECT provider_name err = tx.QueryRowContext(ctx, `SELECT provider_name
FROM platform_vehicle_source_provider FROM platform_vehicle_source_provider
WHERE vin = ? AND protocol = ? AND source_key = ?`, WHERE vin = ? AND protocol = ? AND source_key = ?`,
@@ -116,9 +129,9 @@ WHERE vin = ? AND protocol = ? AND source_key = ?`,
} else if err != nil { } else if err != nil {
return VehicleSourcePolicyConfig{}, err return VehicleSourcePolicyConfig{}, err
} }
policyChanged := currentEnabled != update.Enabled || policyChanged := update.PolicyConfigurable && (currentEnabled != update.Enabled ||
currentPriority != update.Priority || currentPriority != update.Priority ||
strings.TrimSpace(currentRemark) != update.Remark strings.TrimSpace(currentRemark) != update.Remark)
providerChanged := strings.TrimSpace(currentProvider) != update.ProviderName providerChanged := strings.TrimSpace(currentProvider) != update.ProviderName
if !policyChanged && !providerChanged { if !policyChanged && !providerChanged {
if err := tx.Rollback(); err != nil { if err := tx.Rollback(); err != nil {

View File

@@ -68,6 +68,7 @@ func TestProductionProviderUpdateKeepsPolicyRemarkSeparate(t *testing.T) {
CurrentEnabled: true, CurrentEnabled: true,
CurrentPriority: 20, CurrentPriority: 20,
CurrentRemark: "保持当前优先级", CurrentRemark: "保持当前优先级",
PolicyConfigurable: true,
} }
mock.ExpectBegin() mock.ExpectBegin()
@@ -120,3 +121,156 @@ WHERE vin = ? AND protocol = ? AND source_key = ?`)).
t.Fatal(err) 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))); 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 onSuccess: onSaved
}); });
const providerChanged = providerName.trim() !== (source.providerOverride || ''); const providerChanged = providerName.trim() !== (source.providerOverride || '');
const policyEditable = source.sourceKind !== 'CANONICAL';
const policyChanged = enabled !== source.enabled || priority !== source.priority || remark.trim() !== (source.policyRemark || ''); 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' : ''}> return <tr className={source.recommended ? 'is-recommended' : ''}>
<td><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.sourceKind || '未维护终端'}</span></td> <td><strong>{source.sourceLabel}</strong><span>{source.terminalLabel || source.sourceKind || '未维护终端'}</span></td>
<td><b>{source.protocol}</b><span>{source.selectedWithinProtocol ? '协议内已选' : '协议内候选'}</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><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-reason"><strong>{source.recommended ? '当前推荐' : source.selectedWithinProtocol ? '协议首选' : '备用来源'}</strong><span>{source.selectionReason || '等待选举说明'}</span></td>
<td className="v2-source-policy-cell"> <td className="v2-source-policy-cell">
<label><input type="checkbox" checked={enabled} disabled={!editable || save.isPending} onChange={(event) => setEnabled(event.target.checked)} /></label> <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 || save.isPending} onChange={(event) => setPriority(Number(event.target.value))} /> <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-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-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="启停或优先级调整原因(可选)" /> <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 ? '保存中' : '保存策略'}</button> <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} {save.isError ? <em role="alert">{save.error instanceof Error ? save.error.message : '保存失败'}</em> : null}
</td> </td>
</tr>; </tr>;

View File

@@ -0,0 +1,379 @@
#!/usr/bin/env python3
from __future__ import print_function
import argparse
import csv
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
SOURCE_REF_PATTERN = re.compile(r"^[0-9a-f]{64}$")
VIN_PATTERN = re.compile(r"^[A-Z0-9]{6,64}$")
MAX_ROWS = 500
class ImportError(RuntimeError):
pass
class Client:
def __init__(self, base_url, token, timeout):
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout = timeout
def data(self, method, path, payload=None):
body = None
headers = {"Authorization": "Bearer " + self.token}
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
request = urllib.request.Request(
self.base_url + path,
data=body,
headers=headers,
method=method
)
try:
response = urllib.request.urlopen(request, timeout=self.timeout)
status = response.getcode()
raw = response.read()
except urllib.error.HTTPError as error:
status = error.code
raw = error.read()
if status < 200 or status >= 300:
code = ""
message = "HTTP {0}".format(status)
try:
envelope = json.loads(raw.decode("utf-8"))
code = envelope.get("code") or envelope.get("errorCode") or ""
message = envelope.get("message") or envelope.get("error") or message
except (ValueError, UnicodeDecodeError):
pass
detail = "{0}: {1}".format(code, message) if code else message
raise ImportError("{0} {1} failed: {2}".format(method, path, detail))
try:
envelope = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
raise ImportError("{0} {1} returned invalid JSON".format(method, path))
if not isinstance(envelope, dict) or "data" not in envelope:
raise ImportError("{0} {1} returned no data envelope".format(method, path))
return envelope["data"]
def clean(value):
return (value or "").strip()
def read_rows(path):
rows = []
seen = set()
try:
handle = open(path, "r", encoding="utf-8-sig", newline="")
except (IOError, OSError) as error:
raise ImportError("cannot open CSV: {0}".format(error))
with handle:
reader = csv.DictReader(handle)
required = {"vin", "source_ref", "provider_name", "evidence"}
fields = set(reader.fieldnames or [])
missing = sorted(required - fields)
if missing:
raise ImportError("CSV missing columns: " + ",".join(missing))
for line_number, raw in enumerate(reader, 2):
vin = clean(raw.get("vin")).upper()
source_ref = clean(raw.get("source_ref")).lower()
provider_name = clean(raw.get("provider_name"))
evidence = clean(raw.get("evidence"))
expected_protocol = clean(raw.get("expected_protocol")).upper()
expected_current_provider = clean(raw.get("expected_current_provider"))
if not any((vin, source_ref, provider_name, evidence, expected_protocol, expected_current_provider)):
continue
if not VIN_PATTERN.match(vin):
raise ImportError("line {0}: invalid VIN".format(line_number))
if not SOURCE_REF_PATTERN.match(source_ref):
raise ImportError("line {0}: source_ref must be a 64-character lowercase SHA-256 value".format(line_number))
if not provider_name:
raise ImportError("line {0}: provider_name is required".format(line_number))
if len(provider_name) > 128:
raise ImportError("line {0}: provider_name exceeds 128 characters".format(line_number))
if not evidence:
raise ImportError("line {0}: evidence is required".format(line_number))
if len(evidence) > 255:
raise ImportError("line {0}: evidence exceeds 255 characters".format(line_number))
key = (vin, source_ref)
if key in seen:
raise ImportError("line {0}: duplicate VIN/source_ref".format(line_number))
seen.add(key)
rows.append({
"line": line_number,
"vin": vin,
"sourceRef": source_ref,
"providerName": provider_name,
"evidence": evidence,
"expectedProtocol": expected_protocol,
"expectedCurrentProvider": expected_current_provider
})
if len(rows) > MAX_ROWS:
raise ImportError("CSV exceeds the {0}-row safety limit".format(MAX_ROWS))
if not rows:
raise ImportError("CSV contains no provider rows")
return rows
def diagnostic_path(vin):
return "/api/v2/operations/vehicles/{0}/sources".format(urllib.parse.quote(vin, safe=""))
def update_path(vin, source_ref):
return "/api/v2/operations/vehicles/{0}/sources/{1}".format(
urllib.parse.quote(vin, safe=""),
urllib.parse.quote(source_ref, safe="")
)
def sources(diagnostic):
evidence = diagnostic.get("evidence") if isinstance(diagnostic, dict) else None
result = evidence.get("locationSources") if isinstance(evidence, dict) else None
return result if isinstance(result, list) else []
def find_source(diagnostic, row):
for source in sources(diagnostic):
if clean(source.get("sourceRef")).lower() == row["sourceRef"]:
return source
raise ImportError(
"line {0}: source_ref no longer exists for VIN {1}".format(row["line"], row["vin"])
)
def policy_version(diagnostic, row):
policy = diagnostic.get("policy") if isinstance(diagnostic, dict) else None
version = policy.get("version") if isinstance(policy, dict) else None
if not isinstance(version, int) or version < 1:
raise ImportError(
"line {0}: invalid source policy version for VIN {1}".format(row["line"], row["vin"])
)
return version
def effective_provider(source):
return clean(source.get("providerOverride")) or clean(source.get("sourceLabel"))
def validate_source(row, source):
source_protocol = clean(source.get("protocol")).upper()
if row["expectedProtocol"] and source_protocol != row["expectedProtocol"]:
raise ImportError(
"line {0}: protocol changed from {1} to {2}".format(
row["line"], row["expectedProtocol"], source_protocol or "-"
)
)
current_provider = effective_provider(source)
if row["expectedCurrentProvider"] and current_provider != row["expectedCurrentProvider"]:
raise ImportError(
"line {0}: current provider changed from {1} to {2}".format(
row["line"], row["expectedCurrentProvider"], current_provider or "-"
)
)
priority = source.get("priority")
if not isinstance(priority, int) or priority < 1 or priority > 1000:
raise ImportError("line {0}: current priority is invalid".format(row["line"]))
if not isinstance(source.get("enabled"), bool):
raise ImportError("line {0}: current enabled state is invalid".format(row["line"]))
def preflight(client, rows):
diagnostics = {}
changes = 0
for row in rows:
vin = row["vin"]
if vin not in diagnostics:
diagnostics[vin] = client.data("GET", diagnostic_path(vin))
policy_version(diagnostics[vin], row)
source = find_source(diagnostics[vin], row)
validate_source(row, source)
if clean(source.get("providerOverride")) != row["providerName"]:
changes += 1
return diagnostics, changes
def export_missing(client, path):
vehicles = []
offset = 0
total = None
while total is None or offset < total:
page = client.data("POST", "/api/v2/access/vehicles", payload={
"connectionState": "master_data",
"limit": 200,
"offset": offset
})
if not isinstance(page, dict) or not isinstance(page.get("items"), list):
raise ImportError("access vehicle query returned an invalid page")
if total is None:
total = page.get("total")
if not isinstance(total, int) or total < 0:
raise ImportError("access vehicle query returned an invalid total")
vehicles.extend(page["items"])
if not page["items"]:
break
offset += len(page["items"])
rows = []
for vehicle in vehicles:
issues = vehicle.get("masterDataIssues") or []
if "JT808 接入方未维护" not in issues:
continue
vin = clean(vehicle.get("vin")).upper()
if not VIN_PATTERN.match(vin):
raise ImportError("access vehicle query returned an invalid VIN")
diagnostic = client.data("GET", diagnostic_path(vin))
for source in sources(diagnostic):
if clean(source.get("protocol")).upper() != "JT808":
continue
if clean(source.get("providerOverride")):
continue
current_provider = effective_provider(source)
source_ref = clean(source.get("sourceRef")).lower()
if not SOURCE_REF_PATTERN.match(source_ref):
raise ImportError("diagnostic returned an invalid source_ref for VIN " + vin)
rows.append({
"vin": vin,
"plate": clean(vehicle.get("plate")),
"source_ref": source_ref,
"terminal_label": clean(source.get("terminalLabel")),
"expected_protocol": "JT808",
"expected_current_provider": current_provider,
"provider_name": "",
"evidence": ""
})
if len(rows) > MAX_ROWS:
raise ImportError("missing provider export exceeds the {0}-row safety limit".format(MAX_ROWS))
if not rows:
raise ImportError("no JT808 provider gaps were found")
fieldnames = [
"vin", "plate", "source_ref", "terminal_label",
"expected_protocol", "expected_current_provider", "provider_name", "evidence"
]
try:
handle = open(path, "x", encoding="utf-8-sig", newline="")
except FileExistsError:
raise ImportError("export path already exists: " + path)
except (IOError, OSError) as error:
raise ImportError("cannot create export CSV: {0}".format(error))
with handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return len(rows), len(set(row["vin"] for row in rows))
def apply_rows(client, rows, diagnostics):
updated = 0
unchanged = 0
for row in rows:
diagnostic = diagnostics[row["vin"]]
source = find_source(diagnostic, row)
validate_source(row, source)
if clean(source.get("providerOverride")) == row["providerName"]:
unchanged += 1
continue
payload = {
"version": policy_version(diagnostic, row),
"providerName": row["providerName"],
"providerEvidence": row["evidence"],
"enabled": source["enabled"],
"priority": source["priority"],
"remark": clean(source.get("policyRemark"))
}
diagnostic = client.data(
"PUT",
update_path(row["vin"], row["sourceRef"]),
payload=payload
)
diagnostics[row["vin"]] = diagnostic
saved = find_source(diagnostic, row)
if clean(saved.get("providerOverride")) != row["providerName"]:
raise ImportError(
"line {0}: server did not persist provider for VIN {1}".format(row["line"], row["vin"])
)
updated += 1
print(
"source_provider_row=updated vin={0} source_ref={1} provider={2}".format(
row["vin"], row["sourceRef"][:12], row["providerName"]
)
)
return updated, unchanged
def parser():
value = argparse.ArgumentParser(
description="Preflight and apply an authoritative source-provider CSV through the audited admin API."
)
mode = value.add_mutually_exclusive_group(required=True)
mode.add_argument("--csv", help="UTF-8 CSV with vin,source_ref,provider_name,evidence")
mode.add_argument(
"--export-missing",
metavar="PATH",
help="Write a fillable CSV for current JT808 provider gaps; refuses to overwrite an existing file."
)
value.add_argument("--base-url", default="http://127.0.0.1:20300")
value.add_argument("--token-env", default="SOURCE_PROVIDER_ADMIN_TOKEN")
value.add_argument("--timeout", type=float, default=20.0)
value.add_argument(
"--apply",
action="store_true",
help="Apply changes after a full preflight. Without this flag the command is read-only."
)
return value
def main(argv=None):
args = parser().parse_args(argv)
token = os.environ.get(args.token_env, "")
if not token:
print("source_provider_import=failed reason=token_environment_missing", file=sys.stderr)
return 2
try:
client = Client(args.base_url, token, args.timeout)
if args.export_missing:
if args.apply:
raise ImportError("--apply cannot be used with --export-missing")
row_count, vehicle_count = export_missing(client, args.export_missing)
print(
"source_provider_export=ok rows={0} vehicles={1} path={2}".format(
row_count, vehicle_count, args.export_missing
)
)
return 0
rows = read_rows(args.csv)
diagnostics, changes = preflight(client, rows)
unchanged = len(rows) - changes
if not args.apply:
print(
"source_provider_import=ready rows={0} vehicles={1} changes={2} unchanged={3} mode=dry-run".format(
len(rows), len(diagnostics), changes, unchanged
)
)
return 0
updated, unchanged = apply_rows(client, rows, diagnostics)
except ImportError as error:
print(
"source_provider_import=failed reason={0}".format(str(error).replace("\n", " ")),
file=sys.stderr
)
return 1
print(
"source_provider_import=ok rows={0} vehicles={1} updated={2} unchanged={3}".format(
len(rows), len(diagnostics), updated, unchanged
)
)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,211 @@
#!/usr/bin/env python3
from __future__ import print_function
import csv
import json
import os
import subprocess
import sys
import tempfile
import threading
import unittest
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import urlparse
VIN = "LB9A32A21R0LS1464"
SOURCE_REF = "a" * 64
def diagnostic(version=1, provider=""):
return {
"evidence": {
"vin": VIN,
"locationSources": [{
"protocol": "JT808",
"sourceLabel": provider or "接入方未维护",
"providerOverride": provider,
"sourceRef": SOURCE_REF,
"sourceKind": "PLATFORM",
"enabled": True,
"priority": 88,
"policyRemark": "保留策略备注"
}]
},
"policy": {"vin": VIN, "version": version, "audit": []}
}
class Handler(BaseHTTPRequestHandler):
puts = []
def log_message(self, _format, *_args):
pass
def envelope(self, data, status=200):
body = json.dumps({"data": data, "traceId": "test", "timestamp": 1}).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self.assert_auth()
if urlparse(self.path).path == "/api/v2/operations/vehicles/{0}/sources".format(VIN):
self.envelope(diagnostic())
return
self.envelope({}, 404)
def do_POST(self):
self.assert_auth()
if urlparse(self.path).path == "/api/v2/access/vehicles":
size = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(size).decode("utf-8"))
self.envelope({
"items": [{
"vin": VIN,
"plate": "粤AGR6863",
"masterDataIssues": ["JT808 接入方未维护"]
}],
"total": 1,
"limit": payload.get("limit"),
"offset": payload.get("offset")
})
return
self.envelope({}, 404)
def do_PUT(self):
self.assert_auth()
expected = "/api/v2/operations/vehicles/{0}/sources/{1}".format(VIN, SOURCE_REF)
if urlparse(self.path).path != expected:
self.envelope({}, 404)
return
size = int(self.headers.get("Content-Length", "0"))
payload = json.loads(self.rfile.read(size).decode("utf-8"))
Handler.puts.append(payload)
self.envelope(diagnostic(version=2, provider=payload.get("providerName", "")))
def assert_auth(self):
if self.headers.get("Authorization") != "Bearer admin-test-token":
self.envelope({}, 401)
raise RuntimeError("missing test authorization")
class SourceProviderImportTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server = HTTPServer(("127.0.0.1", 0), Handler)
cls.thread = threading.Thread(target=cls.server.serve_forever)
cls.thread.daemon = True
cls.thread.start()
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
cls.thread.join()
def setUp(self):
Handler.puts = []
self.tempdir = tempfile.TemporaryDirectory()
def tearDown(self):
self.tempdir.cleanup()
def write_csv(self, evidence="GPS 运维终端清单 2026-07-16", expected_provider="接入方未维护"):
path = Path(self.tempdir.name) / "providers.csv"
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=[
"vin", "source_ref", "provider_name", "evidence",
"expected_protocol", "expected_current_provider"
])
writer.writeheader()
writer.writerow({
"vin": VIN,
"source_ref": SOURCE_REF,
"provider_name": "G7s",
"evidence": evidence,
"expected_protocol": "JT808",
"expected_current_provider": expected_provider
})
return str(path)
def run_script(self, csv_path, apply=False):
script = str(Path(__file__).with_name("import-source-providers.py"))
environment = dict(os.environ)
environment["SOURCE_PROVIDER_ADMIN_TOKEN"] = "admin-test-token"
command = [
sys.executable,
script,
"--csv", csv_path,
"--base-url", "http://127.0.0.1:{0}".format(self.server.server_port)
]
if apply:
command.append("--apply")
return subprocess.run(
command,
env=environment,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True
)
def test_dry_run_preflights_without_writing(self):
result = self.run_script(self.write_csv())
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("source_provider_import=ready", result.stdout)
self.assertIn("changes=1", result.stdout)
self.assertEqual(Handler.puts, [])
def test_apply_preserves_policy_and_sends_evidence(self):
result = self.run_script(self.write_csv(), apply=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("source_provider_import=ok", result.stdout)
self.assertEqual(len(Handler.puts), 1)
self.assertEqual(Handler.puts[0], {
"version": 1,
"providerName": "G7s",
"providerEvidence": "GPS 运维终端清单 2026-07-16",
"enabled": True,
"priority": 88,
"remark": "保留策略备注"
})
def test_missing_evidence_fails_before_any_write(self):
result = self.run_script(self.write_csv(evidence=""), apply=True)
self.assertEqual(result.returncode, 1)
self.assertIn("evidence is required", result.stderr)
self.assertEqual(Handler.puts, [])
def test_stale_expected_provider_fails_preflight(self):
result = self.run_script(self.write_csv(expected_provider="旧提供方"), apply=True)
self.assertEqual(result.returncode, 1)
self.assertIn("current provider changed", result.stderr)
self.assertEqual(Handler.puts, [])
def test_export_missing_writes_fillable_template(self):
script = str(Path(__file__).with_name("import-source-providers.py"))
environment = dict(os.environ)
environment["SOURCE_PROVIDER_ADMIN_TOKEN"] = "admin-test-token"
path = str(Path(self.tempdir.name) / "missing.csv")
result = subprocess.run([
sys.executable,
script,
"--export-missing", path,
"--base-url", "http://127.0.0.1:{0}".format(self.server.server_port)
], env=environment, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("source_provider_export=ok rows=1 vehicles=1", result.stdout)
with open(path, "r", encoding="utf-8-sig", newline="") as handle:
rows = list(csv.DictReader(handle))
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0]["vin"], VIN)
self.assertEqual(rows[0]["source_ref"], SOURCE_REF)
self.assertEqual(rows[0]["terminal_label"], "")
self.assertEqual(rows[0]["provider_name"], "")
if __name__ == "__main__":
unittest.main()

View File

@@ -32,6 +32,7 @@ fi
test -x "$SCRIPT_DIR/prepare-web-release-tree.sh" || { printf 'release tree helper is missing\n' >&2; exit 1; } test -x "$SCRIPT_DIR/prepare-web-release-tree.sh" || { printf 'release tree helper is missing\n' >&2; exit 1; }
test -f "$SCRIPT_DIR/prune-release-history.py" || { printf 'release pruning helper is missing\n' >&2; exit 1; } test -f "$SCRIPT_DIR/prune-release-history.py" || { printf 'release pruning helper is missing\n' >&2; exit 1; }
test -f "$SCRIPT_DIR/verify-customer-demo.py" || { printf 'customer demo gate is missing\n' >&2; exit 1; } test -f "$SCRIPT_DIR/verify-customer-demo.py" || { printf 'customer demo gate is missing\n' >&2; exit 1; }
test -f "$SCRIPT_DIR/import-source-providers.py" || { printf 'source provider import helper is missing\n' >&2; exit 1; }
old=$(readlink -f "$ROOT/current") old=$(readlink -f "$ROOT/current")
next="$ROOT/releases/$RELEASE_ID" next="$ROOT/releases/$RELEASE_ID"
@@ -110,8 +111,9 @@ fi
cp "$SCRIPT_DIR/prepare-web-release-tree.sh" "$next/deploy/prepare-web-release-tree.sh" cp "$SCRIPT_DIR/prepare-web-release-tree.sh" "$next/deploy/prepare-web-release-tree.sh"
cp "$SCRIPT_DIR/prune-release-history.py" "$next/deploy/prune-release-history.py" cp "$SCRIPT_DIR/prune-release-history.py" "$next/deploy/prune-release-history.py"
cp "$SCRIPT_DIR/verify-customer-demo.py" "$next/deploy/verify-customer-demo.py" cp "$SCRIPT_DIR/verify-customer-demo.py" "$next/deploy/verify-customer-demo.py"
cp "$SCRIPT_DIR/import-source-providers.py" "$next/deploy/import-source-providers.py"
cp "$0" "$next/deploy/install-web-release.sh" cp "$0" "$next/deploy/install-web-release.sh"
chmod +x "$next/platform-api" "$next/deploy/"*.sh "$next/deploy/prune-release-history.py" "$next/deploy/verify-customer-demo.py" chmod +x "$next/platform-api" "$next/deploy/"*.sh "$next/deploy/prune-release-history.py" "$next/deploy/verify-customer-demo.py" "$next/deploy/import-source-providers.py"
for runtime_file in alert-evaluator alert-stream-evaluator platform-migrate oneos-scope-sync reconciliation-evaluator alert-benchmark; do for runtime_file in alert-evaluator alert-stream-evaluator platform-migrate oneos-scope-sync reconciliation-evaluator alert-benchmark; do
test ! -f "$next/$runtime_file" || chmod +x "$next/$runtime_file" test ! -f "$next/$runtime_file" || chmod +x "$next/$runtime_file"
done done

View File

@@ -46,6 +46,7 @@ test -f "$root/current/web/assets/new.js"
test -f "$root/current/web/assets/old.js" test -f "$root/current/web/assets/old.js"
test -f "$root/current/web/.compatibility-manifests/1.assets" test -f "$root/current/web/.compatibility-manifests/1.assets"
test -x "$root/current/deploy/verify-customer-demo.py" test -x "$root/current/deploy/verify-customer-demo.py"
test -x "$root/current/deploy/import-source-providers.py"
test -f "$root/current/deploy/migrations/017_reconciliation_center.sql" test -f "$root/current/deploy/migrations/017_reconciliation_center.sql"
test -f "$root/current/deploy/systemd/lingniu-vehicle-reconciliation-evaluator.timer" test -f "$root/current/deploy/systemd/lingniu-vehicle-reconciliation-evaluator.timer"
test ! -e "$root/current/lingniu-vehicle-platform.service" test ! -e "$root/current/lingniu-vehicle-platform.service"

View File

@@ -341,7 +341,7 @@ Content-Type: application/json
} }
``` ```
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. 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. A connected protocol that currently has no independent location candidate is represented by a `CANONICAL` protocol snapshot with an opaque `sourceRef`, so its verified provider can still be maintained. Canonical snapshots are provider-only targets: attempts to change `enabled`, `priority` or `remark` return `SOURCE_POLICY_CANONICAL_READ_ONLY`. 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`. `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`.

View File

@@ -158,6 +158,8 @@ Production smoke must create an export for an active VIN, wait for `completed`,
Before a customer demonstration, run `deploy/verify-customer-demo.py` with the password supplied only through `DEMO_PASSWORD`. The gate logs in as the actual customer, requires the exact four customer menus, compares the complete realtime VIN set with the approved cohort, verifies representative source/location states, confirms an out-of-Scope VIN cannot resolve, checks optional track and mileage evidence, loads the four customer SPA routes and logs out. The script never creates an account, changes grants or persists the password; account identity and VIN Scope must be approved separately. See `docs/customer-demo-acceptance.md`. Before a customer demonstration, run `deploy/verify-customer-demo.py` with the password supplied only through `DEMO_PASSWORD`. The gate logs in as the actual customer, requires the exact four customer menus, compares the complete realtime VIN set with the approved cohort, verifies representative source/location states, confirms an out-of-Scope VIN cannot resolve, checks optional track and mileage evidence, loads the four customer SPA routes and logs out. The script never creates an account, changes grants or persists the password; account identity and VIN Scope must be approved separately. See `docs/customer-demo-acceptance.md`.
For an authoritative JT808 provider list, use `deploy/import-source-providers.py`. The command reads its administrator credential only from `SOURCE_PROVIDER_ADMIN_TOKEN`. `--export-missing <new.csv>` produces an Excel-compatible, masked template for current provider gaps and refuses to overwrite an existing path. A filled CSV must first pass the default read-only preflight; add `--apply` only after reviewing the `ready` summary. The importer validates every row before the first write, preserves the current enabled state, priority and policy remark, and updates only provider/evidence through the audited optimistic-version API. Protocol-level canonical snapshots receive an opaque provider reference even when no independent location source exists, but their terminal policy fields remain read-only. The importer stops on source/protocol/current-provider drift and never accepts a missing evidence reference. See `docs/meeting-goal-external-input-handoff.md`.
`AUTH_MODE=enforce` is mandatory on ECS. Tokens are stored as SHA-256 comparisons in memory and sent as Bearer credentials; the browser stores the entered token only in `sessionStorage`. Roles are cumulative: `AUTH_MODE=enforce` is mandatory on ECS. Tokens are stored as SHA-256 comparisons in memory and sent as Bearer credentials; the browser stores the entered token only in `sessionStorage`. Roles are cumulative:
| Role | Permissions | | Role | Permissions |

View File

@@ -0,0 +1,105 @@
# 0716 会议 Goal 外部输入交接单
更新时间2026-07-16 22:49Asia/Shanghai
本交接单只记录开发侧无法推断、且会改变真实客户可见范围或主数据事实的输入。收到资料后按本文验证,不修改 `ln-asset-management`
## 1. 当前生产阻塞快照
| 待办 | 当前生产事实 | 缺少的权威输入 | 输入责任方 |
| --- | --- | --- | --- |
| P0-08 客户账号与演示 | 目前共 2 个账号1 个管理员、1 个既有客户;既有客户严格四菜单并授权 1 辆车 | 秦总、蒲总各自登录名/手机号、显示名、车辆 VIN Scope、初始密码安全交付方式可选客户/租户映射 | 业务负责人 |
| P1-02 接入资料校准 | 1,024 辆权威车辆中101 辆仍有 `JT808 接入方未维护`8 辆仍有 `车辆品牌未维护` | 当前终端/手机号或协议接入到 JT808 提供方的权威清单8 辆车的品牌主数据及来源依据 | GPS 运维/厂家、资产资料责任人 |
| P1-04 OneOS 正式接口 | ECS 未配置 `ONEOS_SCOPE_SOURCE`、正式 URL、Service Token、HMAC 密钥;同步定时器未安装 | 正式内网 URL、独立 Service Token、独立 HMAC 密钥、ECS 私网源地址白名单、完整快照 | OneOS 团队 |
禁止用终端号规律、来源 IP、旧里程来源、注册报文厂商码或历史聚合值猜测当前提供方禁止为了演示倒签车辆授权起点。
## 2. P0-08 账号输入格式
每个账号至少提供:
```text
loginName:
displayName:
vehicleVins:
passwordDeliveryMethod:
customerRef: # 可选
tenantRef: # 可选
```
固定菜单只能是:
```text
monitor,vehicles,tracks,statistics
```
初始密码不得写入 Git、会议纪要或本交接单应通过独立安全渠道交付。账号建立后分别运行 `deploy/verify-customer-demo.py`,并为两个账号各选择一个对方未授权 VIN 验证越权阻断。
## 3. P1-02 提供方清单导出与导入
生产 release `canonical-provider-access-fallback-20260716224816` 内置 `deploy/import-source-providers.py`。令牌只通过临时环境变量传入不写命令行、CSV 或日志。
先导出当前待补清单:
```bash
read -s SOURCE_PROVIDER_ADMIN_TOKEN
export SOURCE_PROVIDER_ADMIN_TOKEN
/opt/lingniu-vehicle-platform/current/deploy/import-source-providers.py \
--base-url http://127.0.0.1:20300 \
--export-missing /root/jt808-provider-gaps-20260716.csv
```
导出文件是 UTF-8 BOM CSV适合 Excel 打开,包含:
- `vin``plate`
- 不可逆的 `source_ref`
- 脱敏终端标签;若网关只保留协议接入快照而没有独立位置来源,该列为空,仍可按车辆/协议维护提供方;
- 预期协议和当前提供方,用于防止清单处理期间来源漂移;
- 待填写的 `provider_name``evidence`
GPS 运维或厂家必须为每行填写规范提供方名称和可追溯依据。依据可以是权威终端清单版本、厂家确认记录编号等,不能只写“已确认”。
导入前先只读预检:
```bash
/opt/lingniu-vehicle-platform/current/deploy/import-source-providers.py \
--base-url http://127.0.0.1:20300 \
--csv /root/jt808-provider-gaps-20260716-filled.csv
```
只有输出 `source_provider_import=ready` 后才执行:
```bash
/opt/lingniu-vehicle-platform/current/deploy/import-source-providers.py \
--base-url http://127.0.0.1:20300 \
--csv /root/jt808-provider-gaps-20260716-filled.csv \
--apply
unset SOURCE_PROVIDER_ADMIN_TOKEN
```
工具会在任何写入前完成全表预检,并逐条复用当前启停、优先级和策略备注,只修改提供方及其独立核验依据。每次修改继续走管理员 API、乐观版本和不可变审计单批最多 500 行。协议级融合快照只能维护提供方,前后端均禁止从快照修改终端启停、优先级或策略备注。若中途发生并发版本变化,工具立即停止,已成功项保留审计,剩余项需重新导出/预检。
品牌缺口必须先在 OneOS/资产主数据中取得正式品牌事实,再按现有主数据同步路径进入中台;不得直接猜测或覆盖已有品牌。
## 4. P1-04 OneOS 交付检查
OneOS 交付时必须同时确认:
1. 内网 URL 与路径;
2. Service Token 和 HMAC 密钥互相独立;
3. OneOS 网关只允许 ECS 指定私网源地址;
4. 时间戳窗口不超过 60 秒request ID 防重放;
5. 分页内 `scopeVersion``generatedAt` 稳定且 `complete=true`
6. 返回客户、部门、负责人、合同/项目、正式交车启用时间和机器可读隔离原因;
7. 草稿交车/还车时间不作为正式授权事实。
配置密钥后先手工执行一次影子同步,核对总数、抽样车辆、拒绝项和内容版本。验证通过前不得安装或启用两分钟同步定时器;失败时必须保持当前投影,不得退化为全量车辆。
## 5. Goal 解除阻塞的验收证据
- 两个实际客户账号分别通过四菜单、车辆集合、授权时间、轨迹、里程和对方 VIN 越权门禁;
- 接入管理中 `JT808 接入方未维护=0``车辆品牌未维护=0`,每次补录可追溯;
- OneOS 正式快照完成手工影子校验并启用定时同步,接口故障演练证明失败关闭;
- 业务负责人完成客户演示并确认首期交付范围。

View File

@@ -292,7 +292,7 @@
已完成准备: 已完成准备:
- 演示门禁已随 release `customer-demo-gate-20260716172056` 进入 ECS 不可变发布目录Python 3.6 编译、帮助入口、鉴权健康与三个服务状态烟测通过。 - 演示门禁已随 release `customer-demo-gate-20260716172056` 进入 ECS 不可变发布目录Python 3.6 编译、帮助入口、鉴权健康与三个服务状态烟测通过。
- 生产只读核确认当前只有管理员和 1 个既有客户账号尚无能明确对应秦总、蒲总的两个账号;未修改既有账号和车辆 Scope。 - 2026-07-16 20:04 生产只读核确认当前共 2 个账号:管理员和 1 个既有客户账号;既有客户严格只有四个客户菜单并授权 1 辆车。尚无能明确对应秦总、蒲总的两个账号;未修改既有账号和车辆 Scope。
- 已筛选在线多来源、在线单来源、无位置、离线有位置四类真实演示样本,并验证三台有历史能力的车辆具备当天轨迹和近 7 天里程。 - 已筛选在线多来源、在线单来源、无位置、离线有位置四类真实演示样本,并验证三台有历史能力的车辆具备当天轨迹和近 7 天里程。
- 新增无明文密码落盘的客户演示门禁,可验证客户角色、严格四菜单、车辆集合、四类样本状态、越权 VIN 阻断、轨迹、里程和四个 SPA 路由;脚本及测试兼容 ECS Python 3.6.8。 - 新增无明文密码落盘的客户演示门禁,可验证客户角色、严格四菜单、车辆集合、四类样本状态、越权 VIN 阻断、轨迹、里程和四个 SPA 路由;脚本及测试兼容 ECS Python 3.6.8。
- 已形成 `docs/customer-demo-acceptance.md`,记录授权时间边界、建议样本、演示顺序、成功证据和现场问题模板。 - 已形成 `docs/customer-demo-acceptance.md`,记录授权时间边界、建议样本、演示顺序、成功证据和现场问题模板。
@@ -384,9 +384,18 @@
- 生产烟测以 `LB9A32A21R0LS1464` 的既有 `g7s` 识别结果写入同义规范值 `G7s`:策略版本 `v1→v2`,提供方审计包含核验依据,原策略备注保持不变,接入明细立即显示 `JT808 / G7s`,响应中不存在 `sourceKey`。平台、两类告警评估器和对账定时器均为 active。 - 生产烟测以 `LB9A32A21R0LS1464` 的既有 `g7s` 识别结果写入同义规范值 `G7s`:策略版本 `v1→v2`,提供方审计包含核验依据,原策略备注保持不变,接入明细立即显示 `JT808 / G7s`,响应中不存在 `sourceKey`。平台、两类告警评估器和对账定时器均为 active。
- Go 全量测试、前端 49 个文件/259 项测试、TypeScript/Vite 生产构建和 23 个当前 Web 资源门禁通过。 - Go 全量测试、前端 49 个文件/259 项测试、TypeScript/Vite 生产构建和 23 个当前 Web 资源门禁通过。
权威清单批量落库准备:
- 新增 `deploy/import-source-providers.py`,可从生产按当前资料缺口导出带 VIN、车牌、不可逆 `sourceRef`、脱敏终端和防漂移字段的 UTF-8 CSV 模板。
- 填写后的清单先完成全表只读预检,再通过管理员 API 逐条应用;工具复用当前启停、优先级和策略备注,只修改提供方及独立核验依据,继续使用乐观版本和不可变审计。
- 管理员令牌只从临时环境变量读取,单批最多 500 行;导出拒绝覆盖已有文件,导入拒绝缺依据、重复来源、协议变化、当前提供方变化和失效 `sourceRef`
- Python 自动化覆盖只读预检、成功写入、策略字段保持、缺依据失败、并发前置值漂移失败和缺口模板导出;发布安装器同步携带该工具。
- 生产只读烟测发现缺口车辆当前只有协议融合快照、没有独立终端候选release `canonical-provider-access-fallback-20260716224816` 已为协议级来源生成不可逆 `sourceRef`。管理员可维护权威提供方,但前后端均禁止从融合快照修改终端启停、优先级或策略备注。
- 生产导出与接入管理逐车集合一致:当前生成 101 行/101 辆缺口模板,全部通过真实 CSV 防漂移只读预检;未执行 `--apply`,没有写入任何未经确认的提供方。
剩余业务资料依赖: 剩余业务资料依赖:
- 当前动态复核仍有 104 辆 JT808 提供方缺失。注册报文厂商码在生产中同时对应 G7s、东方北斗和赛格不能唯一映射OneOS 只标记为“氢气智能管理平台”,这是聚合来源而非终端提供方。 - 2026-07-16 22:49 动态复核仍有 101 辆 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 年当前终端提供方。 - 进一步只读核对 `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、位置、旧里程来源或厂商码猜测提供方。 - 因此这 104 辆继续留在待处理队列,需 GPS 运维/厂家提供当前 phone/终端到提供方的权威清单。平台不会根据终端号、IP、位置、旧里程来源或厂商码猜测提供方。
- 另有 8 辆 OneOS 车型主数据本身无品牌,需资产资料责任人补录后再通过正式 API 同步。 - 另有 8 辆 OneOS 车型主数据本身无品牌,需资产资料责任人补录后再通过正式 API 同步。
@@ -450,6 +459,7 @@
- 正式内网 URL、独立服务令牌、HMAC 密钥和允许访问的 ECS 私网源地址; - 正式内网 URL、独立服务令牌、HMAC 密钥和允许访问的 ECS 私网源地址;
- 符合契约的全客户完整快照,包含稳定版本、客户/部门/负责人、合同/项目、正式交车启用时间及机器可读隔离原因; - 符合契约的全客户完整快照,包含稳定版本、客户/部门/负责人、合同/项目、正式交车启用时间及机器可读隔离原因;
- OneOS 侧完成时间戳窗口、request ID 去重、来源限制和接口监控。取得后先手工影子同步、核对抽样与总数,再启用两分钟定时器。 - OneOS 侧完成时间戳窗口、request ID 去重、来源限制和接口监控。取得后先手工影子同步、核对抽样与总数,再启用两分钟定时器。
- 2026-07-16 20:04 ECS 复核确认 `ONEOS_SCOPE_SOURCE``ONEOS_SCOPE_API_URL``ONEOS_SCOPE_API_SERVICE_TOKEN``ONEOS_SCOPE_API_SIGNING_SECRET` 均未配置OneOS 同步 timer unit 未安装;当前保持失败关闭。
目标: 目标: