1628 lines
52 KiB
Go
1628 lines
52 KiB
Go
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) 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
|
||
}
|