feat(platform): batch vehicle service overview lookup

This commit is contained in:
lingniu
2026-07-04 03:12:21 +08:00
parent f996fa0df9
commit e35565f993
4 changed files with 259 additions and 16 deletions

View File

@@ -216,6 +216,91 @@ func (m *MockStore) VehicleRealtime(_ context.Context, query url.Values) (Page[V
return page(items, query), nil
}
func (m *MockStore) VehicleServiceOverviews(_ context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
items := make([]VehicleServiceOverview, 0, len(query.Keywords))
for _, keyword := range normalizedKeywords(query.Keywords) {
vehicles := m.vehiclesForKeyword(keyword, query.Protocol)
resolution := buildVehicleIdentityResolution(keyword, vehicles)
identity := resolveVehicleIdentity(keyword, vehicles)
resolvedVIN := ""
if identity != nil && strings.TrimSpace(identity.VIN) != "" {
resolvedVIN = identity.VIN
} else if resolution.Resolved {
resolvedVIN = resolution.VIN
}
if resolvedVIN == "" {
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
continue
}
summary := m.realtimeSummaryForVIN(resolvedVIN, query.Protocol)
sourceStatus := vehicleSourceStatus(vehicles, nil, nil, nil, nil)
items = append(items, *buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
}
return Page[VehicleServiceOverview]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
}
func (m *MockStore) vehiclesForKeyword(keyword string, protocol string) []VehicleRow {
keyword = strings.ToLower(strings.TrimSpace(keyword))
protocol = strings.TrimSpace(protocol)
out := make([]VehicleRow, 0)
for _, vehicle := range m.vehicles {
if protocol != "" && vehicle.Protocol != protocol {
continue
}
haystack := strings.ToLower(vehicle.VIN + vehicle.Plate + vehicle.Phone + vehicle.OEM)
if keyword == "" || strings.Contains(haystack, keyword) {
out = append(out, vehicle)
}
}
return out
}
func (m *MockStore) realtimeSummaryForVIN(vin string, protocol string) *VehicleRealtimeRow {
protocol = strings.TrimSpace(protocol)
var summary *VehicleRealtimeRow
for _, location := range m.locations {
if location.VIN != vin {
continue
}
if protocol != "" && location.Protocol != protocol {
continue
}
if summary == nil {
vehicle := m.vehicleByVIN(location.VIN)
summary = &VehicleRealtimeRow{
VIN: location.VIN,
Plate: firstNonEmpty(location.Plate, vehicle.Plate),
Phone: vehicle.Phone,
OEM: vehicle.OEM,
BindingStatus: "bound",
LastSeen: location.LastSeen,
}
}
if !containsString(summary.Protocols, location.Protocol) {
summary.Protocols = append(summary.Protocols, location.Protocol)
summary.SourceCount = len(summary.Protocols)
}
if location.LastSeen >= summary.LastSeen {
summary.PrimaryProtocol = location.Protocol
summary.Longitude = location.Longitude
summary.Latitude = location.Latitude
summary.SpeedKmh = location.SpeedKmh
summary.SOCPercent = location.SOCPercent
summary.TotalMileageKm = location.TotalMileageKm
summary.LastSeen = location.LastSeen
}
if location.LastSeen >= "2026-07-03 20:11:00" {
summary.Online = true
summary.OnlineSourceCount++
}
}
if summary != nil {
sort.Strings(summary.Protocols)
summary.ServiceStatus = buildRealtimeServiceStatus(*summary)
}
return summary
}
func (m *MockStore) vehicleByVIN(vin string) VehicleRow {
for _, vehicle := range m.vehicles {
if vehicle.VIN == vin {

View File

@@ -228,6 +228,85 @@ func (s *ProductionStore) VehicleRealtime(ctx context.Context, query url.Values)
return Page[VehicleRealtimeRow]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
}
func (s *ProductionStore) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
keywords := normalizedKeywords(query.Keywords)
if len(keywords) == 0 {
return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: 0, Limit: query.Limit, Offset: query.Offset}, nil
}
placeholders := repeatPlaceholders(len(keywords))
protocolJoin := ""
args := make([]any, 0, len(keywords)*5+1)
for i := 0; i < 3; i++ {
for _, keyword := range keywords {
args = append(args, keyword)
}
}
for i := 0; i < 2; i++ {
for _, keyword := range keywords {
args = append(args, keyword)
}
}
if protocol := strings.TrimSpace(query.Protocol); protocol != "" {
protocolJoin = " AND s.protocol = ? "
args = append(args, protocol)
}
sqlText := `SELECT i.vin, COALESCE(MAX(NULLIF(i.plate, '')), '') AS plate, ` +
`COALESCE(MAX(NULLIF(i.phone, '')), '') AS phone, COALESCE(MAX(NULLIF(i.oem, '')), '') AS oem, ` +
`COALESCE(GROUP_CONCAT(DISTINCT s.protocol ORDER BY s.protocol SEPARATOR ','), '') AS protocols, ` +
`COUNT(DISTINCT s.protocol) AS source_count, ` +
`COUNT(DISTINCT CASE WHEN s.updated_at >= DATE_SUB(NOW(), INTERVAL 1 MINUTE) THEN s.protocol END) AS online_source_count, ` +
`COALESCE(DATE_FORMAT(MAX(s.updated_at), '%Y-%m-%d %H:%i:%s'), '') AS last_seen, ` +
`COALESCE(SUBSTRING_INDEX(GROUP_CONCAT(s.protocol ORDER BY s.updated_at DESC, s.protocol ASC), ',', 1), '') AS primary_protocol ` +
`FROM (` +
`SELECT b.vin, b.plate, b.phone, b.oem FROM vehicle_identity_binding b ` +
`WHERE b.vin IN (` + placeholders + `) OR b.plate IN (` + placeholders + `) OR b.phone IN (` + placeholders + `) ` +
`UNION ` +
`SELECT s.vin, COALESCE(NULLIF(MAX(NULLIF(s.plate, '')), ''), b.plate, '') AS plate, COALESCE(b.phone, '') AS phone, COALESCE(b.oem, '') AS oem ` +
`FROM vehicle_realtime_snapshot s LEFT JOIN vehicle_identity_binding b ON b.vin = s.vin ` +
`WHERE s.vin IN (` + placeholders + `) OR s.plate IN (` + placeholders + `) ` +
`GROUP BY s.vin, b.plate, b.phone, b.oem` +
`) i LEFT JOIN vehicle_realtime_snapshot s ON s.vin = i.vin ` + protocolJoin +
`WHERE i.vin IS NOT NULL AND i.vin <> '' GROUP BY i.vin`
rows, err := s.db.QueryContext(ctx, sqlText, args...)
if err != nil {
return Page[VehicleServiceOverview]{}, err
}
defer rows.Close()
byKey := map[string]*VehicleServiceOverview{}
for rows.Next() {
var overview VehicleServiceOverview
var protocols string
if err := rows.Scan(&overview.VIN, &overview.Plate, &overview.Phone, &overview.OEM, &protocols, &overview.SourceCount, &overview.OnlineSourceCount, &overview.LastSeen, &overview.PrimaryProtocol); err != nil {
return Page[VehicleServiceOverview]{}, err
}
overview.Protocols = splitCSV(protocols)
if overview.PrimaryProtocol == "" && len(overview.Protocols) > 0 {
overview.PrimaryProtocol = overview.Protocols[0]
}
overview.RealtimeCount = overview.SourceCount
overview.CoverageStatus = coverageStatus(overview.SourceCount, overview.OnlineSourceCount)
overview.ServiceStatus = buildVehicleServiceStatus(strings.TrimSpace(overview.VIN) != "", statusesForOverview(&overview))
for _, key := range []string{overview.VIN, overview.Plate, overview.Phone} {
if trimmed := strings.ToLower(strings.TrimSpace(key)); trimmed != "" {
byKey[trimmed] = &overview
}
}
}
if err := rows.Err(); err != nil {
return Page[VehicleServiceOverview]{}, err
}
items := make([]VehicleServiceOverview, 0, len(keywords))
for _, keyword := range keywords {
if overview := byKey[strings.ToLower(strings.TrimSpace(keyword))]; overview != nil {
items = append(items, *overview)
continue
}
resolution := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}}
items = append(items, *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}))
}
return Page[VehicleServiceOverview]{Items: items, Total: len(items), Limit: query.Limit, Offset: query.Offset}, nil
}
func (s *ProductionStore) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
built := buildRealtimeLocationSQL(query)
total := 0
@@ -685,3 +764,10 @@ func firstNonEmpty(values ...string) string {
}
return ""
}
func repeatPlaceholders(count int) string {
if count <= 0 {
return ""
}
return strings.TrimRight(strings.Repeat("?,", count), ",")
}

