Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/platform/service.go

1808 lines
58 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package platform
import (
"context"
"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: "通知解析负责人核对字段映射和原始记录样本",
SLA: "当日闭环",
},
}
var qualityNotificationPolicies = []QualityNotificationPolicy{
{
Name: "P0 实时中断",
Target: "接入运维 + 业务责任人",
Channel: "站内告警 / 邮件 / 企业微信",
Condition: "无来源、VIN 缺失、存储不可写",
EscalationMinutes: 30,
AcceptanceCriteria: "来源恢复并持续 10 分钟,车辆服务可查到实时与历史证据",
},
{
Name: "P1 数据质量",
Target: "协议解析 + 数据治理",
Channel: "站内告警 / 每日汇总邮件",
Condition: "字段缺失、链路间断、容量风险",
EscalationMinutes: 120,
AcceptanceCriteria: "核心字段恢复解析,影响车辆可通过原始记录与历史证据复核",
},
{
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 buildSourceReadinessPlan(summary VehicleServiceSummary, health OpsHealth, quality QualitySummary) SourceReadinessPlan {
missingByProtocol := make(map[string]int, len(summary.MissingSources))
for _, item := range summary.MissingSources {
missingByProtocol[strings.TrimSpace(item.Protocol)] = item.Count
}
issueByType := make(map[string]int, len(quality.IssueTypes))
for _, item := range quality.IssueTypes {
issueByType[strings.TrimSpace(item.Name)] = item.Count
}
protocolStats := completeProtocolStats(summary.Protocols)
rows := make([]SourceReadinessRow, 0, len(protocolStats))
for _, stat := range protocolStats {
protocol := strings.TrimSpace(stat.Protocol)
if protocol == "" {
continue
}
missing := missingByProtocol[protocol]
row := SourceReadinessRow{
Protocol: protocol,
Role: sourceReadinessRole(protocol),
Online: stat.Online,
Total: stat.Total,
OnlineRate: sourceOnlineRate(stat),
MissingVehicles: missing,
Severity: "ok",
Status: "生产可用",
Evidence: sourceReadinessEvidence(stat, missing, health),
Action: "保持实时、轨迹、原始记录和统计链路巡检。",
Acceptance: "该来源有在线车辆、无消息积压,车辆服务可回查实时、轨迹和原始记录。",
VehiclesHash: buildHash("vehicles", "", protocol, map[string]string{"missingProtocol": protocol}),
RealtimeHash: buildHash("realtime", "", protocol, map[string]string{"online": "online"}),
HistoryHash: buildHash("history-query", "", protocol, map[string]string{"tab": "raw", "includeFields": "true"}),
AlertHash: buildHash("alert-events", "", protocol, nil),
}
if stat.Total <= 0 {
row.Severity = "error"
row.Status = "未形成来源证据"
row.Action = "确认平台账号、端口监听、订阅配置和协议接入服务是否启动。"
row.Acceptance = "该来源至少出现绑定车辆和实时原始记录。"
} else if stat.Online <= 0 {
row.Severity = "error"
row.Status = "来源全离线"
row.Action = "优先检查上游平台转发、网关连接、登录/鉴权回复和 Redis 在线 TTL。"
row.Acceptance = "该协议恢复在线车辆Redis 在线状态持续续期。"
} else if missing > summary.TotalVehicles/2 && summary.TotalVehicles > 0 {
row.Severity = "warning"
row.Status = "覆盖不足"
row.Action = "核对该协议车辆范围、绑定表和平台转发清单,确认缺失车辆是否应接入。"
row.Acceptance = "缺失来源车辆下降,单源车辆可解释。"
}
if health.KafkaLag != nil && *health.KafkaLag > 0 {
row.Severity = maxSourceSeverity(row.Severity, "warning")
row.Status = row.Status + " / Kafka积压"
row.Action = row.Action + " 同时检查 Kafka 消费 Lag避免历史和统计滞后。"
}
if issueByType["NO_SOURCE"] > 0 && stat.Total <= 0 {
row.Severity = "error"
}
rows = append(rows, row)
}
return SourceReadinessPlan{
TotalVehicles: summary.TotalVehicles,
OnlineVehicles: summary.OnlineVehicles,
KafkaLag: health.KafkaLag,
ActiveConnections: health.ActiveConnections,
RedisOnlineKeys: health.RedisOnlineKeys,
PlatformRelease: health.Runtime.PlatformRelease,
Sources: rows,
}
}
func sourceReadinessRole(protocol string) string {
switch strings.ToUpper(strings.TrimSpace(protocol)) {
case "GB32960":
return "整车与氢能实时数据主来源"
case "JT808":
return "位置、总里程和设备在线主来源"
case "YUTONG_MQTT":
return "宇通派生工况与燃料电池补充来源"
default:
return "扩展接入来源"
}
}
func sourceOnlineRate(stat ProtocolStat) float64 {
if stat.Total <= 0 {
return 0
}
return float64(stat.Online) / float64(stat.Total) * 100
}
func sourceReadinessEvidence(stat ProtocolStat, missing int, health OpsHealth) string {
parts := []string{
"在线 " + strconv.Itoa(stat.Online) + "/" + strconv.Itoa(stat.Total),
"缺失车辆 " + strconv.Itoa(missing),
}
if health.KafkaLag != nil {
parts = append(parts, "Kafka Lag "+strconv.Itoa(*health.KafkaLag))
}
if health.RedisOnlineKeys != nil {
parts = append(parts, "Redis在线Key "+strconv.Itoa(*health.RedisOnlineKeys))
}
return strings.Join(parts, "")
}
func maxSourceSeverity(left string, right string) string {
weight := map[string]int{"ok": 0, "warning": 1, "error": 2}
if weight[right] > weight[left] {
return right
}
return left
}
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) SourceReadiness(ctx context.Context) (SourceReadinessPlan, error) {
summary, err := s.store.VehicleServiceSummary(ctx)
if err != nil {
return SourceReadinessPlan{}, err
}
health, err := s.store.OpsHealth(ctx)
if err != nil {
return SourceReadinessPlan{}, err
}
quality, err := s.store.QualitySummary(ctx, url.Values{})
if err != nil {
return SourceReadinessPlan{}, err
}
health.Runtime = s.runtime
return buildSourceReadinessPlan(summary, health, quality), nil
}
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,
"原始记录:" + 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
}