Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/service.go
2026-07-03 23:25:39 +08:00

290 lines
9.3 KiB
Go

package platform
import (
"context"
"net/url"
"sort"
"strings"
)
type Store interface {
DashboardSummary(context.Context) (DashboardSummary, error)
Vehicles(context.Context, url.Values) (Page[VehicleRow], error)
VehicleCoverage(context.Context, url.Values) (Page[VehicleCoverageRow], 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 RawFrameQuery struct {
Protocol string `json:"protocol"`
VIN string `json:"vin"`
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 Service struct {
store Store
}
func NewService(store Store) *Service {
return &Service{store: store}
}
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) VehicleCoverage(ctx context.Context, query url.Values) (Page[VehicleCoverageRow], error) {
return s.store.VehicleCoverage(ctx, query)
}
func (s *Service) VehicleRealtime(ctx context.Context, query url.Values) (Page[VehicleRealtimeRow], error) {
return s.store.VehicleRealtime(ctx, query)
}
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
}
identity := resolveVehicleIdentity(keyword, vehicles.Items)
resolvedVIN := keyword
if identity != nil && strings.TrimSpace(identity.VIN) != "" {
resolvedVIN = identity.VIN
}
realtimeQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}}
historyQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}}
rawQuery := RawFrameQuery{VIN: resolvedVIN, IncludeFields: true, Limit: 10}
mileageQuery := url.Values{"vin": {resolvedVIN}, "limit": {"20"}}
qualityQuery := url.Values{"vin": {resolvedVIN}, "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": {resolvedVIN}, "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, resolvedVIN, sourceStatus)
return VehicleDetail{
VIN: resolvedVIN,
Identity: identity,
RealtimeSummary: summary,
Sources: sourceNames(sourceStatus),
SourceStatus: sourceStatus,
Realtime: realtime.Items,
History: history,
Raw: raw,
Mileage: mileage,
Quality: quality,
}, nil
}
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 (s *Service) RealtimeLocations(ctx context.Context, query url.Values) (Page[RealtimeLocationRow], error) {
return s.store.RealtimeLocations(ctx, query)
}
func (s *Service) HistoryLocations(ctx context.Context, query url.Values) (Page[HistoryLocationRow], error) {
return s.store.HistoryLocationsFromTDengine(ctx, query)
}
func (s *Service) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
if query.Limit <= 0 || query.Limit > 500 {
query.Limit = 100
}
return s.store.RawFrames(ctx, query)
}
func (s *Service) MileageSummary(ctx context.Context, query url.Values) (MileageSummary, error) {
return s.store.MileageSummary(ctx, query)
}
func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[DailyMileageRow], error) {
return s.store.DailyMileage(ctx, query)
}
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) {
return s.store.OpsHealth(ctx)
}
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 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
}