View File

@@ -24,6 +24,10 @@ type Store interface {
OpsHealth(context.Context) (OpsHealth, error)
}
type VehicleOverviewBatchStore interface {
VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error)
}
type RawFrameQuery struct {
Protocol string `json:"protocol"`
VIN string `json:"vin"`
@@ -133,30 +137,32 @@ func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, pr
}
func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
keywords := normalizedKeywords(query.Keywords)
total := len(keywords)
if query.Offset < 0 {
query.Offset = 0
keywords, total, limit, offset := overviewBatchPage(query)
if offset >= total {
return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: total, Limit: limit, Offset: offset}, nil
}
if query.Limit <= 0 || query.Limit > 200 {
query.Limit = 200
query.Keywords = keywords
query.Limit = limit
query.Offset = offset
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
page, err := batchStore.VehicleServiceOverviews(ctx, query)
if err != nil {
return Page[VehicleServiceOverview]{}, err
}
page.Total = total
page.Limit = limit
page.Offset = offset
return page, nil
}
if query.Offset >= total {
return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
}
end := query.Offset + query.Limit
if end > total {
end = total
}
items := make([]VehicleServiceOverview, 0, end-query.Offset)
for _, keyword := range keywords[query.Offset:end] {
items := make([]VehicleServiceOverview, 0, len(keywords))
for _, keyword := range keywords {
overview, err := s.VehicleServiceOverview(ctx, keyword, query.Protocol)
if err != nil {
return Page[VehicleServiceOverview]{}, err
}
items = append(items, overview)
}
return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: query.Limit, Offset: query.Offset}, nil
return Page[VehicleServiceOverview]{Items: items, Total: total, Limit: limit, Offset: offset}, nil
}
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
@@ -566,6 +572,27 @@ func normalizedKeywords(values []string) []string {
return out
}
func overviewBatchPage(query VehicleOverviewBatchQuery) ([]string, int, int, int) {
keywords := normalizedKeywords(query.Keywords)
total := len(keywords)
limit := query.Limit
offset := query.Offset
if offset < 0 {
offset = 0
}
if limit <= 0 || limit > 200 {
limit = 200
}
if offset >= total {
return []string{}, total, limit, offset
}
end := offset + limit
if end > total {
end = total
}
return keywords[offset:end], total, limit, offset
}
func vehicleSourceStatus(vehicles []VehicleRow, realtime []RealtimeLocationRow, history []HistoryLocationRow, raw []RawFrameRow, mileage []DailyMileageRow) []VehicleSourceStatus {
statusByProtocol := map[string]*VehicleSourceStatus{}
ensure := func(protocol string) *VehicleSourceStatus {

View File

@@ -0,0 +1,45 @@
package platform
import (
"context"
"net/url"
"testing"
)
type countingStore struct {
*MockStore
vehiclesCalls int
vehicleRealtimeCalls int
}
func newCountingStore() *countingStore {
return &countingStore{MockStore: NewMockStore()}
}
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
s.vehiclesCalls++
return s.MockStore.Vehicles(ctx, query)
}
func (s *countingStore) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
s.vehicleRealtimeCalls++
return s.MockStore.VehicleRealtime(ctx, query)
}
func TestVehicleServiceOverviewsUsesBatchDataPath(t *testing.T) {
store := newCountingStore()
service := NewService(store)
page, err := service.VehicleServiceOverviews(context.Background(), VehicleOverviewBatchQuery{
Keywords: []string{"粤AG18312", "LMRKH9AC2R1004087"},
Limit: 200,
})
if err != nil {
t.Fatalf("VehicleServiceOverviews returned error: %v", err)
}
if page.Total != 2 || len(page.Items) != 2 {
t.Fatalf("expected two overview rows, got %+v", page)
}
if store.vehiclesCalls > 1 || store.vehicleRealtimeCalls > 1 {
t.Fatalf("batch overview should avoid per-keyword store calls, vehicles=%d realtime=%d", store.vehiclesCalls, store.vehicleRealtimeCalls)
}
}