package platform import ( "context" "errors" "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 } type clientError struct { Code string Message string } func (e clientError) Error() string { return e.Message } func asClientError(err error) (clientError, bool) { var target clientError if errors.As(err, &target) { return target, true } return clientError{}, false } var canonicalVehicleProtocols = []string{"GB32960", "JT808", "YUTONG_MQTT"} var qualityAlertRuleTemplates = []QualityAlertRule{ { IssueType: "NO_SOURCE", Title: "无数据来源", Level: "P0", Owner: "平台接入", Trigger: "绑定车辆连续无 GB32960、JT808、MQTT 来源证据", Notify: "立即通知接入运维,30 分钟未恢复升级给业务责任人", SLA: "30 分钟确认", }, { IssueType: "VIN_MISSING", Title: "VIN 缺失", Level: "P0", Owner: "车辆档案", Trigger: "来源有数据但无法归并到 VIN", Notify: "通知档案维护人补齐车牌、手机号和 VIN 映射", SLA: "2 小时修复", }, { IssueType: "LINK_GAP", Title: "链路中断", Level: "P1", Owner: "链路运维", Trigger: "车辆或平台来源上报间断、Redis 在线状态过期", Notify: "通知平台转发和网关值班人,持续异常进入日报", SLA: "1 小时恢复", }, { IssueType: "FIELD_MISSING", Title: "字段缺失", Level: "P1", Owner: "协议解析", Trigger: "核心字段缺失导致定位、里程或统计不可用", Notify: "通知解析负责人核对字段映射和 RAW 样本", SLA: "当日闭环", }, } var qualityNotificationPolicies = []QualityNotificationPolicy{ { Name: "P0 实时中断", Target: "接入运维 + 业务责任人", Channel: "站内告警 / 邮件 / 企业微信", Condition: "无来源、VIN 缺失、存储不可写", EscalationMinutes: 30, AcceptanceCriteria: "来源恢复并持续 10 分钟,车辆服务可查到实时与历史证据", }, { Name: "P1 数据质量", Target: "协议解析 + 数据治理", Channel: "站内告警 / 每日汇总邮件", Condition: "字段缺失、链路间断、容量风险", EscalationMinutes: 120, AcceptanceCriteria: "核心字段恢复解析,影响车辆可通过 RAW 与历史证据复核", }, { Name: "P2 趋势关注", Target: "数智中心", Channel: "周报 / 趋势看板", Condition: "单源车辆、档案缺项、来源覆盖下降", EscalationMinutes: 1440, AcceptanceCriteria: "趋势原因已归档,责任人和后续治理动作明确", }, } var qualityIssuePriorityWeight = map[string]int{ "NO_SOURCE": 10, "VIN_MISSING": 9, "LINK_GAP": 8, "FIELD_MISSING": 7, } 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 query.IncludeFields && !rawFrameQueryHasNarrowScope(query) { return Page[RawFrameRow]{}, clientError{ Code: "RAW_FRAME_SCOPE_REQUIRED", Message: "RAW 解析字段查询需要提供车辆或时间范围", } } 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 rawFrameQueryHasNarrowScope(query RawFrameQuery) bool { return strings.TrimSpace(query.VIN) != "" || strings.TrimSpace(query.Keyword) != "" || strings.TrimSpace(query.DateFrom) != "" || strings.TrimSpace(query.DateTo) != "" || len(query.Fields) > 0 } 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) OnlineStatisticsSummary(ctx context.Context, query url.Values) (OnlineStatisticsSummary, error) { resolvedQuery, err := s.resolveVehicleQuery(ctx, query) if err != nil { return OnlineStatisticsSummary{}, err } coverage, err := s.store.VehicleCoverageSummary(ctx, resolvedQuery) if err != nil { return OnlineStatisticsSummary{}, err } serviceSummary, err := s.store.VehicleServiceSummary(ctx) if err != nil { return OnlineStatisticsSummary{}, err } opsHealth, err := s.store.OpsHealth(ctx) if err != nil { return OnlineStatisticsSummary{}, err } total := coverage.TotalVehicles online := coverage.OnlineVehicles offline := total - online if offline < 0 { offline = 0 } rate := 0.0 if total > 0 { rate = float64(online) / float64(total) * 100 } return OnlineStatisticsSummary{ VehicleCount: total, OnlineVehicleCount: online, OfflineVehicleCount: offline, OnlineRatePercent: rate, RedisOnlineKeys: opsHealth.RedisOnlineKeys, ProtocolStats: serviceSummary.Protocols, Evidence: "由车辆服务覆盖汇总、实时来源状态和 Redis 在线 key 计算", }, nil } func (s *Service) OnlineVehicleStatuses(ctx context.Context, query url.Values) (Page[OnlineVehicleStatusRow], error) { resolvedQuery, err := s.resolveVehicleQuery(ctx, query) if err != nil { return Page[OnlineVehicleStatusRow]{}, err } coverage, err := s.store.VehicleCoverage(ctx, resolvedQuery) if err != nil { return Page[OnlineVehicleStatusRow]{}, err } now := time.Now() items := make([]OnlineVehicleStatusRow, 0, len(coverage.Items)) for _, row := range coverage.Items { items = append(items, OnlineVehicleStatusRow{ VIN: row.VIN, Plate: row.Plate, Phone: row.Phone, OEM: row.OEM, Protocols: append([]string(nil), row.Protocols...), Online: row.Online, LastSeen: row.LastSeen, OfflineDurationMinutes: offlineDurationMinutes(row.Online, row.LastSeen, now), SourceCount: row.SourceCount, OnlineSourceCount: row.OnlineSourceCount, BindingStatus: row.BindingStatus, }) } return Page[OnlineVehicleStatusRow]{ Items: items, Total: coverage.Total, Limit: coverage.Limit, Offset: coverage.Offset, }, nil } func offlineDurationMinutes(online bool, lastSeen string, now time.Time) *int { if online { value := 0 return &value } parsed, ok := parseVehicleServiceTime(lastSeen) if !ok { return nil } minutes := int(now.Sub(parsed).Minutes()) if minutes < 0 { minutes = 0 } return &minutes } 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) QualityNotificationPlan(ctx context.Context, query url.Values) (QualityNotificationPlan, error) { summary, err := s.store.QualitySummary(ctx, query) if err != nil { return QualityNotificationPlan{}, err } issueQuery := cloneValues(query) if issueQuery.Get("limit") == "" { issueQuery.Set("limit", "20") } issues, err := s.store.QualityIssues(ctx, issueQuery) if err != nil { return QualityNotificationPlan{}, err } health, err := s.store.OpsHealth(ctx) if err != nil { return QualityNotificationPlan{}, err } health.Runtime = s.runtime rules := qualityNotificationRules(summary, health) activeRuleCount := 0 p0RuleCount := 0 for _, rule := range rules { if rule.Count <= 0 { continue } activeRuleCount++ if rule.Level == "P0" { p0RuleCount++ } } return QualityNotificationPlan{ Summary: summary, Rules: rules, Policies: append([]QualityNotificationPolicy(nil), qualityNotificationPolicies...), PriorityIssues: qualityPriorityIssues(issues.Items), ActiveRuleCount: activeRuleCount, P0RuleCount: p0RuleCount, }, nil } 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 qualityNotificationRules(summary QualitySummary, health OpsHealth) []QualityAlertRule { rows := make([]QualityAlertRule, 0, len(qualityAlertRuleTemplates)+1) for _, item := range qualityAlertRuleTemplates { item.Count = qualityIssueCount(summary, item.IssueType) rows = append(rows, item) } storageWritable := health.TDengineWritable && health.MySQLWritable capacityCount := len(health.CapacityFindings) capacityRule := QualityAlertRule{ IssueType: "CAPACITY_RISK", Title: "容量与存储风险", Level: "P2", Owner: "基础设施", Trigger: "Kafka Lag、连接容量、Redis 在线 Key 或存储读写异常", Notify: "容量风险进入运维日报,超过阈值升级", SLA: "当日评估", Count: capacityCount, } if !storageWritable { capacityRule.Level = "P0" capacityRule.Notify = "立即通知基础设施值班人处理存储不可写" capacityRule.SLA = "15 分钟恢复" capacityRule.Count++ } rows = append(rows, capacityRule) return rows } func qualityIssueCount(summary QualitySummary, issueType string) int { for _, item := range summary.IssueTypes { if item.Name == issueType { return item.Count } } return 0 } func qualityPriorityIssues(rows []QualityIssueRow) []QualityPriorityIssue { out := make([]QualityPriorityIssue, 0, len(rows)) for _, row := range rows { out = append(out, buildQualityPriorityIssue(row)) } sort.Slice(out, func(i, j int) bool { if out[i].Priority != out[j].Priority { return out[i].Priority == "P0" } weightDelta := qualityIssuePriorityWeight[out[j].IssueType] - qualityIssuePriorityWeight[out[i].IssueType] if weightDelta != 0 { return weightDelta < 0 } return out[i].LastSeen > out[j].LastSeen }) if len(out) > 5 { out = out[:5] } return out } func buildQualityPriorityIssue(row QualityIssueRow) QualityPriorityIssue { actionLabel, actionDetail := qualityAction(row) priority := "P1" if row.Severity == "error" || row.IssueType == "NO_SOURCE" { priority = "P0" } lookup := qualityIssueLookup(row) dateFrom := evidenceDate(row.LastSeen) dateTo := nextEvidenceDate(dateFrom) realtimeHash := buildHash("realtime", lookup, row.Protocol, nil) historyFilters := map[string]string{} if dateFrom != "" { historyFilters["dateFrom"] = dateFrom } if dateTo != "" { historyFilters["dateTo"] = dateTo } historyHash := buildHash("history", lookup, row.Protocol, historyFilters) rawFilters := cloneStringMap(historyFilters) rawFilters["tab"] = "raw" rawFilters["includeFields"] = "true" rawHash := buildHash("history-query", lookup, row.Protocol, rawFilters) vehicleHash := buildHash("detail", lookup, row.Protocol, nil) item := QualityPriorityIssue{ QualityIssueRow: row, Priority: priority, ActionLabel: actionLabel, ActionDetail: actionDetail, SLA: qualityIssueSLA(row.IssueType), VehicleLabel: qualityVehicleLabel(row), RealtimeHash: realtimeHash, HistoryHash: historyHash, RawHash: rawHash, VehicleHash: vehicleHash, } item.NotificationText = qualityNotificationText(item) return item } func qualityAction(row QualityIssueRow) (string, string) { switch { case row.IssueType == "NO_SOURCE": return "确认平台转发", "车辆已绑定但没有任何来源证据,先确认平台转发、端口和订阅。" case row.IssueType == "VIN_MISSING" || strings.TrimSpace(row.VIN) == "": return "维护身份绑定", "数据已有来源但无法归并到 VIN,优先用车牌/手机号补齐绑定。" case row.IssueType == "LINK_GAP": return "排查来源链路", "来源存在上报间断,优先检查平台转发、网络和消费延迟。" case row.IssueType == "FIELD_MISSING": return "核对解析字段", "字段缺失会影响统计和展示,优先核对解析映射和原始 RAW。" default: return "查看车辆服务", "进入车辆服务详情,结合来源证据继续排查。" } } func qualityIssueSLA(issueType string) string { for _, item := range qualityAlertRuleTemplates { if item.IssueType == issueType { return item.SLA } } return "当日闭环" } func qualityVehicleLabel(row QualityIssueRow) string { identity := firstNonEmpty(strings.TrimSpace(row.VIN), strings.TrimSpace(row.Phone), strings.TrimSpace(row.SourceEndpoint), "-") if plate := strings.TrimSpace(row.Plate); plate != "" { return plate + " / " + identity } return identity } func qualityIssueLookup(row QualityIssueRow) string { return firstNonEmpty(strings.TrimSpace(row.VIN), strings.TrimSpace(row.Plate), strings.TrimSpace(row.Phone), strings.TrimSpace(row.SourceEndpoint)) } func evidenceDate(value string) string { value = strings.TrimSpace(value) if len(value) < len("2006-01-02") { return "" } date := value[:len("2006-01-02")] if _, err := time.Parse("2006-01-02", date); err != nil { return "" } return date } func nextEvidenceDate(value string) string { if value == "" { return "" } date, err := time.Parse("2006-01-02", value) if err != nil { return "" } return date.AddDate(0, 0, 1).Format("2006-01-02") } func buildHash(page string, keyword string, protocol string, filters map[string]string) string { values := url.Values{} if keyword != "" { values.Set("keyword", keyword) } if protocol != "" { values.Set("protocol", protocol) } for key, value := range filters { if strings.TrimSpace(value) != "" { values.Set(key, value) } } query := values.Encode() if query == "" { return "#/" + page } return "#/" + page + "?" + query } func qualityNotificationText(row QualityPriorityIssue) string { return strings.Join([]string{ "【" + row.Priority + " 告警通知】" + qualityIssueTitle(row.IssueType), "车辆:" + row.VehicleLabel, "数据来源:" + row.Protocol, "问题:" + qualityIssueTitle(row.IssueType), "建议动作:" + row.ActionLabel, "SLA:" + row.SLA, "最后时间:" + firstNonEmpty(row.LastSeen, "-"), "详情:" + firstNonEmpty(row.Detail, "-"), "实时定位:" + row.RealtimeHash, "轨迹证据:" + row.HistoryHash, "RAW证据:" + row.RawHash, "车辆服务:" + row.VehicleHash, }, "\n") } func qualityIssueTitle(issueType string) string { for _, item := range qualityAlertRuleTemplates { if item.IssueType == issueType { return item.Title } } if issueType == "CAPACITY_RISK" { return "容量与存储风险" } return issueType } func cloneStringMap(values map[string]string) map[string]string { out := make(map[string]string, len(values)) for key, value := range values { out[key] = value } return out } 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 }