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 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1134,14 +1134,15 @@ type VehicleSourcePolicyUpdate struct {
|
|||||||
|
|
||||||
type vehicleSourcePolicyStoreUpdate struct {
|
type vehicleSourcePolicyStoreUpdate struct {
|
||||||
VehicleSourcePolicyUpdate
|
VehicleSourcePolicyUpdate
|
||||||
VIN string
|
VIN string
|
||||||
Protocol string
|
Protocol string
|
||||||
SourceKey string
|
SourceKey string
|
||||||
SourceLabel string
|
SourceLabel string
|
||||||
CurrentEnabled bool
|
CurrentEnabled bool
|
||||||
CurrentPriority int
|
CurrentPriority int
|
||||||
CurrentRemark string
|
CurrentRemark string
|
||||||
CurrentProvider string
|
CurrentProvider string
|
||||||
|
PolicyConfigurable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type vehicleLocationSourceHistory struct {
|
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) {
|
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)
|
||||||
|
|||||||
@@ -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。选举还会综合两分钟新鲜度、漂移冲突保护和连续有效样本。",
|
||||||
|
|||||||
@@ -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,16 +106,18 @@ WHERE vin = ? FOR UPDATE`, update.VIN).Scan(¤tVersion); err != nil {
|
|||||||
currentPriority := update.CurrentPriority
|
currentPriority := update.CurrentPriority
|
||||||
currentRemark := update.CurrentRemark
|
currentRemark := update.CurrentRemark
|
||||||
currentProvider := strings.TrimSpace(update.CurrentProvider)
|
currentProvider := strings.TrimSpace(update.CurrentProvider)
|
||||||
var enabled int
|
if update.PolicyConfigurable {
|
||||||
err = tx.QueryRowContext(ctx, `SELECT enabled, priority, remark
|
var enabled int
|
||||||
|
err = tx.QueryRowContext(ctx, `SELECT enabled, priority, remark
|
||||||
FROM vehicle_location_source_policy
|
FROM vehicle_location_source_policy
|
||||||
WHERE vin = ? AND protocol = ? AND source_key = ?`,
|
WHERE vin = ? AND protocol = ? AND source_key = ?`,
|
||||||
update.VIN, update.Protocol, update.SourceKey,
|
update.VIN, update.Protocol, update.SourceKey,
|
||||||
).Scan(&enabled, ¤tPriority, ¤tRemark)
|
).Scan(&enabled, ¤tPriority, ¤tRemark)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
currentEnabled = enabled == 1
|
currentEnabled = enabled == 1
|
||||||
} 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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -61,13 +61,14 @@ func TestProductionProviderUpdateKeepsPolicyRemarkSeparate(t *testing.T) {
|
|||||||
Remark: "保持当前优先级",
|
Remark: "保持当前优先级",
|
||||||
Actor: "平台管理员",
|
Actor: "平台管理员",
|
||||||
},
|
},
|
||||||
VIN: "VIN001",
|
VIN: "VIN001",
|
||||||
Protocol: "JT808",
|
Protocol: "JT808",
|
||||||
SourceKey: "opaque-source-key",
|
SourceKey: "opaque-source-key",
|
||||||
SourceLabel: "JT808 终端",
|
SourceLabel: "JT808 终端",
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: ''
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
379
vehicle-data-platform/deploy/import-source-providers.py
Normal file
379
vehicle-data-platform/deploy/import-source-providers.py
Normal 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())
|
||||||
211
vehicle-data-platform/deploy/import-source-providers.test.py
Normal file
211
vehicle-data-platform/deploy/import-source-providers.test.py
Normal 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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 0716 会议 Goal 外部输入交接单
|
||||||
|
|
||||||
|
更新时间:2026-07-16 22:49(Asia/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 正式快照完成手工影子校验并启用定时同步,接口故障演练证明失败关闭;
|
||||||
|
- 业务负责人完成客户演示并确认首期交付范围。
|
||||||
@@ -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 未安装;当前保持失败关闭。
|
||||||
|
|
||||||
目标:
|
目标:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user