feat(platform): consolidate production vehicle data workflows
This commit is contained in:
@@ -124,6 +124,14 @@ func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessS
|
||||
protocols := map[string]*AccessDistribution{}
|
||||
oems := map[string]*AccessDistribution{}
|
||||
for _, row := range rows {
|
||||
switch row.ConnectionState {
|
||||
case "healthy":
|
||||
result.HealthyVehicles++
|
||||
case "incomplete":
|
||||
result.IncompleteVehicles++
|
||||
case "degraded":
|
||||
result.DegradedVehicles++
|
||||
}
|
||||
switch row.OnlineState {
|
||||
case "online":
|
||||
result.OnlineVehicles++
|
||||
@@ -143,7 +151,11 @@ func (s *Service) AccessSummary(ctx context.Context, query AccessQuery) (AccessS
|
||||
if reportedOnDay(row.LatestReceivedAt, now) {
|
||||
result.ReportedToday++
|
||||
}
|
||||
addAccessDistribution(protocols, firstNonEmpty(row.Protocol, "未识别"), row.OnlineState == "online")
|
||||
for _, status := range row.ProtocolStatuses {
|
||||
if status.Connected {
|
||||
addAccessDistribution(protocols, status.Protocol, status.OnlineState == "online")
|
||||
}
|
||||
}
|
||||
addAccessDistribution(oems, firstNonEmpty(row.OEM, "未维护"), row.OnlineState == "online")
|
||||
}
|
||||
if result.TotalVehicles > 0 {
|
||||
@@ -171,9 +183,18 @@ func (s *Service) accessRows(ctx context.Context, query AccessQuery) ([]AccessVe
|
||||
return nil, AccessThresholdConfig{}, err
|
||||
}
|
||||
now := time.Now()
|
||||
rows := make([]AccessVehicleRow, 0, len(evidence))
|
||||
byVIN := make(map[string][]AccessEvidenceRow, len(evidence))
|
||||
order := make([]string, 0, len(evidence))
|
||||
for _, item := range evidence {
|
||||
row := buildAccessVehicleRow(item, config, now)
|
||||
vin := strings.TrimSpace(item.VIN)
|
||||
if _, exists := byVIN[vin]; !exists {
|
||||
order = append(order, vin)
|
||||
}
|
||||
byVIN[vin] = append(byVIN[vin], item)
|
||||
}
|
||||
rows := make([]AccessVehicleRow, 0, len(byVIN))
|
||||
for _, vin := range order {
|
||||
row := buildAccessVehicleGroup(byVIN[vin], config, now)
|
||||
if keepAccessRow(row, query) {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
@@ -191,6 +212,159 @@ func (s *Service) accessRows(ctx context.Context, query AccessQuery) ([]AccessVe
|
||||
return rows, config, nil
|
||||
}
|
||||
|
||||
func buildAccessVehicleGroup(items []AccessEvidenceRow, config AccessThresholdConfig, now time.Time) AccessVehicleRow {
|
||||
if len(items) == 0 {
|
||||
return AccessVehicleRow{}
|
||||
}
|
||||
base := items[0]
|
||||
row := AccessVehicleRow{
|
||||
VIN: strings.TrimSpace(base.VIN),
|
||||
Plate: strings.TrimSpace(base.Plate),
|
||||
OEM: strings.TrimSpace(base.OEM),
|
||||
Model: strings.TrimSpace(base.Model),
|
||||
Company: strings.TrimSpace(base.Company),
|
||||
ExpectedProtocols: append([]string(nil), canonicalVehicleProtocols...),
|
||||
ActualProtocols: []string{},
|
||||
MissingProtocols: []string{},
|
||||
ProtocolStatuses: []AccessProtocolStatus{},
|
||||
ExpectationEvidence: "平台标准接入基线:GB32960 / JT808 / YUTONG_MQTT",
|
||||
ConnectionState: "not_connected",
|
||||
OnlineState: "never_reported",
|
||||
FirstSeenEvidence: "尚未形成任何协议接入快照",
|
||||
ReportIntervalProof: "需要连续上报样本后才能计算",
|
||||
}
|
||||
byProtocol := make(map[string]AccessEvidenceRow, len(items))
|
||||
for _, item := range items {
|
||||
protocol := strings.ToUpper(strings.TrimSpace(item.Protocol))
|
||||
if protocol != "" {
|
||||
byProtocol[protocol] = item
|
||||
}
|
||||
if row.Plate == "" {
|
||||
row.Plate = strings.TrimSpace(item.Plate)
|
||||
}
|
||||
if row.OEM == "" {
|
||||
row.OEM = strings.TrimSpace(item.OEM)
|
||||
}
|
||||
if row.Model == "" {
|
||||
row.Model = strings.TrimSpace(item.Model)
|
||||
}
|
||||
if row.Company == "" {
|
||||
row.Company = strings.TrimSpace(item.Company)
|
||||
}
|
||||
}
|
||||
onlineCount := 0
|
||||
unknownCount := 0
|
||||
connectedCount := 0
|
||||
for _, protocol := range canonicalVehicleProtocols {
|
||||
item, connected := byProtocol[protocol]
|
||||
if !connected {
|
||||
threshold := accessProtocolThreshold(config, protocol)
|
||||
row.MissingProtocols = append(row.MissingProtocols, protocol)
|
||||
row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{
|
||||
Protocol: protocol, Expected: true, Connected: false, OnlineState: "never_reported", ThresholdSec: threshold,
|
||||
FirstSeenEvidence: "应接协议尚未形成实时快照", ReportIntervalEvidence: "尚无接收样本",
|
||||
})
|
||||
continue
|
||||
}
|
||||
source := buildAccessVehicleRow(item, config, now)
|
||||
connectedCount++
|
||||
row.ActualProtocols = append(row.ActualProtocols, protocol)
|
||||
if source.OnlineState == "online" {
|
||||
onlineCount++
|
||||
}
|
||||
if source.DelayAbnormal {
|
||||
row.DelayAbnormal = true
|
||||
}
|
||||
row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{
|
||||
Protocol: protocol, Expected: true, Connected: true, Provider: source.Provider,
|
||||
FirstSeenAt: source.FirstSeenAt, LatestEventAt: source.LatestEventAt, LatestReceivedAt: source.LatestReceivedAt,
|
||||
ReportIntervalSec: source.ReportIntervalSec, DataDelaySec: source.DataDelaySec, FreshnessSec: source.FreshnessSec,
|
||||
OnlineState: source.OnlineState, ThresholdSec: source.ThresholdSec, DelayAbnormal: source.DelayAbnormal,
|
||||
FirstSeenEvidence: source.FirstSeenEvidence, ReportIntervalEvidence: source.ReportIntervalProof,
|
||||
Longitude: item.Longitude, Latitude: item.Latitude, SpeedKmh: item.SpeedKmh, SOCPercent: item.SOCPercent, TotalMileageKm: item.TotalMileageKm, DailyMileageKm: item.DailyMileageKm,
|
||||
})
|
||||
if row.FirstSeenAt == "" || source.FirstSeenAt != "" && source.FirstSeenAt < row.FirstSeenAt {
|
||||
row.FirstSeenAt = source.FirstSeenAt
|
||||
row.FirstSeenEvidence = source.FirstSeenEvidence
|
||||
row.FirstSeenSource = source.FirstSeenSource
|
||||
}
|
||||
if row.LatestReceivedAt == "" || source.LatestReceivedAt > row.LatestReceivedAt {
|
||||
copyAccessPrimaryFields(&row, source)
|
||||
}
|
||||
}
|
||||
for protocol, item := range byProtocol {
|
||||
if containsString(canonicalVehicleProtocols, protocol) {
|
||||
continue
|
||||
}
|
||||
source := buildAccessVehicleRow(item, config, now)
|
||||
row.ActualProtocols = append(row.ActualProtocols, protocol)
|
||||
if source.OnlineState == "online" {
|
||||
onlineCount++
|
||||
}
|
||||
if source.OnlineState == "unknown" {
|
||||
unknownCount++
|
||||
}
|
||||
row.ProtocolStatuses = append(row.ProtocolStatuses, AccessProtocolStatus{
|
||||
Protocol: protocol, Expected: false, Connected: true, Provider: source.Provider,
|
||||
FirstSeenAt: source.FirstSeenAt, LatestEventAt: source.LatestEventAt, LatestReceivedAt: source.LatestReceivedAt,
|
||||
ReportIntervalSec: source.ReportIntervalSec, DataDelaySec: source.DataDelaySec, FreshnessSec: source.FreshnessSec,
|
||||
OnlineState: source.OnlineState, ThresholdSec: source.ThresholdSec, DelayAbnormal: source.DelayAbnormal,
|
||||
FirstSeenEvidence: source.FirstSeenEvidence, ReportIntervalEvidence: source.ReportIntervalProof,
|
||||
Longitude: item.Longitude, Latitude: item.Latitude, SpeedKmh: item.SpeedKmh, SOCPercent: item.SOCPercent, TotalMileageKm: item.TotalMileageKm, DailyMileageKm: item.DailyMileageKm,
|
||||
})
|
||||
if row.LatestReceivedAt == "" || source.LatestReceivedAt > row.LatestReceivedAt {
|
||||
copyAccessPrimaryFields(&row, source)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case connectedCount == 0 && len(row.ActualProtocols) == 0:
|
||||
row.ConnectionState = "not_connected"
|
||||
row.OnlineState = "never_reported"
|
||||
case connectedCount == len(canonicalVehicleProtocols) && onlineCount == connectedCount:
|
||||
row.ConnectionState = "healthy"
|
||||
row.OnlineState = "online"
|
||||
case onlineCount == 0 && unknownCount > 0:
|
||||
row.ConnectionState = "incomplete"
|
||||
row.OnlineState = "unknown"
|
||||
case onlineCount == 0:
|
||||
row.ConnectionState = "offline"
|
||||
row.OnlineState = "offline"
|
||||
case connectedCount < len(canonicalVehicleProtocols):
|
||||
row.ConnectionState = "incomplete"
|
||||
row.OnlineState = "online"
|
||||
default:
|
||||
row.ConnectionState = "degraded"
|
||||
row.OnlineState = "online"
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func accessProtocolThreshold(config AccessThresholdConfig, protocol string) int {
|
||||
for _, override := range config.Protocols {
|
||||
if strings.EqualFold(override.Protocol, protocol) {
|
||||
return override.ThresholdSec
|
||||
}
|
||||
}
|
||||
return config.DefaultThresholdSec
|
||||
}
|
||||
|
||||
func copyAccessPrimaryFields(target *AccessVehicleRow, source AccessVehicleRow) {
|
||||
target.Protocol = source.Protocol
|
||||
target.Provider = source.Provider
|
||||
target.Source = source.Source
|
||||
target.LatestEventAt = source.LatestEventAt
|
||||
target.LatestReceivedAt = source.LatestReceivedAt
|
||||
target.ReportIntervalSec = source.ReportIntervalSec
|
||||
target.DataDelaySec = source.DataDelaySec
|
||||
target.FreshnessSec = source.FreshnessSec
|
||||
target.ThresholdSec = source.ThresholdSec
|
||||
target.LatestMessageType = source.LatestMessageType
|
||||
target.LatestEventID = source.LatestEventID
|
||||
target.LatestError = source.LatestError
|
||||
target.ReportIntervalProof = source.ReportIntervalProof
|
||||
target.ReportSampleCount = source.ReportSampleCount
|
||||
}
|
||||
|
||||
func buildAccessVehicleRow(item AccessEvidenceRow, config AccessThresholdConfig, now time.Time) AccessVehicleRow {
|
||||
threshold := config.DefaultThresholdSec
|
||||
for _, override := range config.Protocols {
|
||||
@@ -266,10 +440,10 @@ func buildAccessVehicleRow(item AccessEvidenceRow, config AccessThresholdConfig,
|
||||
|
||||
func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool {
|
||||
keyword := strings.ToLower(strings.TrimSpace(query.Keyword))
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(row.VIN), keyword) && !strings.Contains(strings.ToLower(row.Plate), keyword) {
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(row.VIN), keyword) && !strings.Contains(strings.ToLower(row.Plate), keyword) && !strings.Contains(strings.ToLower(row.OEM), keyword) && !strings.Contains(strings.ToLower(row.Model), keyword) && !strings.Contains(strings.ToLower(row.Company), keyword) {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(query.Protocol); value != "" && !strings.EqualFold(value, row.Protocol) {
|
||||
if value := strings.TrimSpace(query.Protocol); value != "" && !containsString(row.ActualProtocols, value) {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(query.OEM); value != "" && !strings.EqualFold(value, row.OEM) {
|
||||
@@ -278,8 +452,17 @@ func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool {
|
||||
if value := strings.ToLower(strings.TrimSpace(query.Model)); value != "" && !strings.Contains(strings.ToLower(row.Model), value) {
|
||||
return false
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(query.Provider)); value != "" && !strings.Contains(strings.ToLower(row.Provider), value) {
|
||||
return false
|
||||
if value := strings.ToLower(strings.TrimSpace(query.Provider)); value != "" {
|
||||
matched := false
|
||||
for _, status := range row.ProtocolStatuses {
|
||||
if strings.Contains(strings.ToLower(status.Provider), value) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !accessTimeMatches(row.FirstSeenAt, query.FirstSeenFrom, query.FirstSeenTo) || !accessTimeMatches(row.LatestReceivedAt, query.LatestSeenFrom, query.LatestSeenTo) {
|
||||
return false
|
||||
@@ -287,6 +470,15 @@ func keepAccessRow(row AccessVehicleRow, query AccessQuery) bool {
|
||||
if value := strings.TrimSpace(query.OnlineState); value != "" && value != "all" && value != row.OnlineState {
|
||||
return false
|
||||
}
|
||||
if value := strings.TrimSpace(query.ConnectionState); value != "" && value != "all" {
|
||||
if value == "attention" {
|
||||
if row.ConnectionState == "healthy" {
|
||||
return false
|
||||
}
|
||||
} else if value != row.ConnectionState {
|
||||
return false
|
||||
}
|
||||
}
|
||||
switch strings.TrimSpace(query.DelayState) {
|
||||
case "abnormal":
|
||||
return row.DelayAbnormal
|
||||
|
||||
@@ -77,21 +77,27 @@ COALESCE(DATE_FORMAT(s.access_latest_received_at, '%Y-%m-%d %H:%i:%s.%f'), '') A
|
||||
COALESCE(DATE_FORMAT(s.access_latest_received_at, '%Y-%m-%d %H:%i:%s.%f'), '') AS updated_at,
|
||||
COALESCE(s.access_report_interval_ms, -1) AS report_interval_ms,
|
||||
COALESCE(s.access_sample_count, 0) AS report_sample_count,
|
||||
COALESCE(s.event_id, '') AS event_id
|
||||
COALESCE(s.event_id, '') AS event_id,
|
||||
COALESCE(l.longitude, 0) AS longitude,
|
||||
COALESCE(l.latitude, 0) AS latitude,
|
||||
COALESCE(l.speed_kmh, 0) AS speed_kmh,
|
||||
COALESCE(l.soc_percent, 0) AS soc_percent,
|
||||
COALESCE(l.total_mileage_km, 0) AS total_mileage_km,
|
||||
COALESCE(m.daily_mileage_km, 0) AS daily_mileage_km
|
||||
FROM (
|
||||
SELECT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> ''
|
||||
UNION
|
||||
SELECT vin FROM vehicle_realtime_snapshot WHERE vin IS NOT NULL AND vin <> ''
|
||||
SELECT DISTINCT vin FROM vehicle_identity_binding WHERE vin IS NOT NULL AND vin <> ''
|
||||
) v
|
||||
LEFT JOIN vehicle_identity_binding b ON b.vin = v.vin
|
||||
LEFT JOIN vehicle_profile p ON p.vin = v.vin
|
||||
LEFT JOIN vehicle_realtime_snapshot s ON s.vin = v.vin
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM vehicle_realtime_snapshot newer
|
||||
WHERE newer.vin = s.vin
|
||||
AND (newer.access_latest_received_at > s.access_latest_received_at OR (newer.access_latest_received_at = s.access_latest_received_at AND newer.protocol < s.protocol))
|
||||
)
|
||||
ORDER BY s.access_latest_received_at DESC, v.vin ASC
|
||||
LEFT JOIN vehicle_realtime_location l ON l.vin = s.vin AND l.protocol = s.protocol
|
||||
LEFT JOIN (
|
||||
SELECT vin, protocol, MAX(daily_mileage_km) AS daily_mileage_km
|
||||
FROM vehicle_daily_mileage
|
||||
WHERE stat_date = CURDATE()
|
||||
GROUP BY vin, protocol
|
||||
) m ON m.vin = s.vin AND m.protocol = s.protocol
|
||||
ORDER BY v.vin ASC, s.protocol ASC
|
||||
LIMIT ?`, accessEvidenceLimit+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -101,7 +107,7 @@ LIMIT ?`, accessEvidenceLimit+1)
|
||||
for rows.Next() {
|
||||
var row AccessEvidenceRow
|
||||
var reportIntervalMS int64
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.OEM, &row.Model, &row.Company, &row.Protocol, &row.Provider, &row.FirstSeenAt, &row.FirstSeenSource, &row.LatestEventAt, &row.LatestReceivedAt, &row.LatestUpdatedAt, &reportIntervalMS, &row.ReportSampleCount, &row.LatestEventID); err != nil {
|
||||
if err := rows.Scan(&row.VIN, &row.Plate, &row.OEM, &row.Model, &row.Company, &row.Protocol, &row.Provider, &row.FirstSeenAt, &row.FirstSeenSource, &row.LatestEventAt, &row.LatestReceivedAt, &row.LatestUpdatedAt, &reportIntervalMS, &row.ReportSampleCount, &row.LatestEventID, &row.Longitude, &row.Latitude, &row.SpeedKmh, &row.SOCPercent, &row.TotalMileageKm, &row.DailyMileageKm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reportIntervalMS >= 0 {
|
||||
|
||||
@@ -23,6 +23,9 @@ func TestAccessSummaryUsesDynamicFreshnessAndDelay(t *testing.T) {
|
||||
if summary.DelayAbnormal != 1 || summary.ThresholdVersion != 1 {
|
||||
t.Fatalf("summary must expose dynamic delay and threshold version: %+v", summary)
|
||||
}
|
||||
if summary.HealthyVehicles != 0 || summary.IncompleteVehicles != 3 || summary.DegradedVehicles != 0 {
|
||||
t.Fatalf("connection-state counters must be mutually exclusive: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessVehiclesFiltersAndKeepsEvidenceGapsExplicit(t *testing.T) {
|
||||
@@ -42,6 +45,11 @@ func TestAccessVehiclesFiltersAndKeepsEvidenceGapsExplicit(t *testing.T) {
|
||||
if err != nil || delayed.Total != 1 || delayed.Items[0].DataDelaySec == nil || *delayed.Items[0].DataDelaySec != 45 {
|
||||
t.Fatalf("delay filter should use event/receive difference: page=%+v err=%v", delayed, err)
|
||||
}
|
||||
|
||||
attention, err := service.AccessVehicles(context.Background(), AccessQuery{ConnectionState: "attention", Limit: 20})
|
||||
if err != nil || attention.Total != 6 {
|
||||
t.Fatalf("attention filter should return every vehicle with an access difference: page=%+v err=%v", attention, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessVehiclesSupportsModelProviderAndTimeFilters(t *testing.T) {
|
||||
@@ -96,6 +104,42 @@ func TestAccessVehicleCarriesDurableProjectionEvidenceAndMasterData(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessVehiclesKeywordMatchesFleetAndModel(t *testing.T) {
|
||||
service := NewService(NewMockStore())
|
||||
fleet, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "岭牛示范车队", Limit: 20})
|
||||
if err != nil || fleet.Total != 1 || fleet.Items[0].VIN != "LB9A32A24R0LS1426" {
|
||||
t.Fatalf("fleet keyword should find its vehicles: page=%+v err=%v", fleet, err)
|
||||
}
|
||||
model, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "氢燃料车型", Limit: 20})
|
||||
if err != nil || model.Total != 1 || model.Items[0].VIN != "LNXNEGRR7SR318212" {
|
||||
t.Fatalf("model keyword should find its vehicles: page=%+v err=%v", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessVehicleGroupsCanonicalProtocolsByMasterVehicle(t *testing.T) {
|
||||
store := NewMockStore()
|
||||
service := NewService(store)
|
||||
page, err := service.AccessVehicles(t.Context(), AccessQuery{Keyword: "LB9A32A24R0LS1426", Limit: 10})
|
||||
if err != nil || len(page.Items) != 1 {
|
||||
t.Fatalf("access vehicle query failed: page=%+v err=%v", page, err)
|
||||
}
|
||||
row := page.Items[0]
|
||||
if len(row.ExpectedProtocols) != 3 || len(row.ProtocolStatuses) != 3 {
|
||||
t.Fatalf("vehicle must expose three canonical protocol slots: %+v", row)
|
||||
}
|
||||
if len(row.ActualProtocols) != 1 || row.ActualProtocols[0] != "JT808" || len(row.MissingProtocols) != 2 {
|
||||
t.Fatalf("actual and missing protocols are incorrect: %+v", row)
|
||||
}
|
||||
if row.ConnectionState != "incomplete" || row.OnlineState != "online" {
|
||||
t.Fatalf("single online source should be an online but incomplete vehicle: %+v", row)
|
||||
}
|
||||
for _, status := range row.ProtocolStatuses {
|
||||
if status.Protocol == "JT808" && (!status.Connected || status.LatestReceivedAt == "") {
|
||||
t.Fatalf("connected protocol lost evidence: %+v", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
@@ -103,12 +147,12 @@ func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) {
|
||||
}
|
||||
defer db.Close()
|
||||
store := NewProductionStore(db, nil, "")
|
||||
mock.ExpectQuery("(?s)SELECT.*access_first_seen_at.*access_first_seen_source.*access_report_interval_ms.*vehicle_profile.*access_latest_received_at").
|
||||
mock.ExpectQuery("(?s)SELECT.*access_first_seen_at.*access_latest_received_at.*FROM.*SELECT DISTINCT vin FROM vehicle_identity_binding.*vehicle_profile.*vehicle_realtime_snapshot").
|
||||
WithArgs(accessEvidenceLimit + 1).
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"vin", "plate", "oem", "model_name", "company_name", "protocol", "provider", "first_seen_at", "first_seen_source",
|
||||
"event_time", "received_at", "updated_at", "report_interval_ms", "report_sample_count", "event_id",
|
||||
}).AddRow("VIN001", "粤A1", "示范厂家", "纯电客车", "示范公交", "GB32960", "车厂平台", "2026-07-14 05:00:00.000", "snapshot_backfill", "2026-07-14 05:01:00.000", "2026-07-14 05:01:30.500", "2026-07-14 05:01:30.500", int64(30500), int64(3), "evt-3"))
|
||||
"event_time", "received_at", "updated_at", "report_interval_ms", "report_sample_count", "event_id", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "daily_mileage_km",
|
||||
}).AddRow("VIN001", "粤A1", "示范厂家", "纯电客车", "示范公交", "GB32960", "车厂平台", "2026-07-14 05:00:00.000", "snapshot_backfill", "2026-07-14 05:01:00.000", "2026-07-14 05:01:30.500", "2026-07-14 05:01:30.500", int64(30500), int64(3), "evt-3", 113.1, 23.1, 32.5, 76.0, 12000.5, 38.6))
|
||||
rows, err := store.AccessEvidence(context.Background())
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("AccessEvidence rows=%+v err=%v", rows, err)
|
||||
@@ -117,6 +161,9 @@ func TestProductionAccessEvidenceReadsDurableWriterProjection(t *testing.T) {
|
||||
if row.ReportIntervalSec == nil || *row.ReportIntervalSec != 31 || row.ReportSampleCount != 3 || row.Model != "纯电客车" || row.Company != "示范公交" {
|
||||
t.Fatalf("unexpected durable projection row: %+v", row)
|
||||
}
|
||||
if row.SpeedKmh != 32.5 || row.TotalMileageKm != 12000.5 || row.DailyMileageKm != 38.6 {
|
||||
t.Fatalf("realtime source metrics missing: %+v", row)
|
||||
}
|
||||
if row.FirstSeenEvidence != "上线基线:由实时快照回填(非历史首次接入)" || row.ReportIntervalProof == "" {
|
||||
t.Fatalf("projection evidence boundary missing: %+v", row)
|
||||
}
|
||||
|
||||
@@ -871,6 +871,27 @@ func TestHandlerVehicleRealtimeAcceptsKeyword(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerVehicleRealtimeAcceptsBatchKeywords(t *testing.T) {
|
||||
handler := NewHandler(NewService(NewMockStore()))
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/realtime/vehicles?keywords=%E7%B2%A4AG18312%2C%E5%B7%9DAHTWO1%2C%E7%B2%A4AG18312&limit=10", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Data struct {
|
||||
Items []VehicleRealtimeRow `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("response JSON should decode: %v body=%s", err, rec.Body.String())
|
||||
}
|
||||
if len(body.Data.Items) != 2 {
|
||||
t.Fatalf("batch search should return two deduplicated vehicles, got %+v body=%s", body.Data.Items, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerHistoryMileageQualityOps(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
|
||||
@@ -485,10 +485,16 @@ func buildArchiveMissingFieldStats(missingPlate, missingPhone, missingOEM int) [
|
||||
|
||||
func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
|
||||
rows := m.locations
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
keyword := strings.ToLower(vin)
|
||||
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
|
||||
rows = keep(rows, func(row RealtimeLocationRow) bool {
|
||||
return strings.Contains(strings.ToLower(row.VIN+row.Plate), keyword)
|
||||
vehicle := m.vehicleByVIN(row.VIN)
|
||||
haystack := strings.ToLower(row.VIN + row.Plate + vehicle.Plate + vehicle.Phone + vehicle.OEM)
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(haystack, strings.ToLower(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
@@ -898,6 +904,13 @@ func (m *MockStore) dailyMileageRows(query url.Values) []DailyMileageRow {
|
||||
return strings.Contains(strings.ToLower(row.VIN+row.Plate), keyword)
|
||||
})
|
||||
}
|
||||
if raw := strings.TrimSpace(query.Get("vins")); raw != "" {
|
||||
selected := map[string]bool{}
|
||||
for _, vin := range strings.Split(raw, ",") {
|
||||
selected[strings.TrimSpace(vin)] = true
|
||||
}
|
||||
rows = keep(rows, func(row DailyMileageRow) bool { return selected[row.VIN] })
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
rows = keep(rows, func(row DailyMileageRow) bool { return row.Source == protocol })
|
||||
}
|
||||
|
||||
@@ -353,19 +353,20 @@ type HistoryExportCursor struct {
|
||||
}
|
||||
|
||||
type AccessQuery struct {
|
||||
Keyword string `json:"keyword"`
|
||||
Protocol string `json:"protocol"`
|
||||
OEM string `json:"oem"`
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
FirstSeenFrom string `json:"firstSeenFrom"`
|
||||
FirstSeenTo string `json:"firstSeenTo"`
|
||||
LatestSeenFrom string `json:"latestSeenFrom"`
|
||||
LatestSeenTo string `json:"latestSeenTo"`
|
||||
OnlineState string `json:"onlineState"`
|
||||
DelayState string `json:"delayState"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
Keyword string `json:"keyword"`
|
||||
Protocol string `json:"protocol"`
|
||||
OEM string `json:"oem"`
|
||||
Model string `json:"model"`
|
||||
Provider string `json:"provider"`
|
||||
FirstSeenFrom string `json:"firstSeenFrom"`
|
||||
FirstSeenTo string `json:"firstSeenTo"`
|
||||
LatestSeenFrom string `json:"latestSeenFrom"`
|
||||
LatestSeenTo string `json:"latestSeenTo"`
|
||||
OnlineState string `json:"onlineState"`
|
||||
DelayState string `json:"delayState"`
|
||||
ConnectionState string `json:"connectionState"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
type AccessEvidenceRow struct {
|
||||
@@ -389,33 +390,69 @@ type AccessEvidenceRow struct {
|
||||
FirstSeenSource string
|
||||
ReportIntervalProof string
|
||||
ReportSampleCount int64
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
SpeedKmh float64
|
||||
SOCPercent float64
|
||||
TotalMileageKm float64
|
||||
DailyMileageKm float64
|
||||
}
|
||||
|
||||
type AccessVehicleRow struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
OEM string `json:"oem"`
|
||||
Model string `json:"model"`
|
||||
Company string `json:"company"`
|
||||
Protocol string `json:"protocol"`
|
||||
Provider string `json:"provider"`
|
||||
Source string `json:"source"`
|
||||
FirstSeenAt string `json:"firstSeenAt"`
|
||||
LatestEventAt string `json:"latestEventAt"`
|
||||
LatestReceivedAt string `json:"latestReceivedAt"`
|
||||
ReportIntervalSec *int `json:"reportIntervalSec"`
|
||||
DataDelaySec *int `json:"dataDelaySec"`
|
||||
FreshnessSec *int `json:"freshnessSec"`
|
||||
OnlineState string `json:"onlineState"`
|
||||
ThresholdSec int `json:"thresholdSec"`
|
||||
LatestMessageType string `json:"latestMessageType"`
|
||||
LatestEventID string `json:"latestEventId"`
|
||||
LatestError string `json:"latestError"`
|
||||
DelayAbnormal bool `json:"delayAbnormal"`
|
||||
FirstSeenEvidence string `json:"firstSeenEvidence"`
|
||||
FirstSeenSource string `json:"firstSeenSource"`
|
||||
ReportIntervalProof string `json:"reportIntervalEvidence"`
|
||||
ReportSampleCount int64 `json:"reportSampleCount"`
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
OEM string `json:"oem"`
|
||||
Model string `json:"model"`
|
||||
Company string `json:"company"`
|
||||
Protocol string `json:"protocol"`
|
||||
Provider string `json:"provider"`
|
||||
Source string `json:"source"`
|
||||
FirstSeenAt string `json:"firstSeenAt"`
|
||||
LatestEventAt string `json:"latestEventAt"`
|
||||
LatestReceivedAt string `json:"latestReceivedAt"`
|
||||
ReportIntervalSec *int `json:"reportIntervalSec"`
|
||||
DataDelaySec *int `json:"dataDelaySec"`
|
||||
FreshnessSec *int `json:"freshnessSec"`
|
||||
OnlineState string `json:"onlineState"`
|
||||
ThresholdSec int `json:"thresholdSec"`
|
||||
LatestMessageType string `json:"latestMessageType"`
|
||||
LatestEventID string `json:"latestEventId"`
|
||||
LatestError string `json:"latestError"`
|
||||
DelayAbnormal bool `json:"delayAbnormal"`
|
||||
FirstSeenEvidence string `json:"firstSeenEvidence"`
|
||||
FirstSeenSource string `json:"firstSeenSource"`
|
||||
ReportIntervalProof string `json:"reportIntervalEvidence"`
|
||||
ReportSampleCount int64 `json:"reportSampleCount"`
|
||||
ExpectedProtocols []string `json:"expectedProtocols"`
|
||||
ActualProtocols []string `json:"actualProtocols"`
|
||||
MissingProtocols []string `json:"missingProtocols"`
|
||||
ProtocolStatuses []AccessProtocolStatus `json:"protocolStatuses"`
|
||||
ConnectionState string `json:"connectionState"`
|
||||
ExpectationEvidence string `json:"expectationEvidence"`
|
||||
}
|
||||
|
||||
type AccessProtocolStatus struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Expected bool `json:"expected"`
|
||||
Connected bool `json:"connected"`
|
||||
Provider string `json:"provider"`
|
||||
FirstSeenAt string `json:"firstSeenAt"`
|
||||
LatestEventAt string `json:"latestEventAt"`
|
||||
LatestReceivedAt string `json:"latestReceivedAt"`
|
||||
ReportIntervalSec *int `json:"reportIntervalSec"`
|
||||
DataDelaySec *int `json:"dataDelaySec"`
|
||||
FreshnessSec *int `json:"freshnessSec"`
|
||||
OnlineState string `json:"onlineState"`
|
||||
ThresholdSec int `json:"thresholdSec"`
|
||||
DelayAbnormal bool `json:"delayAbnormal"`
|
||||
FirstSeenEvidence string `json:"firstSeenEvidence"`
|
||||
ReportIntervalEvidence string `json:"reportIntervalEvidence"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
SpeedKmh float64 `json:"speedKmh"`
|
||||
SOCPercent float64 `json:"socPercent"`
|
||||
TotalMileageKm float64 `json:"totalMileageKm"`
|
||||
DailyMileageKm float64 `json:"dailyMileageKm"`
|
||||
}
|
||||
|
||||
type AccessUnresolvedIdentityQuery struct {
|
||||
@@ -457,6 +494,9 @@ type AccessSummary struct {
|
||||
UnknownVehicles int `json:"unknownVehicles"`
|
||||
DelayAbnormal int `json:"delayAbnormal"`
|
||||
ReportedToday int `json:"reportedToday"`
|
||||
HealthyVehicles int `json:"healthyVehicles"`
|
||||
IncompleteVehicles int `json:"incompleteVehicles"`
|
||||
DegradedVehicles int `json:"degradedVehicles"`
|
||||
OnlineRate float64 `json:"onlineRate"`
|
||||
Protocols []AccessDistribution `json:"protocols"`
|
||||
OEMs []AccessDistribution `json:"oems"`
|
||||
|
||||
@@ -326,10 +326,14 @@ func buildVehicleRealtimeSQL(query url.Values) SQLQuery {
|
||||
where = append(where, "l.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Get("vin")); keyword != "" {
|
||||
where = append(where, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like)
|
||||
if keywords := vehicleSearchKeywords(query); len(keywords) > 0 {
|
||||
matches := make([]string, 0, len(keywords))
|
||||
for _, keyword := range keywords {
|
||||
matches = append(matches, "(l.vin LIKE ? OR l.plate LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + keyword + "%"
|
||||
args = append(args, like, like, like, like, like)
|
||||
}
|
||||
where = append(where, "("+strings.Join(matches, " OR ")+")")
|
||||
}
|
||||
switch strings.TrimSpace(query.Get("online")) {
|
||||
case "online":
|
||||
@@ -415,12 +419,19 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
offset := parsePositive(query.Get("offset"), 0)
|
||||
args := []any{}
|
||||
where := []string{"1 = 1"}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
||||
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
||||
}
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + vin + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
if len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "m.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
@@ -435,6 +446,26 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
countArgs := append([]any(nil), args...)
|
||||
args = append(args, limit, offset)
|
||||
fromSQL := `FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin WHERE ` + strings.Join(where, " AND ")
|
||||
if query.Get("deduplicate") == "1" || strings.EqualFold(query.Get("deduplicate"), "true") {
|
||||
selectionOrder := `m.daily_mileage_km DESC, m.protocol ASC`
|
||||
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder = mileageProtocolOrder("m.protocol", protocols)
|
||||
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
}
|
||||
groupSQL := fromSQL + ` GROUP BY m.vin, m.stat_date`
|
||||
return SQLQuery{
|
||||
Text: `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km - m.daily_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS start_mileage_km, ` +
|
||||
`COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(m.latest_total_mileage_km AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0) AS end_mileage_km, ` +
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(m.protocol ORDER BY ` + selectionOrder + `), ',', 1), '') AS protocol ` +
|
||||
groupSQL + ` ORDER BY m.stat_date DESC, m.vin ASC LIMIT ? OFFSET ?`,
|
||||
Args: args,
|
||||
CountText: `SELECT COUNT(*) FROM (SELECT m.vin ` + groupSQL + `) vehicle_daily_mileage_count`,
|
||||
CountArgs: countArgs,
|
||||
}
|
||||
}
|
||||
return SQLQuery{
|
||||
Text: `SELECT m.vin, COALESCE(b.plate, '') AS plate, DATE_FORMAT(m.stat_date, '%Y-%m-%d') AS stat_date, ` +
|
||||
`COALESCE(m.latest_total_mileage_km - m.daily_mileage_km, 0) AS start_mileage_km, ` +
|
||||
@@ -449,12 +480,18 @@ func buildDailyMileageSQL(query url.Values) SQLQuery {
|
||||
func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
args := []any{}
|
||||
where := []string{"1 = 1"}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
||||
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
||||
}
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + vin + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "m.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
@@ -477,12 +514,18 @@ func buildMileageSummarySQL(query url.Values) SQLQuery {
|
||||
func buildMileageStatisticsWhere(query url.Values) (string, []any) {
|
||||
where := []string{"1 = 1"}
|
||||
args := []any{}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
||||
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
||||
}
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
where = append(where, "(m.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + vin + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where, args = appendVINListFilter(where, args, "m.vin", query.Get("vins"))
|
||||
if protocols := parseMileageProtocols(query.Get("protocols")); len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "m.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "m.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
@@ -499,9 +542,17 @@ func buildMileageStatisticsWhere(query url.Values) (string, []any) {
|
||||
|
||||
func buildMileageStatisticsBaseSQL(query url.Values) (string, []any) {
|
||||
where, args := buildMileageStatisticsWhere(query)
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
dailyMileageExpression := `MAX(COALESCE(m.daily_mileage_km, 0))`
|
||||
latestMileageExpression := `MAX(COALESCE(m.latest_total_mileage_km, 0))`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder := mileageProtocolOrder("m.protocol", protocols)
|
||||
dailyMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
latestMileageExpression = `COALESCE(CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(COALESCE(m.latest_total_mileage_km, 0) AS CHAR) ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)), 0)`
|
||||
}
|
||||
return `SELECT m.vin, COALESCE(MAX(NULLIF(b.plate, '')), '') AS plate, m.stat_date, ` +
|
||||
`MAX(COALESCE(m.daily_mileage_km, 0)) AS daily_mileage_km, ` +
|
||||
`MAX(COALESCE(m.latest_total_mileage_km, 0)) AS latest_mileage_km ` +
|
||||
dailyMileageExpression + ` AS daily_mileage_km, ` +
|
||||
latestMileageExpression + ` AS latest_mileage_km ` +
|
||||
`FROM vehicle_daily_mileage m LEFT JOIN vehicle_identity_binding b ON b.vin = m.vin ` +
|
||||
`WHERE ` + where + ` GROUP BY m.vin, m.stat_date`, args
|
||||
}
|
||||
@@ -535,22 +586,90 @@ func buildMileageStatisticsSourceCountSQL(query url.Values) SQLQuery {
|
||||
func buildFleetLatestMileageSQL(query url.Values) SQLQuery {
|
||||
where := []string{"l.total_mileage_km IS NOT NULL", "l.total_mileage_km >= 0"}
|
||||
args := []any{}
|
||||
if strings.EqualFold(strings.TrimSpace(query.Get("vehicleScope")), "bound") {
|
||||
where = append(where, "b.vin IS NOT NULL", "b.vin <> ''")
|
||||
}
|
||||
if vin := strings.TrimSpace(query.Get("vin")); vin != "" {
|
||||
where = append(where, "(l.vin LIKE ? OR b.plate LIKE ? OR b.phone LIKE ? OR b.oem LIKE ?)")
|
||||
like := "%" + vin + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where, args = appendVINListFilter(where, args, "l.vin", query.Get("vins"))
|
||||
protocols := parseMileageProtocols(query.Get("protocols"))
|
||||
if len(protocols) > 0 {
|
||||
where, args = appendMileageProtocolFilter(where, args, "l.protocol", protocols)
|
||||
} else if protocol := strings.TrimSpace(query.Get("protocol")); protocol != "" {
|
||||
where = append(where, "l.protocol = ?")
|
||||
args = append(args, protocol)
|
||||
}
|
||||
selectionOrder := `l.updated_at DESC, l.protocol ASC`
|
||||
if len(protocols) > 0 {
|
||||
selectionOrder = mileageProtocolOrder("l.protocol", protocols) + `, l.updated_at DESC`
|
||||
}
|
||||
inner := `SELECT l.vin, CAST(SUBSTRING_INDEX(GROUP_CONCAT(CAST(l.total_mileage_km AS CHAR) ` +
|
||||
`ORDER BY l.updated_at DESC, l.protocol ASC), ',', 1) AS DECIMAL(18,3)) AS latest_mileage_km ` +
|
||||
`ORDER BY ` + selectionOrder + `), ',', 1) AS DECIMAL(18,3)) AS latest_mileage_km ` +
|
||||
`FROM vehicle_realtime_location l LEFT JOIN vehicle_identity_binding b ON b.vin = l.vin WHERE ` +
|
||||
strings.Join(where, " AND ") + ` GROUP BY l.vin`
|
||||
return SQLQuery{Text: `SELECT COALESCE(SUM(x.latest_mileage_km), 0) FROM (` + inner + `) x`, Args: args}
|
||||
}
|
||||
|
||||
func parseMileageProtocols(raw string) []string {
|
||||
seen := map[string]bool{}
|
||||
values := make([]string, 0, 3)
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
protocol := strings.ToUpper(strings.TrimSpace(item))
|
||||
switch protocol {
|
||||
case "GB32960", "JT808", "YUTONG_MQTT":
|
||||
if !seen[protocol] {
|
||||
seen[protocol] = true
|
||||
values = append(values, protocol)
|
||||
}
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func appendMileageProtocolFilter(where []string, args []any, column string, protocols []string) ([]string, []any) {
|
||||
placeholders := make([]string, len(protocols))
|
||||
for index, protocol := range protocols {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, protocol)
|
||||
}
|
||||
return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args
|
||||
}
|
||||
|
||||
func mileageProtocolOrder(column string, protocols []string) string {
|
||||
parts := make([]string, 0, len(protocols)+1)
|
||||
parts = append(parts, "CASE "+column)
|
||||
for index, protocol := range protocols {
|
||||
parts = append(parts, "WHEN '"+protocol+"' THEN "+strconv.Itoa(index+1))
|
||||
}
|
||||
parts = append(parts, "ELSE 999 END")
|
||||
return strings.Join(parts, " ") + ", " + column + " ASC"
|
||||
}
|
||||
|
||||
func appendVINListFilter(where []string, args []any, column string, raw string) ([]string, []any) {
|
||||
seen := map[string]bool{}
|
||||
values := make([]string, 0)
|
||||
for _, item := range strings.Split(raw, ",") {
|
||||
vin := strings.TrimSpace(item)
|
||||
if vin == "" || seen[vin] {
|
||||
continue
|
||||
}
|
||||
seen[vin] = true
|
||||
values = append(values, vin)
|
||||
}
|
||||
if len(values) == 0 {
|
||||
return where, args
|
||||
}
|
||||
placeholders := make([]string, len(values))
|
||||
for index, vin := range values {
|
||||
placeholders[index] = "?"
|
||||
args = append(args, vin)
|
||||
}
|
||||
return append(where, column+" IN ("+strings.Join(placeholders, ",")+")"), args
|
||||
}
|
||||
|
||||
func buildLimitOffset(query url.Values) (int, int) {
|
||||
return parsePositive(query.Get("limit"), 20), parsePositive(query.Get("offset"), 0)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -214,6 +215,19 @@ func TestBuildVehicleRealtimeSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLFiltersMultipleVehicleKeywords(t *testing.T) {
|
||||
built := buildVehicleRealtimeSQL(url.Values{"keywords": {"粤AG18312, 川AHTWO1, 粤AG18312"}, "limit": {"10"}})
|
||||
if !strings.Contains(built.Text, ") OR (l.vin LIKE ?") || !strings.Contains(built.CountText, ") OR (l.vin LIKE ?") {
|
||||
t.Fatalf("batch vehicle search must match any keyword in rows and count: %s / %s", built.Text, built.CountText)
|
||||
}
|
||||
if len(built.Args) != 12 || built.Args[0] != "%粤AG18312%" || built.Args[5] != "%川AHTWO1%" || built.Args[10] != 10 {
|
||||
t.Fatalf("batch args = %#v", built.Args)
|
||||
}
|
||||
if len(built.CountArgs) != 10 || built.CountArgs[0] != "%粤AG18312%" || built.CountArgs[5] != "%川AHTWO1%" {
|
||||
t.Fatalf("batch count args = %#v", built.CountArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVehicleRealtimeSQLFiltersServiceStatus(t *testing.T) {
|
||||
query := url.Values{"serviceStatus": {"degraded"}, "limit": {"8"}}
|
||||
built := buildVehicleRealtimeSQL(query)
|
||||
@@ -264,6 +278,96 @@ func TestBuildDailyMileageSQL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageQueriesFilterExactVINSelection(t *testing.T) {
|
||||
query := url.Values{"vins": {"VIN001,VIN002,VIN001"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||
daily := buildDailyMileageSQL(query)
|
||||
if !strings.Contains(daily.Text, "m.vin IN (?,?)") || daily.Args[0] != "VIN001" || daily.Args[1] != "VIN002" {
|
||||
t.Fatalf("daily mileage should use an exact de-duplicated VIN list: %s %#v", daily.Text, daily.Args)
|
||||
}
|
||||
statistics := buildMileageStatisticsSummarySQL(query)
|
||||
if !strings.Contains(statistics.Text, "m.vin IN (?,?)") || len(statistics.Args) != 4 {
|
||||
t.Fatalf("statistics should share the selected VIN scope: %s %#v", statistics.Text, statistics.Args)
|
||||
}
|
||||
fleet := buildFleetLatestMileageSQL(query)
|
||||
if !strings.Contains(fleet.Text, "l.vin IN (?,?)") || len(fleet.Args) != 2 {
|
||||
t.Fatalf("latest mileage should share the selected VIN scope: %s %#v", fleet.Text, fleet.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageQueriesCanRestrictFleetScopeToAuthoritativelyBoundVehicles(t *testing.T) {
|
||||
query := url.Values{"vehicleScope": {"bound"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||
for name, built := range map[string]SQLQuery{
|
||||
"daily": buildDailyMileageSQL(query),
|
||||
"statistics": buildMileageStatisticsSummarySQL(query),
|
||||
"latest": buildFleetLatestMileageSQL(query),
|
||||
} {
|
||||
if !strings.Contains(built.Text, "b.vin IS NOT NULL") || !strings.Contains(built.Text, "b.vin <> ''") {
|
||||
t.Fatalf("%s mileage SQL must exclude unbound realtime-only identities: %s", name, built.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDailyMileageSQLCanMatchStatisticsVehicleDayScope(t *testing.T) {
|
||||
built := buildDailyMileageSQL(url.Values{"deduplicate": {"1"}, "limit": {"50"}})
|
||||
for _, want := range []string{"GROUP BY m.vin, m.stat_date", "MAX(COALESCE(m.daily_mileage_km, 0))", "GROUP_CONCAT(m.protocol ORDER BY m.daily_mileage_km DESC", "vehicle_daily_mileage_count"} {
|
||||
if !strings.Contains(built.Text+built.CountText, want) {
|
||||
t.Fatalf("deduplicated daily mileage SQL missing %q: %s / %s", want, built.Text, built.CountText)
|
||||
}
|
||||
}
|
||||
if built.Args[len(built.Args)-2] != 50 || built.Args[len(built.Args)-1] != 0 {
|
||||
t.Fatalf("deduplicated daily mileage pagination args = %#v", built.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageQueriesRespectEnabledProtocolPriority(t *testing.T) {
|
||||
query := url.Values{
|
||||
"protocols": {"JT808,GB32960"},
|
||||
"dateFrom": {"2026-07-01"},
|
||||
"dateTo": {"2026-07-03"},
|
||||
"deduplicate": {"1"},
|
||||
}
|
||||
daily := buildDailyMileageSQL(query)
|
||||
for _, want := range []string{
|
||||
"m.protocol IN (?,?)",
|
||||
"CASE m.protocol WHEN 'JT808' THEN 1 WHEN 'GB32960' THEN 2 ELSE 999 END",
|
||||
"ELSE 999 END, m.protocol ASC",
|
||||
"GROUP_CONCAT(CAST(COALESCE(m.daily_mileage_km, 0) AS CHAR) ORDER BY",
|
||||
} {
|
||||
if !strings.Contains(daily.Text, want) {
|
||||
t.Fatalf("priority daily mileage SQL missing %q: %s", want, daily.Text)
|
||||
}
|
||||
}
|
||||
if daily.Args[0] != "JT808" || daily.Args[1] != "GB32960" {
|
||||
t.Fatalf("enabled protocol filter should preserve priority order: %#v", daily.Args)
|
||||
}
|
||||
|
||||
statistics := buildMileageStatisticsSummarySQL(query)
|
||||
for _, want := range []string{
|
||||
"m.protocol IN (?,?)",
|
||||
"CASE m.protocol WHEN 'JT808' THEN 1 WHEN 'GB32960' THEN 2 ELSE 999 END",
|
||||
"SUBSTRING_INDEX(GROUP_CONCAT",
|
||||
} {
|
||||
if !strings.Contains(statistics.Text, want) {
|
||||
t.Fatalf("priority statistics SQL missing %q: %s", want, statistics.Text)
|
||||
}
|
||||
}
|
||||
if strings.Contains(statistics.Text, "MAX(COALESCE(m.daily_mileage_km, 0))") {
|
||||
t.Fatalf("configured priority must select the preferred source instead of the maximum mileage: %s", statistics.Text)
|
||||
}
|
||||
|
||||
fleet := buildFleetLatestMileageSQL(query)
|
||||
if !strings.Contains(fleet.Text, "l.protocol IN (?,?)") || !strings.Contains(fleet.Text, "CASE l.protocol WHEN 'JT808' THEN 1 WHEN 'GB32960' THEN 2") {
|
||||
t.Fatalf("latest mileage should share the configured source strategy: %s", fleet.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageProtocolsRejectUnknownValues(t *testing.T) {
|
||||
protocols := parseMileageProtocols("JT808,invalid');DROP TABLE vehicle_daily_mileage;--,JT808,YUTONG_MQTT")
|
||||
if fmt.Sprint(protocols) != "[JT808 YUTONG_MQTT]" {
|
||||
t.Fatalf("unexpected sanitized mileage protocols: %#v", protocols)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMileageSummarySQL(t *testing.T) {
|
||||
query := url.Values{"vin": {"粤A"}, "protocol": {"GB32960"}, "dateFrom": {"2026-07-01"}, "dateTo": {"2026-07-03"}}
|
||||
built := buildMileageSummarySQL(query)
|
||||
@@ -315,6 +419,20 @@ func TestNormalizeMileageStatisticsWindow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMileageVINSelection(t *testing.T) {
|
||||
normalized, err := normalizeMileageVINSelection(url.Values{"vins": {" VIN001,VIN002,VIN001 "}})
|
||||
if err != nil || normalized.Get("vins") != "VIN001,VIN002" {
|
||||
t.Fatalf("normalize VIN selection = %q, %v", normalized.Get("vins"), err)
|
||||
}
|
||||
tooMany := make([]string, 21)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = fmt.Sprintf("VIN%03d", index)
|
||||
}
|
||||
if _, err := normalizeMileageVINSelection(url.Values{"vins": {strings.Join(tooMany, ",")}}); err == nil {
|
||||
t.Fatal("more than 20 selected vehicles should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFleetLatestMileageSQLUsesOneLatestValuePerVehicle(t *testing.T) {
|
||||
built := buildFleetLatestMileageSQL(url.Values{"vin": {"VIN001"}, "protocol": {"JT808"}})
|
||||
for _, want := range []string{"GROUP_CONCAT", "ORDER BY l.updated_at DESC", "GROUP BY l.vin", "SUM(x.latest_mileage_km)"} {
|
||||
|
||||
@@ -364,10 +364,11 @@ func (s *Service) DashboardSummary(ctx context.Context) (DashboardSummary, error
|
||||
}
|
||||
|
||||
const (
|
||||
monitorVehicleLimit = 10000
|
||||
monitorPointLimit = 2000
|
||||
monitorPointZoom = 11
|
||||
monitorClusterGridPixels = 64
|
||||
monitorVehicleLimit = 10000
|
||||
monitorPointLimit = 2000
|
||||
monitorPointZoom = 11
|
||||
monitorClusterGridPixels = 64
|
||||
vehicleSearchKeywordLimit = 100
|
||||
)
|
||||
|
||||
type monitorBounds struct {
|
||||
@@ -428,7 +429,7 @@ func normalizeMonitorQuery(query url.Values) url.Values {
|
||||
}
|
||||
next.Set("limit", strconv.Itoa(monitorVehicleLimit))
|
||||
next.Set("offset", "0")
|
||||
if next.Get("vin") == "" && strings.TrimSpace(next.Get("keyword")) != "" {
|
||||
if next.Get("vin") == "" && strings.TrimSpace(next.Get("keywords")) == "" && strings.TrimSpace(next.Get("keyword")) != "" {
|
||||
next.Set("vin", strings.TrimSpace(next.Get("keyword")))
|
||||
}
|
||||
switch strings.TrimSpace(next.Get("status")) {
|
||||
@@ -440,6 +441,41 @@ func normalizeMonitorQuery(query url.Values) url.Values {
|
||||
return next
|
||||
}
|
||||
|
||||
func vehicleSearchKeywords(query url.Values) []string {
|
||||
rawValues := query["keywords"]
|
||||
if len(rawValues) == 0 {
|
||||
rawValues = []string{firstNonEmpty(query.Get("vin"), query.Get("keyword"))}
|
||||
}
|
||||
result := make([]string, 0, len(rawValues))
|
||||
seen := map[string]struct{}{}
|
||||
for _, raw := range rawValues {
|
||||
parts := strings.FieldsFunc(raw, func(char rune) bool {
|
||||
switch char {
|
||||
case ',', ',', '、', ';', ';', '\n', '\r', '\t', ' ':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(part)
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, part)
|
||||
if len(result) == vehicleSearchKeywordLimit {
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func matchesMonitorStatus(row VehicleRealtimeRow, requested string) bool {
|
||||
requested = strings.TrimSpace(requested)
|
||||
return requested == "" || monitorVehicleStatus(row) == requested || requested == "online" && row.Online
|
||||
@@ -3103,6 +3139,10 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
||||
if err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery)
|
||||
if err != nil {
|
||||
return Page[DailyMileageRow]{}, err
|
||||
}
|
||||
return s.store.DailyMileage(ctx, resolvedQuery)
|
||||
}
|
||||
|
||||
@@ -3111,6 +3151,10 @@ func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (Mile
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery)
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
}
|
||||
resolvedQuery, err = normalizeMileageStatisticsWindow(resolvedQuery, time.Now())
|
||||
if err != nil {
|
||||
return MileageStatistics{}, err
|
||||
@@ -3118,6 +3162,29 @@ func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (Mile
|
||||
return s.store.MileageStatistics(ctx, resolvedQuery)
|
||||
}
|
||||
|
||||
func normalizeMileageVINSelection(query url.Values) (url.Values, error) {
|
||||
next := cloneValues(query)
|
||||
seen := map[string]bool{}
|
||||
vins := make([]string, 0)
|
||||
for _, item := range strings.Split(next.Get("vins"), ",") {
|
||||
vin := strings.TrimSpace(item)
|
||||
if vin == "" || seen[vin] {
|
||||
continue
|
||||
}
|
||||
seen[vin] = true
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
if len(vins) > 20 {
|
||||
return nil, clientError{Code: "TOO_MANY_VEHICLES", Message: "里程查询一次最多选择 20 辆车"}
|
||||
}
|
||||
if len(vins) == 0 {
|
||||
next.Del("vins")
|
||||
} else {
|
||||
next.Set("vins", strings.Join(vins, ","))
|
||||
}
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func normalizeMileageStatisticsWindow(query url.Values, now time.Time) (url.Values, error) {
|
||||
next := cloneValues(query)
|
||||
dateTo := strings.TrimSpace(next.Get("dateTo"))
|
||||
@@ -3502,6 +3569,9 @@ func cloneStringMap(values map[string]string) map[string]string {
|
||||
|
||||
func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (url.Values, error) {
|
||||
resolved := cloneValues(query)
|
||||
if strings.TrimSpace(resolved.Get("keywords")) != "" {
|
||||
return resolved, nil
|
||||
}
|
||||
vin := firstNonEmpty(resolved.Get("vin"), resolved.Get("keyword"))
|
||||
if vin == "" {
|
||||
return resolved, nil
|
||||
|
||||
Reference in New Issue
Block a user