package platform import ( "context" "net/url" "sort" "strconv" "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"` 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 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) 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) 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), 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), 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 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 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 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 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) return result }