1232 lines
40 KiB
Go
1232 lines
40 KiB
Go
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
|
||
}
|
||
|
||
var canonicalVehicleProtocols = []string{"GB32960", "JT808", "YUTONG_MQTT"}
|
||
|
||
func completeProtocolStats(stats []ProtocolStat) []ProtocolStat {
|
||
byProtocol := make(map[string]ProtocolStat, len(stats)+len(canonicalVehicleProtocols))
|
||
for _, stat := range stats {
|
||
protocol := strings.TrimSpace(stat.Protocol)
|
||
if protocol == "" {
|
||
continue
|
||
}
|
||
stat.Protocol = protocol
|
||
byProtocol[protocol] = stat
|
||
}
|
||
out := make([]ProtocolStat, 0, len(byProtocol)+len(canonicalVehicleProtocols))
|
||
seen := map[string]struct{}{}
|
||
for _, protocol := range canonicalVehicleProtocols {
|
||
stat := byProtocol[protocol]
|
||
stat.Protocol = protocol
|
||
out = append(out, stat)
|
||
seen[protocol] = struct{}{}
|
||
}
|
||
extra := make([]string, 0)
|
||
for protocol := range byProtocol {
|
||
if _, ok := seen[protocol]; !ok {
|
||
extra = append(extra, protocol)
|
||
}
|
||
}
|
||
sort.Strings(extra)
|
||
for _, protocol := range extra {
|
||
out = append(out, byProtocol[protocol])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func missingCanonicalProtocols(protocols []string) []string {
|
||
missing := make([]string, 0, len(canonicalVehicleProtocols))
|
||
for _, protocol := range canonicalVehicleProtocols {
|
||
if !containsString(protocols, protocol) {
|
||
missing = append(missing, protocol)
|
||
}
|
||
}
|
||
return missing
|
||
}
|
||
|
||
func buildVehicleCoverageSourceStatus(protocols []string, onlineProtocols []string, lastSeen string) []VehicleSourceStatus {
|
||
statuses := make([]VehicleSourceStatus, 0, len(protocols))
|
||
for _, protocol := range protocols {
|
||
protocol = strings.TrimSpace(protocol)
|
||
if protocol == "" {
|
||
continue
|
||
}
|
||
statuses = append(statuses, VehicleSourceStatus{
|
||
Protocol: protocol,
|
||
Online: containsString(onlineProtocols, protocol),
|
||
LastSeen: lastSeen,
|
||
HasRealtime: true,
|
||
})
|
||
}
|
||
return completeVehicleSourceStatus(statuses, "")
|
||
}
|
||
|
||
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)
|
||
sourceStatus = completeVehicleSourceStatus(sourceStatus, protocol)
|
||
serviceStatus := buildVehicleServiceStatus(true, sourceStatus)
|
||
resolution.ServiceStatus = serviceStatus
|
||
if identity != nil {
|
||
identity.ServiceStatus = serviceStatus
|
||
}
|
||
if summary != nil {
|
||
summary.ServiceStatus = serviceStatus
|
||
}
|
||
serviceOverview := buildVehicleServiceOverview(resolvedVIN, keyword, &resolution, identity, summary, sourceStatus, history, raw, mileage, quality)
|
||
serviceOverview.ServiceStatus = serviceStatus
|
||
return VehicleDetail{
|
||
VIN: resolvedVIN,
|
||
LookupKey: keyword,
|
||
LookupResolved: resolvedVIN != "",
|
||
Resolution: &resolution,
|
||
Identity: identity,
|
||
RealtimeSummary: summary,
|
||
ServiceStatus: serviceStatus,
|
||
ServiceOverview: serviceOverview,
|
||
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++
|
||
}
|
||
if vehicleSourceEvidenceMissing(status) {
|
||
consistency.MissingProtocols = append(consistency.MissingProtocols, status.Protocol)
|
||
}
|
||
}
|
||
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()
|
||
}
|
||
applyVehicleSourceConsistencyDiagnosis(consistency)
|
||
return consistency
|
||
}
|
||
|
||
func vehicleSourceEvidenceMissing(status VehicleSourceStatus) bool {
|
||
return strings.TrimSpace(status.Protocol) != "" &&
|
||
!status.Online &&
|
||
strings.TrimSpace(status.LastSeen) == "" &&
|
||
!status.HasRealtime &&
|
||
!status.HasHistory &&
|
||
!status.HasRaw &&
|
||
!status.HasMileage
|
||
}
|
||
|
||
func applyVehicleSourceConsistencyDiagnosis(consistency *VehicleSourceConsistency) {
|
||
if consistency == nil {
|
||
return
|
||
}
|
||
switch {
|
||
case consistency.SourceCount <= 0:
|
||
consistency.Status = "no_source"
|
||
consistency.Severity = "warning"
|
||
consistency.Title = "暂无来源"
|
||
consistency.Detail = "当前车辆没有可用于交叉校验的数据来源。"
|
||
case consistency.SourceCount == 1:
|
||
consistency.Status = "single_source"
|
||
consistency.Severity = "warning"
|
||
consistency.Title = "单一来源"
|
||
consistency.Detail = "当前车辆只有一个数据来源,无法做跨来源一致性校验。"
|
||
case consistency.OnlineSourceCount <= 0:
|
||
consistency.Status = "offline"
|
||
consistency.Severity = "error"
|
||
consistency.Title = "全部来源离线"
|
||
consistency.Detail = sourceCoverageDetail(consistency.SourceCount, consistency.OnlineSourceCount, "无法判断实时一致性。")
|
||
case len(consistency.MissingProtocols) > 0:
|
||
consistency.Status = "degraded"
|
||
consistency.Severity = "warning"
|
||
consistency.Title = "来源不完整"
|
||
consistency.Detail = sourceCoverageDetail(consistency.SourceCount, consistency.OnlineSourceCount, "车辆服务可用但缺少 "+strings.Join(consistency.MissingProtocols, "、")+" 来源。")
|
||
case consistency.OnlineSourceCount < consistency.SourceCount:
|
||
consistency.Status = "degraded"
|
||
consistency.Severity = "warning"
|
||
consistency.Title = "来源不完整"
|
||
consistency.Detail = sourceCoverageDetail(consistency.SourceCount, consistency.OnlineSourceCount, "车辆服务可用但需要关注离线来源。")
|
||
case consistency.MileageDeltaKm > 5:
|
||
consistency.Status = "mileage_divergent"
|
||
consistency.Severity = "warning"
|
||
consistency.Title = "里程差异偏大"
|
||
consistency.Detail = "多个来源的实时总里程差异超过 5 km,请核对来源解析、单位和上报时间。"
|
||
case consistency.SourceTimeDeltaSeconds > 300:
|
||
consistency.Status = "time_divergent"
|
||
consistency.Severity = "warning"
|
||
consistency.Title = "来源时间差偏大"
|
||
consistency.Detail = "多个来源的最新上报时间相差超过 5 分钟,请关注转发延迟或链路断点。"
|
||
default:
|
||
consistency.Status = "consistent"
|
||
consistency.Severity = "ok"
|
||
consistency.Title = "来源一致"
|
||
consistency.Detail = sourceCoverageDetail(consistency.SourceCount, consistency.OnlineSourceCount, "多源实时状态一致。")
|
||
}
|
||
}
|
||
|
||
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)
|
||
overview.SourceConsistency.MissingProtocols = missingProtocolsFromStatuses(statuses)
|
||
applyVehicleSourceConsistencyDiagnosis(overview.SourceConsistency)
|
||
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,
|
||
MissingProtocols: missingCanonicalProtocols(overview.Protocols),
|
||
Scope: "all_sources",
|
||
}
|
||
applyVehicleSourceConsistencyDiagnosis(overview.SourceConsistency)
|
||
}
|
||
|
||
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 completeVehicleSourceStatus(statuses []VehicleSourceStatus, protocol string) []VehicleSourceStatus {
|
||
protocol = strings.TrimSpace(protocol)
|
||
if protocol != "" {
|
||
for _, status := range statuses {
|
||
if status.Protocol == protocol {
|
||
return []VehicleSourceStatus{status}
|
||
}
|
||
}
|
||
return []VehicleSourceStatus{{Protocol: protocol}}
|
||
}
|
||
byProtocol := map[string]VehicleSourceStatus{}
|
||
for _, status := range statuses {
|
||
if strings.TrimSpace(status.Protocol) == "" {
|
||
continue
|
||
}
|
||
byProtocol[status.Protocol] = status
|
||
}
|
||
out := make([]VehicleSourceStatus, 0, len(canonicalVehicleProtocols)+len(statuses))
|
||
seen := map[string]struct{}{}
|
||
for _, protocol := range canonicalVehicleProtocols {
|
||
if status, ok := byProtocol[protocol]; ok {
|
||
out = append(out, status)
|
||
} else {
|
||
out = append(out, VehicleSourceStatus{Protocol: protocol})
|
||
}
|
||
seen[protocol] = struct{}{}
|
||
}
|
||
for _, status := range statuses {
|
||
if _, ok := seen[status.Protocol]; ok || strings.TrimSpace(status.Protocol) == "" {
|
||
continue
|
||
}
|
||
out = append(out, status)
|
||
}
|
||
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++
|
||
}
|
||
}
|
||
missingProtocols := missingProtocolsFromStatuses(statuses)
|
||
if sourceCount == 0 || sourceCount == len(missingProtocols) {
|
||
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 len(missingProtocols) > 0 {
|
||
return &VehicleServiceStatus{
|
||
Status: "degraded",
|
||
Severity: "warning",
|
||
Title: "来源不完整",
|
||
Detail: sourceCoverageDetail(sourceCount, onlineSourceCount, "车辆服务可用但缺少 "+strings.Join(missingProtocols, "、")+" 来源。"),
|
||
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 missingProtocolsFromStatuses(statuses []VehicleSourceStatus) []string {
|
||
missing := make([]string, 0, len(statuses))
|
||
for _, status := range statuses {
|
||
if vehicleSourceEvidenceMissing(status) {
|
||
missing = append(missing, status.Protocol)
|
||
}
|
||
}
|
||
return missing
|
||
}
|
||
|
||
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 len(row.MissingProtocols) > 0 {
|
||
return &VehicleServiceStatus{
|
||
Status: "degraded",
|
||
Severity: "warning",
|
||
Title: "来源不完整",
|
||
Detail: sourceCoverageDetail(row.SourceCount, row.OnlineSourceCount, "车辆服务可用但缺少 "+strings.Join(row.MissingProtocols, "、")+" 来源。"),
|
||
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 buildVehicleCoverageSourceConsistency(row VehicleCoverageRow) *VehicleSourceConsistency {
|
||
consistency := &VehicleSourceConsistency{
|
||
SourceCount: row.SourceCount,
|
||
OnlineSourceCount: row.OnlineSourceCount,
|
||
MissingProtocols: row.MissingProtocols,
|
||
Scope: "coverage",
|
||
LocatedSourceCount: 0,
|
||
}
|
||
applyVehicleSourceConsistencyDiagnosis(consistency)
|
||
return consistency
|
||
}
|
||
|
||
func buildRealtimeServiceStatus(row VehicleRealtimeRow) *VehicleServiceStatus {
|
||
return buildVehicleCoverageServiceStatus(VehicleCoverageRow{
|
||
VIN: row.VIN,
|
||
Plate: row.Plate,
|
||
Phone: row.Phone,
|
||
OEM: row.OEM,
|
||
Protocols: row.Protocols,
|
||
MissingProtocols: missingCanonicalProtocols(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
|
||
}
|