Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/service.go
2026-07-04 04:06:54 +08:00

1015 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package platform
import (
"context"
"math"
"net/url"
"sort"
"strconv"
"strings"
"time"
)
type Store interface {
DashboardSummary(context.Context) (DashboardSummary, error)
Vehicles(context.Context, url.Values) (Page[VehicleRow], error)
VehicleCoverage(context.Context, url.Values) (Page[VehicleCoverageRow], error)
VehicleCoverageSummary(context.Context, url.Values) (VehicleCoverageSummary, error)
VehicleServiceSummary(context.Context) (VehicleServiceSummary, error)
VehicleRealtime(context.Context, url.Values) (Page[VehicleRealtimeRow], error)
RealtimeLocations(context.Context, url.Values) (Page[RealtimeLocationRow], error)
HistoryLocations(context.Context, url.Values) (Page[HistoryLocationRow], error)
HistoryLocationsFromTDengine(context.Context, url.Values) (Page[HistoryLocationRow], error)
RawFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
MileageSummary(context.Context, url.Values) (MileageSummary, error)
DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error)
QualitySummary(context.Context, url.Values) (QualitySummary, error)
QualityIssues(context.Context, url.Values) (Page[QualityIssueRow], error)
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"`
Keyword string `json:"keyword"`
DateFrom string `json:"dateFrom"`
DateTo string `json:"dateTo"`
Fields []string `json:"fields"`
IncludeFields bool `json:"includeFields"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type VehicleOverviewBatchQuery struct {
Keywords []string `json:"keywords"`
Protocol string `json:"protocol"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
type Service struct {
store Store
runtime RuntimeInfo
}
func NewService(store Store) *Service {
return &Service{store: store}
}
func NewServiceWithRuntime(store Store, runtime RuntimeInfo) *Service {
return &Service{store: store, runtime: runtime}
}
func (s *Service) DashboardSummary(ctx context.Context) (DashboardSummary, error) {
return s.store.DashboardSummary(ctx)
}
func (s *Service) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
return s.store.Vehicles(ctx, query)
}
func (s *Service) ResolveVehicleIdentity(ctx context.Context, keyword string, protocol string) (VehicleIdentityResolution, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}}, nil
}
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"20"}}
if protocol = strings.TrimSpace(protocol); protocol != "" {
vehicleQuery.Set("protocol", protocol)
}
vehicles, err := s.store.Vehicles(ctx, vehicleQuery)
if err != nil {
return VehicleIdentityResolution{}, err
}
return buildVehicleIdentityResolution(keyword, vehicles.Items), nil
}
func (s *Service) VehicleCoverage(ctx context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
return s.store.VehicleCoverage(ctx, query)
}
func (s *Service) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
return s.store.VehicleCoverageSummary(ctx, query)
}
func (s *Service) VehicleServiceSummary(ctx context.Context) (VehicleServiceSummary, error) {
return s.store.VehicleServiceSummary(ctx)
}
func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {
return Page[VehicleRealtimeRow]{}, err
}
return s.store.VehicleRealtime(ctx, resolvedQuery)
}
func (s *Service) VehicleServiceOverview(ctx context.Context, keyword string, protocol string) (VehicleServiceOverview, error) {
keyword = strings.TrimSpace(keyword)
protocol = strings.TrimSpace(protocol)
if batchStore, ok := s.store.(VehicleOverviewBatchStore); ok {
page, err := batchStore.VehicleServiceOverviews(ctx, VehicleOverviewBatchQuery{
Keywords: []string{keyword},
Protocol: protocol,
Limit: 1,
})
if err != nil {
return VehicleServiceOverview{}, err
}
if len(page.Items) > 0 {
return page.Items[0], nil
}
return *buildVehicleServiceOverview("", keyword, &VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}}, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}), nil
}
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"20"}}
if protocol != "" {
vehicleQuery.Set("protocol", protocol)
}
vehicles, err := s.store.Vehicles(ctx, vehicleQuery)
if err != nil {
return VehicleServiceOverview{}, err
}
resolution := buildVehicleIdentityResolution(keyword, vehicles.Items)
identity := resolveVehicleIdentity(keyword, vehicles.Items)
resolvedVIN := ""
if identity != nil && strings.TrimSpace(identity.VIN) != "" {
resolvedVIN = identity.VIN
} else if resolution.Resolved {
resolvedVIN = resolution.VIN
}
if resolvedVIN == "" {
return *buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{}), nil
}
realtimeQuery := url.Values{"vin": {resolvedVIN}, "limit": {"1"}}
if protocol != "" {
realtimeQuery.Set("protocol", protocol)
}
realtimeSummary, err := s.store.VehicleRealtime(ctx, realtimeQuery)
if err != nil {
return VehicleServiceOverview{}, err
}
var summary *VehicleRealtimeRow
if len(realtimeSummary.Items) > 0 {
summary = &realtimeSummary.Items[0]
}
sourceStatus := vehicleSourceStatus(vehicles.Items, nil, nil, nil, nil)
overview := buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, Page[QualityIssueRow]{})
return *overview, nil
}
func (s *Service) VehicleServiceOverviews(ctx context.Context, query VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error) {
keywords, total, limit, offset := overviewBatchPage(query)
if offset >= total {
return Page[VehicleServiceOverview]{Items: []VehicleServiceOverview{}, Total: total, Limit: limit, Offset: offset}, nil
}
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
}
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: limit, Offset: offset}, nil
}
func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string) (VehicleDetail, error) {
keyword := strings.TrimSpace(vin)
protocol = strings.TrimSpace(protocol)
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}}
vehicles, err := s.store.Vehicles(ctx, vehicleQuery)
if err != nil {
return VehicleDetail{}, err
}
resolution := buildVehicleIdentityResolution(keyword, vehicles.Items)
identity := resolveVehicleIdentity(keyword, vehicles.Items)
resolvedVIN := ""
if identity != nil && strings.TrimSpace(identity.VIN) != "" {
resolvedVIN = identity.VIN
} else if resolution.Resolved {
resolvedVIN = resolution.VIN
}
queryVIN := resolvedVIN
if queryVIN == "" {
queryVIN = keyword
}
if resolvedVIN == "" {
qualityQuery := url.Values{"keyword": {keyword}, "limit": {"20"}}
if protocol != "" {
qualityQuery.Set("protocol", protocol)
}
quality, err := s.store.QualityIssues(ctx, qualityQuery)
if err != nil {
return VehicleDetail{}, err
}
return VehicleDetail{
VIN: "",
LookupKey: keyword,
LookupResolved: false,
Resolution: &resolution,
ServiceStatus: buildVehicleServiceStatus(false, nil),
ServiceOverview: buildVehicleServiceOverview("", keyword, &resolution, nil, nil, nil, Page[HistoryLocationRow]{}, Page[RawFrameRow]{}, Page[DailyMileageRow]{}, quality),
Sources: []string{},
SourceStatus: []VehicleSourceStatus{},
Realtime: []RealtimeLocationRow{},
History: Page[HistoryLocationRow]{Items: []HistoryLocationRow{}, Limit: 20, Offset: 0},
Raw: Page[RawFrameRow]{Items: []RawFrameRow{}, Limit: 10, Offset: 0},
Mileage: Page[DailyMileageRow]{Items: []DailyMileageRow{}, Limit: 20, Offset: 0},
Quality: quality,
}, nil
}
realtimeQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}}
historyQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}}
rawQuery := RawFrameQuery{VIN: queryVIN, IncludeFields: true, Limit: 10}
mileageQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}}
qualityQuery := url.Values{"vin": {queryVIN}, "limit": {"20"}}
if protocol != "" {
realtimeQuery.Set("protocol", protocol)
historyQuery.Set("protocol", protocol)
rawQuery.Protocol = protocol
qualityQuery.Set("protocol", protocol)
}
realtimeSummary, err := s.store.VehicleRealtime(ctx, url.Values{"vin": {queryVIN}, "limit": {"1"}})
if err != nil {
return VehicleDetail{}, err
}
var summary *VehicleRealtimeRow
if len(realtimeSummary.Items) > 0 {
summary = &realtimeSummary.Items[0]
}
realtime, err := s.store.RealtimeLocations(ctx, realtimeQuery)
if err != nil {
return VehicleDetail{}, err
}
history, err := s.store.HistoryLocationsFromTDengine(ctx, historyQuery)
if err != nil {
return VehicleDetail{}, err
}
raw, err := s.RawFrames(ctx, rawQuery)
if err != nil {
return VehicleDetail{}, err
}
mileage, err := s.store.DailyMileage(ctx, mileageQuery)
if err != nil {
return VehicleDetail{}, err
}
quality, err := s.store.QualityIssues(ctx, qualityQuery)
if err != nil {
return VehicleDetail{}, err
}
sourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items)
sourceStatus = s.enrichVehicleSourceStatus(ctx, queryVIN, sourceStatus)
return VehicleDetail{
VIN: resolvedVIN,
LookupKey: keyword,
LookupResolved: resolvedVIN != "",
Resolution: &resolution,
Identity: identity,
RealtimeSummary: summary,
ServiceStatus: buildVehicleServiceStatus(true, sourceStatus),
ServiceOverview: buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, history, raw, mileage, quality),
SourceConsistency: buildVehicleSourceConsistency(sourceStatus, realtime.Items, protocol),
Sources: sourceNames(sourceStatus),
SourceStatus: sourceStatus,
Realtime: realtime.Items,
History: history,
Raw: raw,
Mileage: mileage,
Quality: quality,
}, nil
}
func buildVehicleSourceConsistency(statuses []VehicleSourceStatus, realtime []RealtimeLocationRow, protocol string) *VehicleSourceConsistency {
consistency := &VehicleSourceConsistency{
SourceCount: len(statuses),
Scope: "all_sources",
}
if strings.TrimSpace(protocol) != "" {
consistency.Scope = "single_source"
}
if consistency.SourceCount == 0 {
seen := map[string]struct{}{}
for _, row := range realtime {
if strings.TrimSpace(row.Protocol) == "" {
continue
}
seen[row.Protocol] = struct{}{}
}
consistency.SourceCount = len(seen)
}
for _, status := range statuses {
if status.Online {
consistency.OnlineSourceCount++
}
}
var minMileage, maxMileage float64
hasMileage := false
var minTime, maxTime time.Time
hasTime := false
for _, row := range realtime {
if row.Longitude != 0 || row.Latitude != 0 {
consistency.LocatedSourceCount++
}
if row.TotalMileageKm > 0 {
if !hasMileage {
minMileage = row.TotalMileageKm
maxMileage = row.TotalMileageKm
hasMileage = true
} else {
minMileage = math.Min(minMileage, row.TotalMileageKm)
maxMileage = math.Max(maxMileage, row.TotalMileageKm)
}
}
if parsed, ok := parseVehicleServiceTime(row.LastSeen); ok {
if !hasTime {
minTime = parsed
maxTime = parsed
hasTime = true
} else {
if parsed.Before(minTime) {
minTime = parsed
}
if parsed.After(maxTime) {
maxTime = parsed
}
}
}
}
for _, status := range statuses {
if parsed, ok := parseVehicleServiceTime(status.LastSeen); ok {
if !hasTime {
minTime = parsed
maxTime = parsed
hasTime = true
} else {
if parsed.Before(minTime) {
minTime = parsed
}
if parsed.After(maxTime) {
maxTime = parsed
}
}
}
}
if hasMileage {
consistency.MileageDeltaKm = maxMileage - minMileage
}
if hasTime {
consistency.SourceTimeDeltaSeconds = maxTime.Sub(minTime).Seconds()
}
return consistency
}
func parseVehicleServiceTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05-07:00"}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func buildVehicleServiceOverview(vin string, lookupKey string, resolution *VehicleIdentityResolution, identity *VehicleRow, summary *VehicleRealtimeRow, statuses []VehicleSourceStatus, history Page[HistoryLocationRow], raw Page[RawFrameRow], mileage Page[DailyMileageRow], quality Page[QualityIssueRow]) *VehicleServiceOverview {
overview := &VehicleServiceOverview{
VIN: strings.TrimSpace(vin),
Plate: "",
Phone: "",
OEM: "",
Protocols: sourceNames(statuses),
CoverageStatus: "no_data",
SourceCount: len(statuses),
OnlineSourceCount: 0,
HistoryCount: pageCount(history.Total, len(history.Items)),
RawCount: pageCount(raw.Total, len(raw.Items)),
MileageCount: pageCount(mileage.Total, len(mileage.Items)),
QualityIssueCount: pageCount(quality.Total, len(quality.Items)),
}
if overview.VIN == "" && resolution != nil && resolution.Resolved {
overview.VIN = strings.TrimSpace(resolution.VIN)
}
if overview.VIN == "" {
overview.VIN = strings.TrimSpace(lookupKey)
}
if resolution != nil {
overview.Plate = resolution.Plate
overview.Phone = resolution.Phone
overview.OEM = resolution.OEM
if len(overview.Protocols) == 0 {
overview.Protocols = append([]string(nil), resolution.Protocols...)
overview.SourceCount = len(overview.Protocols)
}
overview.LastSeen = latestString(overview.LastSeen, resolution.LastSeen)
}
if identity != nil {
overview.Plate = firstNonEmpty(overview.Plate, identity.Plate)
overview.Phone = firstNonEmpty(overview.Phone, identity.Phone)
overview.OEM = firstNonEmpty(overview.OEM, identity.OEM)
overview.LastSeen = latestString(overview.LastSeen, identity.LastSeen)
}
if summary != nil {
overview.Plate = firstNonEmpty(overview.Plate, summary.Plate)
overview.Phone = firstNonEmpty(overview.Phone, summary.Phone)
overview.OEM = firstNonEmpty(overview.OEM, summary.OEM)
overview.PrimaryProtocol = summary.PrimaryProtocol
overview.LastSeen = latestString(overview.LastSeen, summary.LastSeen)
overview.RealtimeCount = pageCount(summary.SourceCount, len(summary.Protocols))
if len(statuses) == 0 {
overview.SourceCount = summary.SourceCount
overview.OnlineSourceCount = summary.OnlineSourceCount
}
if len(overview.Protocols) == 0 {
overview.Protocols = append([]string(nil), summary.Protocols...)
}
}
for _, status := range statuses {
if status.Online {
overview.OnlineSourceCount++
}
if overview.PrimaryProtocol == "" && status.Online {
overview.PrimaryProtocol = status.Protocol
}
overview.LastSeen = latestString(overview.LastSeen, status.LastSeen)
}
if overview.PrimaryProtocol == "" && len(overview.Protocols) > 0 {
overview.PrimaryProtocol = overview.Protocols[0]
}
overview.CoverageStatus = coverageStatus(overview.SourceCount, overview.OnlineSourceCount)
resolved := identity != nil && strings.TrimSpace(identity.VIN) != ""
if !resolved && resolution != nil {
resolved = resolution.Resolved
}
applyVehicleServiceOverviewDerivedFields(overview, resolved)
return overview
}
func applyVehicleServiceOverviewDerivedFields(overview *VehicleServiceOverview, resolved bool) {
if overview == nil {
return
}
overview.CoverageStatus = coverageStatus(overview.SourceCount, overview.OnlineSourceCount)
overview.ServiceStatus = buildVehicleServiceStatus(resolved, statusesForOverview(overview))
overview.SourceConsistency = &VehicleSourceConsistency{
SourceCount: overview.SourceCount,
OnlineSourceCount: overview.OnlineSourceCount,
Scope: "all_sources",
}
}
func statusesForOverview(overview *VehicleServiceOverview) []VehicleSourceStatus {
statuses := make([]VehicleSourceStatus, 0, overview.SourceCount)
for index, protocol := range overview.Protocols {
statuses = append(statuses, VehicleSourceStatus{
Protocol: protocol,
Online: index < overview.OnlineSourceCount,
LastSeen: overview.LastSeen,
})
}
for len(statuses) < overview.SourceCount {
statuses = append(statuses, VehicleSourceStatus{
Protocol: "UNKNOWN",
Online: len(statuses) < overview.OnlineSourceCount,
LastSeen: overview.LastSeen,
})
}
return statuses
}
func pageCount(total int, itemCount int) int {
if total > 0 {
return total
}
return itemCount
}
func coverageStatus(sourceCount int, onlineSourceCount int) string {
if sourceCount <= 0 {
return "no_data"
}
if onlineSourceCount <= 0 {
return "offline"
}
if onlineSourceCount < sourceCount {
return "partial"
}
return "online"
}
func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
for index := range statuses {
protocol := statuses[index].Protocol
if protocol == "" {
continue
}
if !statuses[index].HasHistory {
query := url.Values{"vin": {vin}, "protocol": {protocol}, "limit": {"1"}}
if page, err := s.store.HistoryLocationsFromTDengine(ctx, query); err == nil && len(page.Items) > 0 {
statuses[index].HasHistory = true
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
}
}
if !statuses[index].HasRaw {
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, Limit: 1})
if err == nil && len(page.Items) > 0 {
statuses[index].HasRaw = true
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
}
}
}
return statuses
}
func resolveVehicleIdentity(keyword string, vehicles []VehicleRow) *VehicleRow {
keyword = strings.TrimSpace(keyword)
if keyword == "" {
return nil
}
for index := range vehicles {
if strings.EqualFold(vehicles[index].VIN, keyword) {
return &vehicles[index]
}
}
for index := range vehicles {
if strings.EqualFold(vehicles[index].Plate, keyword) || strings.EqualFold(vehicles[index].Phone, keyword) {
return &vehicles[index]
}
}
if len(vehicles) > 0 {
return &vehicles[0]
}
return nil
}
func isLikelyVIN(value string) bool {
value = strings.ToUpper(strings.TrimSpace(value))
if len(value) != 17 {
return false
}
for _, char := range value {
if char >= '0' && char <= '9' {
continue
}
if char >= 'A' && char <= 'Z' && char != 'I' && char != 'O' && char != 'Q' {
continue
}
return false
}
return true
}
func (s *Service) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {
return Page[RealtimeLocationRow]{}, err
}
return s.store.RealtimeLocations(ctx, resolvedQuery)
}
func (s *Service) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {
return Page[HistoryLocationRow]{}, err
}
return s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
}
func (s *Service) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
if query.Limit <= 0 || query.Limit > 500 {
query.Limit = 100
}
if strings.TrimSpace(query.VIN) == "" {
query.VIN = query.Keyword
}
resolvedVIN, err := s.resolveVehicleVIN(ctx, query.VIN, query.Protocol)
if err != nil {
return Page[RawFrameRow]{}, err
}
if resolvedVIN != "" {
query.VIN = resolvedVIN
}
return s.store.RawFrames(ctx, query)
}
func (s *Service) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {
return MileageSummary{}, err
}
return s.store.MileageSummary(ctx, resolvedQuery)
}
func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
if err != nil {
return Page[DailyMileageRow]{}, err
}
return s.store.DailyMileage(ctx, resolvedQuery)
}
func (s *Service) QualitySummary(ctx context.Context, query url.Values) (QualitySummary, error) {
return s.store.QualitySummary(ctx, query)
}
func (s *Service) QualityIssues(ctx context.Context, query url.Values) (Page[QualityIssueRow], error) {
return s.store.QualityIssues(ctx, query)
}
func (s *Service) OpsHealth(ctx context.Context) (OpsHealth, error) {
health, err := s.store.OpsHealth(ctx)
if err != nil {
return OpsHealth{}, err
}
health.Runtime = s.runtime
return health, nil
}
func (s *Service) resolveVehicleQuery(ctx context.Context, query url.Values) (url.Values, error) {
resolved := cloneValues(query)
vin := firstNonEmpty(resolved.Get("vin"), resolved.Get("keyword"))
if vin == "" {
return resolved, nil
}
resolvedVIN, err := s.resolveVehicleVIN(ctx, vin, resolved.Get("protocol"))
if err != nil {
return nil, err
}
if resolvedVIN != "" {
resolved.Set("vin", resolvedVIN)
}
return resolved, nil
}
func (s *Service) resolveVehicleVIN(ctx context.Context, keyword string, protocol string) (string, error) {
keyword = strings.TrimSpace(keyword)
if keyword == "" || isLikelyVIN(keyword) {
return keyword, nil
}
vehicleQuery := url.Values{"keyword": {keyword}, "limit": {"10"}}
if protocol = strings.TrimSpace(protocol); protocol != "" {
vehicleQuery.Set("protocol", protocol)
}
vehicles, err := s.store.Vehicles(ctx, vehicleQuery)
if err != nil {
return "", err
}
identity := resolveVehicleIdentity(keyword, vehicles.Items)
if identity == nil || strings.TrimSpace(identity.VIN) == "" {
return keyword, nil
}
return identity.VIN, nil
}
func cloneValues(values url.Values) url.Values {
cloned := make(url.Values, len(values))
for key, current := range values {
cloned[key] = append([]string(nil), current...)
}
return cloned
}
func normalizedKeywords(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
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 {
protocol = strings.TrimSpace(protocol)
if protocol == "" {
return nil
}
if current := statusByProtocol[protocol]; current != nil {
return current
}
current := &VehicleSourceStatus{Protocol: protocol}
statusByProtocol[protocol] = current
return current
}
setLastSeen := func(current *VehicleSourceStatus, value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if current.LastSeen == "" || value > current.LastSeen {
current.LastSeen = value
}
}
for _, row := range vehicles {
if current := ensure(row.Protocol); current != nil {
current.Online = current.Online || row.Online
setLastSeen(current, row.LastSeen)
}
}
for _, row := range realtime {
if current := ensure(row.Protocol); current != nil {
current.HasRealtime = true
setLastSeen(current, row.LastSeen)
}
}
for _, row := range history {
if current := ensure(row.Protocol); current != nil {
current.HasHistory = true
setLastSeen(current, firstNonEmpty(row.ServerTime, row.DeviceTime))
}
}
for _, row := range raw {
if current := ensure(row.Protocol); current != nil {
current.HasRaw = true
setLastSeen(current, firstNonEmpty(row.ServerTime, row.DeviceTime))
}
}
for _, row := range mileage {
if current := ensure(row.Source); current != nil {
current.HasMileage = true
setLastSeen(current, row.Date)
}
}
out := make([]VehicleSourceStatus, 0, len(statusByProtocol))
for _, current := range statusByProtocol {
out = append(out, *current)
}
sort.Slice(out, func(i, j int) bool { return out[i].Protocol < out[j].Protocol })
return out
}
func buildVehicleServiceStatus(resolved bool, statuses []VehicleSourceStatus) *VehicleServiceStatus {
if !resolved {
return &VehicleServiceStatus{
Status: "identity_required",
Severity: "warning",
Title: "身份未绑定",
Detail: "车辆关键词暂未解析到 VIN需先维护身份绑定后才能形成完整车辆服务。",
}
}
sourceCount := len(statuses)
onlineSourceCount := 0
for _, status := range statuses {
if status.Online {
onlineSourceCount++
}
}
if sourceCount == 0 {
return &VehicleServiceStatus{
Status: "no_data",
Severity: "warning",
Title: "暂无数据来源",
Detail: "车辆已解析,但暂未查询到 32960、808 或 MQTT 数据来源。",
SourceCount: sourceCount,
OnlineSourceCount: onlineSourceCount,
}
}
if onlineSourceCount == 0 {
return &VehicleServiceStatus{
Status: "offline",
Severity: "error",
Title: "车辆离线",
Detail: "所有已知数据来源均未在线,需要检查平台转发、终端上报或链路状态。",
SourceCount: sourceCount,
OnlineSourceCount: onlineSourceCount,
}
}
if onlineSourceCount < sourceCount {
return &VehicleServiceStatus{
Status: "degraded",
Severity: "warning",
Title: "部分来源离线",
Detail: sourceCoverageDetail(sourceCount, onlineSourceCount, "车辆服务可用但需要关注离线来源。"),
SourceCount: sourceCount,
OnlineSourceCount: onlineSourceCount,
}
}
return &VehicleServiceStatus{
Status: "healthy",
Severity: "ok",
Title: "服务正常",
Detail: sourceCoverageDetail(sourceCount, onlineSourceCount, "全部已知来源在线。"),
SourceCount: sourceCount,
OnlineSourceCount: onlineSourceCount,
}
}
func buildVehicleCoverageServiceStatus(row VehicleCoverageRow) *VehicleServiceStatus {
if row.BindingStatus != "bound" {
return &VehicleServiceStatus{
Status: "identity_required",
Severity: "warning",
Title: "身份未绑定",
Detail: "车辆身份绑定不完整,需先维护 VIN、车牌或手机号映射。",
SourceCount: row.SourceCount,
OnlineSourceCount: row.OnlineSourceCount,
}
}
if row.SourceCount == 0 {
return &VehicleServiceStatus{
Status: "no_data",
Severity: "warning",
Title: "暂无数据来源",
Detail: "车辆已绑定,但暂未查询到 32960、808 或 MQTT 数据来源。",
SourceCount: row.SourceCount,
OnlineSourceCount: row.OnlineSourceCount,
}
}
if row.OnlineSourceCount == 0 {
return &VehicleServiceStatus{
Status: "offline",
Severity: "error",
Title: "车辆离线",
Detail: "所有已知数据来源均未在线,需要检查平台转发、终端上报或链路状态。",
SourceCount: row.SourceCount,
OnlineSourceCount: row.OnlineSourceCount,
}
}
if row.OnlineSourceCount < row.SourceCount {
return &VehicleServiceStatus{
Status: "degraded",
Severity: "warning",
Title: "部分来源离线",
Detail: sourceCoverageDetail(row.SourceCount, row.OnlineSourceCount, "车辆服务可用但需要关注离线来源。"),
SourceCount: row.SourceCount,
OnlineSourceCount: row.OnlineSourceCount,
}
}
return &VehicleServiceStatus{
Status: "healthy",
Severity: "ok",
Title: "服务正常",
Detail: sourceCoverageDetail(row.SourceCount, row.OnlineSourceCount, "全部已知来源在线。"),
SourceCount: row.SourceCount,
OnlineSourceCount: row.OnlineSourceCount,
}
}
func buildRealtimeServiceStatus(row VehicleRealtimeRow) *VehicleServiceStatus {
return buildVehicleCoverageServiceStatus(VehicleCoverageRow{
VIN: row.VIN,
Plate: row.Plate,
Phone: row.Phone,
OEM: row.OEM,
Protocols: row.Protocols,
SourceCount: row.SourceCount,
OnlineSourceCount: row.OnlineSourceCount,
Online: row.Online,
LastSeen: row.LastSeen,
BindingStatus: row.BindingStatus,
})
}
func sourceCoverageDetail(sourceCount int, onlineSourceCount int, suffix string) string {
return strconv.Itoa(onlineSourceCount) + "/" + strconv.Itoa(sourceCount) + " 个来源在线," + suffix
}
func latestString(left string, right string) string {
left = strings.TrimSpace(left)
right = strings.TrimSpace(right)
if left == "" {
return right
}
if right == "" || left >= right {
return left
}
return right
}
func sourceNames(statuses []VehicleSourceStatus) []string {
out := make([]string, 0, len(statuses))
for _, status := range statuses {
out = append(out, status.Protocol)
}
return out
}
func appendIfMissing(values []string, value string) []string {
value = strings.TrimSpace(value)
if value == "" {
return values
}
for _, current := range values {
if current == value {
return values
}
}
return append(values, value)
}
func buildVehicleIdentityResolution(keyword string, vehicles []VehicleRow) VehicleIdentityResolution {
keyword = strings.TrimSpace(keyword)
result := VehicleIdentityResolution{LookupKey: keyword, Protocols: []string{}}
identity := resolveVehicleIdentity(keyword, vehicles)
if identity == nil || strings.TrimSpace(identity.VIN) == "" {
if isLikelyVIN(keyword) {
result.Resolved = true
result.VIN = keyword
}
return result
}
result.Resolved = true
result.VIN = identity.VIN
result.Plate = identity.Plate
result.Phone = identity.Phone
result.OEM = identity.OEM
for _, vehicle := range vehicles {
if !strings.EqualFold(vehicle.VIN, identity.VIN) {
continue
}
if result.ServiceStatus == nil && vehicle.ServiceStatus != nil {
result.ServiceStatus = vehicle.ServiceStatus
}
if vehicle.Online {
result.Online = true
}
if vehicle.LastSeen > result.LastSeen {
result.LastSeen = vehicle.LastSeen
}
if result.Plate == "" {
result.Plate = vehicle.Plate
}
if result.Phone == "" {
result.Phone = vehicle.Phone
}
if result.OEM == "" {
result.OEM = vehicle.OEM
}
result.Protocols = appendIfMissing(result.Protocols, vehicle.Protocol)
}
sort.Strings(result.Protocols)
if result.ServiceStatus == nil {
result.ServiceStatus = buildVehicleCoverageServiceStatus(VehicleCoverageRow{
VIN: result.VIN,
Plate: result.Plate,
Phone: result.Phone,
OEM: result.OEM,
Protocols: result.Protocols,
SourceCount: len(result.Protocols),
OnlineSourceCount: boolToInt(result.Online),
Online: result.Online,
LastSeen: result.LastSeen,
BindingStatus: "bound",
})
}
return result
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}