feat(platform): consolidate production vehicle data workflows

This commit is contained in:
lingniu
2026-07-15 23:26:29 +08:00
parent 6cddc0a43d
commit 3fabcf181a
59 changed files with 6849 additions and 3532 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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