4765 lines
166 KiB
Go
4765 lines
166 KiB
Go
package platform
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/csv"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"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)
|
||
MileageStatistics(context.Context, url.Values) (MileageStatistics, 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 VehicleProfileStore interface {
|
||
VehicleProfile(context.Context, string) (VehicleProfile, bool, error)
|
||
SaveVehicleProfile(context.Context, string, VehicleProfileInput) (VehicleProfile, error)
|
||
SyncVehicleProfiles(context.Context, VehicleProfileSyncRequest) (VehicleProfileSyncResult, error)
|
||
}
|
||
|
||
type VehicleSourceEvidenceStore interface {
|
||
VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, 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"`
|
||
SkipCount bool `json:"-"`
|
||
}
|
||
|
||
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
|
||
exportsMu sync.RWMutex
|
||
exports map[string]*HistoryExportJob
|
||
exportDir string
|
||
exportSlots chan struct{}
|
||
}
|
||
|
||
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,
|
||
BoundVehicles: summary.BoundVehicles,
|
||
IdentityRequiredVehicles: summary.IdentityRequiredVehicles,
|
||
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 newService(store, RuntimeInfo{})
|
||
}
|
||
|
||
func NewServiceWithRuntime(store Store, runtime RuntimeInfo) *Service {
|
||
return newService(store, runtime)
|
||
}
|
||
|
||
func newService(store Store, runtime RuntimeInfo) *Service {
|
||
exportDir := strings.TrimSpace(runtime.ExportDir)
|
||
if exportDir == "" {
|
||
exportDir = filepath.Join(os.TempDir(), "lingniu-vehicle-platform-exports")
|
||
}
|
||
service := &Service{store: store, runtime: runtime, exports: map[string]*HistoryExportJob{}, exportDir: exportDir, exportSlots: make(chan struct{}, 1)}
|
||
service.loadHistoryExports()
|
||
return service
|
||
}
|
||
|
||
func (s *Service) DashboardSummary(ctx context.Context) (DashboardSummary, error) {
|
||
return s.store.DashboardSummary(ctx)
|
||
}
|
||
|
||
const (
|
||
monitorVehicleLimit = 10000
|
||
monitorPointLimit = 2000
|
||
monitorPointZoom = 11
|
||
monitorClusterGridPixels = 64
|
||
vehicleSearchKeywordLimit = 100
|
||
)
|
||
|
||
type monitorBounds struct {
|
||
minLongitude float64
|
||
minLatitude float64
|
||
maxLongitude float64
|
||
maxLatitude float64
|
||
}
|
||
|
||
type monitorAlertStore interface {
|
||
ActiveAlertVINs(context.Context, string) ([]string, error)
|
||
}
|
||
|
||
func parseMonitorBounds(value string) (monitorBounds, bool, error) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return monitorBounds{}, false, nil
|
||
}
|
||
parts := strings.Split(value, ",")
|
||
if len(parts) != 4 {
|
||
return monitorBounds{}, false, clientError{Code: "MONITOR_BOUNDS_INVALID", Message: "地图视口必须为最小经度、最小纬度、最大经度、最大纬度"}
|
||
}
|
||
values := [4]float64{}
|
||
for index, part := range parts {
|
||
parsed, err := strconv.ParseFloat(strings.TrimSpace(part), 64)
|
||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||
return monitorBounds{}, false, clientError{Code: "MONITOR_BOUNDS_INVALID", Message: "地图视口坐标无效"}
|
||
}
|
||
values[index] = parsed
|
||
}
|
||
if values[0] < -180 || values[2] > 180 || values[1] < -90 || values[3] > 90 || values[0] >= values[2] || values[1] >= values[3] {
|
||
return monitorBounds{}, false, clientError{Code: "MONITOR_BOUNDS_INVALID", Message: "地图视口范围无效"}
|
||
}
|
||
return monitorBounds{minLongitude: values[0], minLatitude: values[1], maxLongitude: values[2], maxLatitude: values[3]}, true, nil
|
||
}
|
||
|
||
func (bounds monitorBounds) contains(longitude, latitude float64) bool {
|
||
return longitude >= bounds.minLongitude && longitude <= bounds.maxLongitude && latitude >= bounds.minLatitude && latitude <= bounds.maxLatitude
|
||
}
|
||
|
||
func validRealtimeCoordinate(longitude, latitude float64) bool {
|
||
return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && (longitude != 0 || latitude != 0)
|
||
}
|
||
|
||
func monitorVehicleStatus(row VehicleRealtimeRow) string {
|
||
if strings.TrimSpace(row.LastSeen) == "" {
|
||
return "unknown"
|
||
}
|
||
if !row.Online {
|
||
return "offline"
|
||
}
|
||
if row.SpeedKmh > 3 {
|
||
return "driving"
|
||
}
|
||
return "idle"
|
||
}
|
||
|
||
func normalizeMonitorQuery(query url.Values) url.Values {
|
||
next := make(url.Values, len(query)+2)
|
||
for key, values := range query {
|
||
next[key] = append([]string(nil), values...)
|
||
}
|
||
next.Set("limit", strconv.Itoa(monitorVehicleLimit))
|
||
next.Set("offset", "0")
|
||
if next.Get("vin") == "" && strings.TrimSpace(next.Get("keywords")) == "" && strings.TrimSpace(next.Get("keyword")) != "" {
|
||
next.Set("vin", strings.TrimSpace(next.Get("keyword")))
|
||
}
|
||
switch strings.TrimSpace(next.Get("status")) {
|
||
case "online":
|
||
next.Set("online", "online")
|
||
case "offline":
|
||
next.Set("online", "offline")
|
||
}
|
||
return next
|
||
}
|
||
|
||
func vehicleSearchKeywords(query url.Values) []string {
|
||
rawValues := query["keywords"]
|
||
if len(rawValues) == 0 {
|
||
rawValues = []string{firstNonEmpty(query.Get("vin"), query.Get("keyword"))}
|
||
}
|
||
result := make([]string, 0, len(rawValues))
|
||
seen := map[string]struct{}{}
|
||
for _, raw := range rawValues {
|
||
parts := strings.FieldsFunc(raw, func(char rune) bool {
|
||
switch char {
|
||
case ',', ',', '、', ';', ';', '\n', '\r', '\t', ' ':
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
})
|
||
for _, part := range parts {
|
||
part = strings.TrimSpace(part)
|
||
if part == "" {
|
||
continue
|
||
}
|
||
key := strings.ToLower(part)
|
||
if _, exists := seen[key]; exists {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
result = append(result, part)
|
||
if len(result) == vehicleSearchKeywordLimit {
|
||
return result
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func matchesMonitorStatus(row VehicleRealtimeRow, requested string) bool {
|
||
requested = strings.TrimSpace(requested)
|
||
if requested == "no_location" {
|
||
return !row.LocationAvailable
|
||
}
|
||
return requested == "" || monitorVehicleStatus(row) == requested || requested == "online" && row.Online
|
||
}
|
||
|
||
func (s *Service) MonitorSummary(ctx context.Context, query url.Values) (MonitorSummary, error) {
|
||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||
if err != nil {
|
||
return MonitorSummary{}, err
|
||
}
|
||
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery))
|
||
if err != nil {
|
||
return MonitorSummary{}, err
|
||
}
|
||
return s.buildMonitorSummary(ctx, query, vehicles)
|
||
}
|
||
|
||
func (s *Service) buildMonitorSummary(ctx context.Context, query url.Values, vehicles Page[VehicleRealtimeRow]) (MonitorSummary, error) {
|
||
dashboard, err := s.store.DashboardSummary(ctx)
|
||
if err != nil {
|
||
return MonitorSummary{}, err
|
||
}
|
||
result := MonitorSummary{
|
||
ActiveToday: dashboard.ActiveToday,
|
||
FrameToday: dashboard.FrameToday,
|
||
Truncated: vehicles.Total > len(vehicles.Items),
|
||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||
}
|
||
activeAlertVINs := map[string]struct{}{}
|
||
if store, ok := s.store.(monitorAlertStore); ok {
|
||
vins, alertErr := store.ActiveAlertVINs(ctx, strings.TrimSpace(query.Get("protocol")))
|
||
if alertErr != nil {
|
||
return MonitorSummary{}, alertErr
|
||
}
|
||
for _, vin := range vins {
|
||
activeAlertVINs[strings.TrimSpace(vin)] = struct{}{}
|
||
}
|
||
result.AlertDataAvailable = true
|
||
}
|
||
for _, vehicle := range vehicles.Items {
|
||
if !matchesMonitorStatus(vehicle, query.Get("status")) {
|
||
continue
|
||
}
|
||
result.TotalVehicles++
|
||
if vehicle.LocationAvailable {
|
||
result.LocationVehicles++
|
||
} else {
|
||
result.NoLocationVehicles++
|
||
}
|
||
if _, active := activeAlertVINs[vehicle.VIN]; active {
|
||
result.AlertVehicles++
|
||
}
|
||
switch monitorVehicleStatus(vehicle) {
|
||
case "driving":
|
||
result.DrivingVehicles++
|
||
result.OnlineVehicles++
|
||
case "idle":
|
||
result.IdleVehicles++
|
||
result.OnlineVehicles++
|
||
case "offline":
|
||
result.OfflineVehicles++
|
||
default:
|
||
result.UnknownVehicles++
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) MonitorWorkspace(ctx context.Context, query url.Values) (MonitorWorkspaceResponse, error) {
|
||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||
if err != nil {
|
||
return MonitorWorkspaceResponse{}, err
|
||
}
|
||
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery))
|
||
if err != nil {
|
||
return MonitorWorkspaceResponse{}, err
|
||
}
|
||
summary, err := s.buildMonitorSummary(ctx, query, vehicles)
|
||
if err != nil {
|
||
return MonitorWorkspaceResponse{}, err
|
||
}
|
||
monitorMap, err := buildMonitorMapResponse(vehicles, query)
|
||
if err != nil {
|
||
return MonitorWorkspaceResponse{}, err
|
||
}
|
||
railLimit := parsePositive(query.Get("railLimit"), 200)
|
||
if railLimit > 200 {
|
||
railLimit = 200
|
||
}
|
||
if railLimit > len(vehicles.Items) {
|
||
railLimit = len(vehicles.Items)
|
||
}
|
||
railItems := append([]VehicleRealtimeRow(nil), vehicles.Items[:railLimit]...)
|
||
return MonitorWorkspaceResponse{
|
||
Summary: summary,
|
||
Vehicles: Page[VehicleRealtimeRow]{Items: railItems, Total: vehicles.Total, Limit: railLimit, Offset: 0},
|
||
Map: monitorMap,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) MonitorMap(ctx context.Context, query url.Values) (MonitorMapResponse, error) {
|
||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||
if err != nil {
|
||
return MonitorMapResponse{}, err
|
||
}
|
||
vehicles, err := s.store.VehicleRealtime(ctx, normalizeMonitorQuery(scopedQuery))
|
||
if err != nil {
|
||
return MonitorMapResponse{}, err
|
||
}
|
||
return buildMonitorMapResponse(vehicles, query)
|
||
}
|
||
|
||
func buildMonitorMapResponse(vehicles Page[VehicleRealtimeRow], query url.Values) (MonitorMapResponse, error) {
|
||
zoom := parsePositive(query.Get("zoom"), 5)
|
||
if zoom > 20 {
|
||
zoom = 20
|
||
}
|
||
bounds, hasBounds, err := parseMonitorBounds(query.Get("bounds"))
|
||
if err != nil {
|
||
return MonitorMapResponse{}, err
|
||
}
|
||
result := MonitorMapResponse{
|
||
Mode: "clusters",
|
||
Zoom: zoom,
|
||
Truncated: vehicles.Total > len(vehicles.Items),
|
||
Points: []MonitorMapPoint{},
|
||
Clusters: []MonitorMapCluster{},
|
||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||
}
|
||
points := make([]MonitorMapPoint, 0, len(vehicles.Items))
|
||
for _, vehicle := range vehicles.Items {
|
||
if !matchesMonitorStatus(vehicle, query.Get("status")) {
|
||
continue
|
||
}
|
||
if !vehicle.LocationAvailable || vehicle.Longitude < 73 || vehicle.Longitude > 135 || vehicle.Latitude < 18 || vehicle.Latitude > 54 {
|
||
continue
|
||
}
|
||
result.Total++
|
||
if hasBounds && !bounds.contains(vehicle.Longitude, vehicle.Latitude) {
|
||
continue
|
||
}
|
||
points = append(points, MonitorMapPoint{
|
||
VIN: vehicle.VIN, Plate: vehicle.Plate, Protocol: vehicle.PrimaryProtocol, Protocols: vehicle.Protocols,
|
||
Longitude: vehicle.Longitude, Latitude: vehicle.Latitude, SpeedKmh: vehicle.SpeedKmh,
|
||
SOCPercent: vehicle.SOCPercent, TotalMileageKm: vehicle.TotalMileageKm, LastSeen: vehicle.LastSeen,
|
||
ReportIntervalMs: vehicle.ReportIntervalMs,
|
||
LocationSource: vehicle.LocationSource,
|
||
LocationConflict: vehicle.LocationConflict,
|
||
Status: monitorVehicleStatus(vehicle),
|
||
})
|
||
}
|
||
if zoom >= monitorPointZoom && len(points) <= monitorPointLimit {
|
||
result.Mode = "points"
|
||
result.Points = points
|
||
return result, nil
|
||
}
|
||
type clusterBucket struct {
|
||
cluster MonitorMapCluster
|
||
points []MonitorMapPoint
|
||
}
|
||
cellSize := 360 * monitorClusterGridPixels / (256 * math.Pow(2, float64(zoom)))
|
||
if cellSize < 0.01 {
|
||
cellSize = 0.01
|
||
}
|
||
clusters := map[string]*clusterBucket{}
|
||
for {
|
||
clusters = make(map[string]*clusterBucket)
|
||
for _, point := range points {
|
||
x := int(math.Floor(point.Longitude / cellSize))
|
||
y := int(math.Floor(point.Latitude / cellSize))
|
||
key := strconv.Itoa(x) + ":" + strconv.Itoa(y)
|
||
bucket := clusters[key]
|
||
if bucket == nil {
|
||
bucket = &clusterBucket{cluster: MonitorMapCluster{ID: key}}
|
||
clusters[key] = bucket
|
||
}
|
||
cluster := &bucket.cluster
|
||
bucket.points = append(bucket.points, point)
|
||
cluster.Count++
|
||
cluster.Longitude += (point.Longitude - cluster.Longitude) / float64(cluster.Count)
|
||
cluster.Latitude += (point.Latitude - cluster.Latitude) / float64(cluster.Count)
|
||
switch point.Status {
|
||
case "driving":
|
||
cluster.Driving++
|
||
cluster.Online++
|
||
case "idle":
|
||
cluster.Idle++
|
||
cluster.Online++
|
||
case "offline":
|
||
cluster.Offline++
|
||
default:
|
||
cluster.Unknown++
|
||
}
|
||
}
|
||
if len(clusters) <= monitorPointLimit || cellSize >= 180 {
|
||
break
|
||
}
|
||
cellSize *= 2
|
||
}
|
||
keys := make([]string, 0, len(clusters))
|
||
for key := range clusters {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
for _, key := range keys {
|
||
bucket := clusters[key]
|
||
if bucket.cluster.Count == 1 {
|
||
result.Points = append(result.Points, bucket.points[0])
|
||
continue
|
||
}
|
||
result.Clusters = append(result.Clusters, bucket.cluster)
|
||
}
|
||
if len(result.Points) > 0 {
|
||
if len(result.Clusters) > 0 {
|
||
result.Mode = "mixed"
|
||
} else {
|
||
result.Mode = "points"
|
||
}
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
func (s *Service) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
|
||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||
if err != nil {
|
||
return Page[VehicleRow]{}, err
|
||
}
|
||
return s.store.Vehicles(ctx, scopedQuery)
|
||
}
|
||
|
||
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) {
|
||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||
if err != nil {
|
||
return Page[VehicleCoverageRow]{}, err
|
||
}
|
||
return s.store.VehicleCoverage(ctx, scopedQuery)
|
||
}
|
||
|
||
func (s *Service) VehicleCoverageSummary(ctx context.Context, query url.Values) (VehicleCoverageSummary, error) {
|
||
scopedQuery, err := applyPrincipalVehicleScope(ctx, query)
|
||
if err != nil {
|
||
return VehicleCoverageSummary{}, err
|
||
}
|
||
return s.store.VehicleCoverageSummary(ctx, scopedQuery)
|
||
}
|
||
|
||
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 principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" {
|
||
resolvedScopeVIN, err := s.resolveVehicleVIN(ctx, keyword, protocol)
|
||
if err != nil {
|
||
return VehicleServiceOverview{}, err
|
||
}
|
||
if err := authorizeVehicleVIN(ctx, resolvedScopeVIN); err != nil {
|
||
return VehicleServiceOverview{}, err
|
||
}
|
||
}
|
||
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"}}
|
||
vehicleQuery, err := applyPrincipalVehicleScope(ctx, vehicleQuery)
|
||
if err != nil {
|
||
return VehicleDetail{}, err
|
||
}
|
||
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 err := authorizeVehicleVIN(ctx, queryVIN); err != nil {
|
||
return VehicleDetail{}, err
|
||
}
|
||
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)
|
||
}
|
||
historyQuery, err = applyPrincipalHistoryTimeScope(ctx, queryVIN, historyQuery)
|
||
if err != nil {
|
||
return VehicleDetail{}, err
|
||
}
|
||
rawQuery.DateFrom = historyQuery.Get("dateFrom")
|
||
rawQuery.DateTo = historyQuery.Get("dateTo")
|
||
mileageQuery, err = applyPrincipalVehicleScope(ctx, mileageQuery)
|
||
if err != nil {
|
||
return VehicleDetail{}, err
|
||
}
|
||
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
|
||
profile, err := s.VehicleProfile(ctx, resolvedVIN)
|
||
if err != nil {
|
||
return VehicleDetail{}, err
|
||
}
|
||
return VehicleDetail{
|
||
VIN: resolvedVIN,
|
||
LookupKey: keyword,
|
||
LookupResolved: resolvedVIN != "",
|
||
Resolution: &resolution,
|
||
Identity: identity,
|
||
Profile: &profile,
|
||
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 (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) {
|
||
vin = strings.TrimSpace(vin)
|
||
if vin == "" {
|
||
return VehicleSourceEvidence{}, clientError{Code: "VEHICLE_VIN_REQUIRED", Message: "车辆 VIN 不能为空"}
|
||
}
|
||
if err := authorizeVehicleVIN(ctx, vin); err != nil {
|
||
return VehicleSourceEvidence{}, err
|
||
}
|
||
date = strings.TrimSpace(date)
|
||
if date == "" {
|
||
date = time.Now().Format("2006-01-02")
|
||
} else if _, err := time.Parse("2006-01-02", date); err != nil {
|
||
return VehicleSourceEvidence{}, clientError{Code: "INVALID_DATE", Message: "里程日期格式应为 YYYY-MM-DD"}
|
||
}
|
||
if err := authorizeVehicleDailyEvidenceDate(ctx, vin, date); err != nil {
|
||
return VehicleSourceEvidence{}, err
|
||
}
|
||
store, ok := s.store.(VehicleSourceEvidenceStore)
|
||
if !ok {
|
||
return VehicleSourceEvidence{}, errors.New("vehicle source evidence store is not configured")
|
||
}
|
||
evidence, err := store.VehicleSourceEvidence(ctx, vin, date)
|
||
if err != nil {
|
||
return VehicleSourceEvidence{}, err
|
||
}
|
||
evidence.VIN = vin
|
||
evidence.MileageDate = date
|
||
if evidence.LocationSources == nil {
|
||
evidence.LocationSources = []VehicleLocationSourceEvidence{}
|
||
}
|
||
if evidence.MileageSources == nil {
|
||
evidence.MileageSources = []VehicleMileageSourceEvidence{}
|
||
}
|
||
|
||
realtime, err := s.store.VehicleRealtime(ctx, url.Values{"vin": {vin}, "limit": {"1"}})
|
||
if err != nil {
|
||
return VehicleSourceEvidence{}, err
|
||
}
|
||
var summary *VehicleRealtimeRow
|
||
if len(realtime.Items) > 0 {
|
||
summary = &realtime.Items[0]
|
||
if evidence.Plate == "" {
|
||
evidence.Plate = summary.Plate
|
||
}
|
||
evidence.RecommendedLocationProtocol = summary.PrimaryProtocol
|
||
evidence.LocationConflict = summary.LocationConflict
|
||
evidence.ConflictDistanceM = summary.ConflictDistanceM
|
||
for index := range evidence.LocationSources {
|
||
source := &evidence.LocationSources[index]
|
||
source.Recommended = source.Protocol == summary.PrimaryProtocol &&
|
||
(source.sourceKey == summary.LocationSource ||
|
||
(source.sourceKey == source.Protocol+":canonical" && summary.LocationSource == source.Protocol+":canonical"))
|
||
if source.Recommended {
|
||
evidence.RecommendedLocationLabel = source.SourceLabel
|
||
}
|
||
}
|
||
}
|
||
if evidence.RecommendedLocationLabel == "" {
|
||
for index := range evidence.LocationSources {
|
||
source := &evidence.LocationSources[index]
|
||
if source.SelectedWithinProtocol && (summary == nil || source.Protocol == summary.PrimaryProtocol) {
|
||
source.Recommended = true
|
||
evidence.RecommendedLocationProtocol = source.Protocol
|
||
evidence.RecommendedLocationLabel = source.SourceLabel
|
||
break
|
||
}
|
||
}
|
||
}
|
||
markRecommendedMileageSource(evidence.MileageSources, evidence.RecommendedLocationProtocol)
|
||
evidence.Comparison = compareVehicleSourceEvidence(evidence.LocationSources, evidence.MileageSources)
|
||
evidence.AsOf = time.Now().Format(time.RFC3339)
|
||
return evidence, nil
|
||
}
|
||
|
||
func authorizeVehicleDailyEvidenceDate(ctx context.Context, vin string, date string) error {
|
||
principal, ok := PrincipalFromContext(ctx)
|
||
if !ok || principal.UserType != "customer" {
|
||
return nil
|
||
}
|
||
grant, ok := principal.VehicleGrant(vin)
|
||
if !ok || grant.ValidFrom.IsZero() {
|
||
return clientError{Code: "HISTORY_SCOPE_UNAVAILABLE", Message: "当前车辆缺少可验证的历史授权起始时间"}
|
||
}
|
||
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||
requestedDay, err := time.ParseInLocation("2006-01-02", date, shanghai)
|
||
if err != nil {
|
||
return clientError{Code: "INVALID_DATE", Message: "里程日期格式应为 YYYY-MM-DD"}
|
||
}
|
||
validFrom := grant.ValidFrom.In(shanghai)
|
||
firstFullDay := time.Date(validFrom.Year(), validFrom.Month(), validFrom.Day(), 0, 0, 0, 0, shanghai)
|
||
if !validFrom.Equal(firstFullDay) {
|
||
firstFullDay = firstFullDay.AddDate(0, 0, 1)
|
||
}
|
||
if requestedDay.Before(firstFullDay) {
|
||
return clientError{Code: "HISTORY_BEFORE_AUTHORIZATION", Message: "所选里程日期早于该车辆首个完整授权日"}
|
||
}
|
||
if grant.ValidTo != nil && !grant.ValidTo.IsZero() {
|
||
requestedEnd := requestedDay.AddDate(0, 0, 1)
|
||
if requestedEnd.After(grant.ValidTo.In(shanghai)) {
|
||
return clientError{Code: "HISTORY_AFTER_AUTHORIZATION", Message: "所选里程日期超出该车辆完整授权范围"}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func markRecommendedMileageSource(sources []VehicleMileageSourceEvidence, preferredProtocol string) {
|
||
for index := range sources {
|
||
sources[index].Recommended = false
|
||
}
|
||
for _, protocol := range append([]string{preferredProtocol}, canonicalVehicleProtocols...) {
|
||
if protocol == "" {
|
||
continue
|
||
}
|
||
for index := range sources {
|
||
if sources[index].Protocol == protocol && sources[index].SelectedWithinProtocol {
|
||
sources[index].Recommended = true
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func compareVehicleSourceEvidence(locations []VehicleLocationSourceEvidence, mileages []VehicleMileageSourceEvidence) VehicleSourceEvidenceComparison {
|
||
var comparison VehicleSourceEvidenceComparison
|
||
for left := 0; left < len(locations); left++ {
|
||
if locations[left].Longitude == nil || locations[left].Latitude == nil {
|
||
continue
|
||
}
|
||
for right := left + 1; right < len(locations); right++ {
|
||
if locations[right].Longitude == nil || locations[right].Latitude == nil {
|
||
continue
|
||
}
|
||
distanceM := haversineKm(*locations[left].Latitude, *locations[left].Longitude, *locations[right].Latitude, *locations[right].Longitude) * 1000
|
||
if distanceM > comparison.LocationMaxDistanceM {
|
||
comparison.LocationMaxDistanceM = distanceM
|
||
}
|
||
}
|
||
}
|
||
var minTotal, maxTotal float64
|
||
var hasTotal bool
|
||
var minDaily, maxDaily float64
|
||
var hasDaily bool
|
||
var minTime, maxTime time.Time
|
||
for _, source := range locations {
|
||
if source.TotalMileageKm != nil {
|
||
value := *source.TotalMileageKm
|
||
if !hasTotal || value < minTotal {
|
||
minTotal = value
|
||
}
|
||
if !hasTotal || value > maxTotal {
|
||
maxTotal = value
|
||
}
|
||
hasTotal = true
|
||
}
|
||
if parsed, ok := parseVehicleServiceTime(firstNonEmpty(source.EventTime, source.ReceivedAt)); ok {
|
||
if minTime.IsZero() || parsed.Before(minTime) {
|
||
minTime = parsed
|
||
}
|
||
if maxTime.IsZero() || parsed.After(maxTime) {
|
||
maxTime = parsed
|
||
}
|
||
}
|
||
}
|
||
for _, source := range mileages {
|
||
if source.LatestTotalMileageKm != nil {
|
||
value := *source.LatestTotalMileageKm
|
||
if !hasTotal || value < minTotal {
|
||
minTotal = value
|
||
}
|
||
if !hasTotal || value > maxTotal {
|
||
maxTotal = value
|
||
}
|
||
hasTotal = true
|
||
}
|
||
if source.DailyMileageKm != nil {
|
||
value := *source.DailyMileageKm
|
||
if !hasDaily || value < minDaily {
|
||
minDaily = value
|
||
}
|
||
if !hasDaily || value > maxDaily {
|
||
maxDaily = value
|
||
}
|
||
hasDaily = true
|
||
}
|
||
if parsed, ok := parseVehicleServiceTime(source.LatestEventTime); ok {
|
||
if minTime.IsZero() || parsed.Before(minTime) {
|
||
minTime = parsed
|
||
}
|
||
if maxTime.IsZero() || parsed.After(maxTime) {
|
||
maxTime = parsed
|
||
}
|
||
}
|
||
}
|
||
if hasTotal {
|
||
comparison.TotalMileageDeltaKm = maxTotal - minTotal
|
||
}
|
||
if hasDaily {
|
||
comparison.DailyMileageDeltaKm = maxDaily - minDaily
|
||
}
|
||
if !minTime.IsZero() && !maxTime.IsZero() {
|
||
comparison.ReportTimeDeltaSeconds = maxTime.Sub(minTime).Seconds()
|
||
}
|
||
return comparison
|
||
}
|
||
|
||
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 {
|
||
scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{})
|
||
for index := range statuses {
|
||
protocol := statuses[index].Protocol
|
||
if protocol == "" {
|
||
continue
|
||
}
|
||
if !statuses[index].HasHistory && scopeErr == nil {
|
||
query := url.Values{"vin": {vin}, "protocol": {protocol}, "dateFrom": {scopedWindow.Get("dateFrom")}, "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 && scopeErr == nil {
|
||
page, err := s.store.RawFrames(ctx, RawFrameQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), 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
|
||
}
|
||
resolvedQuery, err = applyPrincipalHistoryTimeScope(ctx, resolvedQuery.Get("vin"), resolvedQuery)
|
||
if err != nil {
|
||
return Page[HistoryLocationRow]{}, err
|
||
}
|
||
return s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||
}
|
||
|
||
func (s *Service) TrackPlayback(ctx context.Context, query url.Values) (TrackPlaybackResponse, error) {
|
||
keyword := strings.TrimSpace(firstNonEmpty(query.Get("keyword"), query.Get("vin")))
|
||
if keyword == "" {
|
||
return TrackPlaybackResponse{}, clientError{Code: "VEHICLE_KEY_REQUIRED", Message: "轨迹查询需要车牌、VIN 或终端标识"}
|
||
}
|
||
if err := validateTrackWindow(query.Get("dateFrom"), query.Get("dateTo")); err != nil {
|
||
return TrackPlaybackResponse{}, err
|
||
}
|
||
maxPoints := parsePositive(query.Get("maxPoints"), 1200)
|
||
if maxPoints < 2 {
|
||
maxPoints = 2
|
||
}
|
||
if maxPoints > 2000 {
|
||
maxPoints = 2000
|
||
}
|
||
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||
if err != nil {
|
||
return TrackPlaybackResponse{}, err
|
||
}
|
||
resolvedQuery, err = applyPrincipalHistoryTimeScope(ctx, resolvedQuery.Get("vin"), resolvedQuery)
|
||
if err != nil {
|
||
return TrackPlaybackResponse{}, err
|
||
}
|
||
resolvedQuery.Set("limit", "5000")
|
||
resolvedQuery.Set("offset", "0")
|
||
page, err := s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||
if err != nil {
|
||
return TrackPlaybackResponse{}, err
|
||
}
|
||
rawPoints := append([]HistoryLocationRow(nil), page.Items...)
|
||
sort.SliceStable(rawPoints, func(i, j int) bool {
|
||
left, leftOK := parseVehicleServiceTime(rawPoints[i].DeviceTime)
|
||
right, rightOK := parseVehicleServiceTime(rawPoints[j].DeviceTime)
|
||
if leftOK && rightOK && !left.Equal(right) {
|
||
return left.Before(right)
|
||
}
|
||
return rawPoints[i].DeviceTime < rawPoints[j].DeviceTime
|
||
})
|
||
cleanPoints, quality := analyzeTrackPoints(rawPoints)
|
||
points, selectedProtocol, alternateSourcePoints := selectTrackPrimarySource(cleanPoints, query.Get("protocol"))
|
||
quality.SelectedProtocol = selectedProtocol
|
||
quality.AlternateSourcePoints = alternateSourcePoints
|
||
quality.ValidPoints = len(points)
|
||
applyTrackSequenceQuality(points, &quality)
|
||
result := TrackPlaybackResponse{
|
||
Points: []HistoryLocationRow{},
|
||
Events: []TrackPlaybackEvent{},
|
||
Sources: summarizeTrackSources(cleanPoints),
|
||
Segments: []TrackSegment{},
|
||
Stops: []TrackStop{},
|
||
Total: page.Total,
|
||
Truncated: page.Total > len(rawPoints),
|
||
Quality: quality,
|
||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||
}
|
||
result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), 0, result.Truncated, false)
|
||
applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints)
|
||
if len(points) == 0 {
|
||
return result, nil
|
||
}
|
||
result.VIN = points[0].VIN
|
||
result.Plate = points[0].Plate
|
||
segments := buildTrackSegments(points)
|
||
stops := buildTrackStops(points, segments)
|
||
result.Summary = summarizeTrack(points)
|
||
result.Coverage.ActualStart = result.Summary.StartTime
|
||
result.Coverage.ActualEnd = result.Summary.EndTime
|
||
result.Summary.SegmentCount = len(segments)
|
||
result.Summary.StopCount = len(stops)
|
||
for _, segment := range segments {
|
||
switch segment.Type {
|
||
case "moving":
|
||
result.Summary.MovingSeconds += segment.DurationSeconds
|
||
case "stopped":
|
||
result.Summary.StoppedSeconds += segment.DurationSeconds
|
||
}
|
||
}
|
||
result.Segments = segments
|
||
result.Stops = stops
|
||
result.Events = buildTrackEvents(points, segments, stops)
|
||
keyIndexes := trackKeyIndexes(result.Events, segments, stops)
|
||
var sampledIndexes []int
|
||
result.Points, sampledIndexes = sampleTrackPointsWithKeys(points, maxPoints, keyIndexes)
|
||
result.Sampled = len(result.Points) < len(points)
|
||
for index := range result.Events {
|
||
result.Events[index].SampledIndex = nearestSampledIndex(result.Events[index].Index, sampledIndexes)
|
||
}
|
||
for index := range result.Segments {
|
||
result.Segments[index].SampledStartIndex = nearestSampledIndex(result.Segments[index].StartIndex, sampledIndexes)
|
||
result.Segments[index].SampledEndIndex = nearestSampledIndex(result.Segments[index].EndIndex, sampledIndexes)
|
||
}
|
||
for index := range result.Stops {
|
||
result.Stops[index].SampledIndex = nearestSampledIndex((result.Stops[index].startIndex+result.Stops[index].endIndex)/2, sampledIndexes)
|
||
}
|
||
result.Coverage = trackCoverage(resolvedQuery, page.Total, len(rawPoints), len(points), len(result.Points), result.Truncated, result.Sampled)
|
||
applyTrackPrimarySourceCoverage(&result.Coverage, selectedProtocol, alternateSourcePoints)
|
||
result.Coverage.ActualStart = result.Summary.StartTime
|
||
result.Coverage.ActualEnd = result.Summary.EndTime
|
||
return result, nil
|
||
}
|
||
|
||
func validateTrackWindow(dateFrom, dateTo string) error {
|
||
dateFrom = strings.TrimSpace(dateFrom)
|
||
dateTo = strings.TrimSpace(dateTo)
|
||
if dateFrom == "" && dateTo == "" {
|
||
return nil
|
||
}
|
||
if dateFrom == "" || dateTo == "" {
|
||
return clientError{Code: "TRACK_TIME_RANGE_REQUIRED", Message: "轨迹时间范围需要同时提供开始和结束时间"}
|
||
}
|
||
start, startOK := parseTrackRequestTime(dateFrom)
|
||
end, endOK := parseTrackRequestTime(dateTo)
|
||
if !startOK || !endOK {
|
||
return clientError{Code: "TRACK_TIME_INVALID", Message: "轨迹时间格式无效"}
|
||
}
|
||
if !end.After(start) {
|
||
return clientError{Code: "TRACK_TIME_ORDER_INVALID", Message: "轨迹结束时间必须晚于开始时间"}
|
||
}
|
||
if end.Sub(start) > 7*24*time.Hour {
|
||
return clientError{Code: "TRACK_TIME_RANGE_TOO_LARGE", Message: "单次轨迹回放最长支持 7 天,请缩小时间范围"}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func parseTrackRequestTime(value string) (time.Time, bool) {
|
||
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02T15:04:05", "2006-01-02 15:04:05"} {
|
||
if layout == time.RFC3339 {
|
||
if parsed, err := time.Parse(layout, value); err == nil {
|
||
return parsed, true
|
||
}
|
||
continue
|
||
}
|
||
if parsed, err := time.ParseInLocation(layout, value, shanghai); err == nil {
|
||
return parsed, true
|
||
}
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
func (s *Service) HistoryMetricCatalog() HistoryMetricCatalog {
|
||
return HistoryMetricCatalog{
|
||
Categories: []HistoryDataCategory{{Key: "location", Label: "位置数据"}, {Key: "raw", Label: "原始报文"}, {Key: "mileage", Label: "日里程"}},
|
||
Metrics: append(append(append([]HistoryMetricDefinition{}, historyLocationMetrics()...), historyRawMetrics()...), historyMileageMetrics()...),
|
||
}
|
||
}
|
||
|
||
type metricCatalogStore interface {
|
||
MetricDefinitions(context.Context) ([]MetricDefinition, error)
|
||
}
|
||
|
||
func (s *Service) metricDefinitions(ctx context.Context) ([]MetricDefinition, error) {
|
||
if store, ok := s.store.(metricCatalogStore); ok {
|
||
return store.MetricDefinitions(ctx)
|
||
}
|
||
return metricDefinitions(), nil
|
||
}
|
||
|
||
func (s *Service) MetricCatalog(ctx context.Context) (MetricCatalog, error) {
|
||
definitions, err := s.metricDefinitions(ctx)
|
||
if err != nil {
|
||
return MetricCatalog{}, err
|
||
}
|
||
return MetricCatalog{Metrics: definitions, AsOf: time.Now().UTC().Format(time.RFC3339)}, nil
|
||
}
|
||
|
||
const latestTelemetryStaleAfter = 5 * time.Minute
|
||
|
||
type latestTelemetryRawResult struct {
|
||
page Page[RawFrameRow]
|
||
err error
|
||
}
|
||
|
||
type latestTelemetryCatalogResult struct {
|
||
definitions []MetricDefinition
|
||
err error
|
||
}
|
||
|
||
// LatestTelemetry keeps the latest scalar value for every unified metric or
|
||
// source field. RAW and catalog reads are independent, so they run in parallel.
|
||
func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (LatestTelemetryResponse, error) {
|
||
vehicleKey = strings.TrimSpace(vehicleKey)
|
||
if vehicleKey == "" {
|
||
return LatestTelemetryResponse{}, clientError{Code: "VEHICLE_KEY_REQUIRED", Message: "最新遥测查询需要车牌、VIN 或终端标识"}
|
||
}
|
||
resolvedVIN, err := s.resolveVehicleVIN(ctx, vehicleKey, "")
|
||
if err != nil {
|
||
return LatestTelemetryResponse{}, err
|
||
}
|
||
if err := authorizeVehicleVIN(ctx, resolvedVIN); err != nil {
|
||
return LatestTelemetryResponse{}, err
|
||
}
|
||
scopedWindow, err := applyPrincipalHistoryTimeScope(ctx, resolvedVIN, url.Values{})
|
||
if err != nil {
|
||
return LatestTelemetryResponse{}, err
|
||
}
|
||
rawResult := make(chan latestTelemetryRawResult, len(canonicalVehicleProtocols))
|
||
catalogResult := make(chan latestTelemetryCatalogResult, 1)
|
||
for _, protocol := range canonicalVehicleProtocols {
|
||
go func(protocol string) {
|
||
page, queryErr := s.store.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), IncludeFields: true, Limit: 5, SkipCount: true})
|
||
rawResult <- latestTelemetryRawResult{page: page, err: queryErr}
|
||
}(protocol)
|
||
}
|
||
go func() {
|
||
definitions, err := s.metricDefinitions(ctx)
|
||
catalogResult <- latestTelemetryCatalogResult{definitions: definitions, err: err}
|
||
}()
|
||
frames := make([]RawFrameRow, 0, len(canonicalVehicleProtocols)*5)
|
||
for range canonicalVehicleProtocols {
|
||
raw := <-rawResult
|
||
if raw.err != nil {
|
||
return LatestTelemetryResponse{}, raw.err
|
||
}
|
||
frames = append(frames, raw.page.Items...)
|
||
}
|
||
catalog := <-catalogResult
|
||
if catalog.err != nil {
|
||
return LatestTelemetryResponse{}, catalog.err
|
||
}
|
||
return buildLatestTelemetryResponse(resolvedVIN, frames, catalog.definitions, time.Now()), nil
|
||
}
|
||
|
||
type latestTelemetryCandidate struct {
|
||
value LatestTelemetryValue
|
||
sortTime time.Time
|
||
timeValid bool
|
||
}
|
||
|
||
func buildLatestTelemetryResponse(vehicleKey string, frames []RawFrameRow, definitions []MetricDefinition, now time.Time) LatestTelemetryResponse {
|
||
asOf := now.UTC().Format(time.RFC3339)
|
||
response := LatestTelemetryResponse{
|
||
VIN: strings.TrimSpace(vehicleKey),
|
||
Categories: []LatestTelemetryCategory{},
|
||
Values: []LatestTelemetryValue{},
|
||
AsOf: asOf,
|
||
StaleAfterSeconds: int64(latestTelemetryStaleAfter / time.Second),
|
||
ScannedFrames: len(frames),
|
||
}
|
||
definitionBySource := make(map[string]MetricDefinition, len(definitions)*2)
|
||
for _, definition := range definitions {
|
||
for protocol, sourceField := range definition.SourceFields {
|
||
definitionBySource[telemetrySourceLookupKey(protocol, sourceField)] = definition
|
||
}
|
||
}
|
||
candidates := make(map[string]latestTelemetryCandidate, 64)
|
||
for _, frame := range frames {
|
||
if response.VIN == vehicleKey && strings.TrimSpace(frame.VIN) != "" {
|
||
response.VIN = strings.TrimSpace(frame.VIN)
|
||
}
|
||
observed, observedOK := latestTelemetryObservedTime(frame)
|
||
quality, qualityReason, freshness, delay := latestTelemetryQuality(frame, now)
|
||
normalizedProtocol := strings.ToUpper(strings.TrimSpace(frame.Protocol))
|
||
sourceLookupPrefix := normalizedProtocol + "\x00"
|
||
for sourceField, rawValue := range frame.ParsedFields {
|
||
if !isTelemetryScalar(rawValue) {
|
||
continue
|
||
}
|
||
definition, unified := definitionBySource[sourceLookupPrefix+strings.TrimSpace(sourceField)]
|
||
key := sourceField
|
||
if unified {
|
||
key = definition.Key
|
||
}
|
||
if previous, exists := candidates[key]; exists && (!observedOK || (previous.timeValid && !observed.After(previous.sortTime))) {
|
||
continue
|
||
}
|
||
label, unit := latestTelemetryRawMetadata(sourceField)
|
||
category := telemetrySourceCategory(sourceField)
|
||
valueType := "dynamic"
|
||
description := ""
|
||
value := rawValue
|
||
if unified {
|
||
label = definition.Label
|
||
unit = definition.Unit
|
||
category = normalizeTelemetryCategory(definition.Category)
|
||
valueType = definition.ValueType
|
||
description = definition.Description
|
||
value = normalizeTelemetryValue(rawValue, definition.ValueType)
|
||
}
|
||
candidates[key] = latestTelemetryCandidate{
|
||
value: LatestTelemetryValue{
|
||
Key: key, SourceField: sourceField, Label: label, Description: description, Unit: unit,
|
||
Category: category, ValueType: valueType, Value: value, Protocol: normalizedProtocol,
|
||
SourceEndpoint: frame.SourceEndpoint, FrameID: frame.ID, DeviceTime: frame.DeviceTime, ServerTime: frame.ServerTime,
|
||
Quality: quality, QualityReason: qualityReason, FreshnessSeconds: freshness, DataDelaySeconds: delay,
|
||
},
|
||
sortTime: observed, timeValid: observedOK,
|
||
}
|
||
}
|
||
}
|
||
for _, candidate := range candidates {
|
||
response.Values = append(response.Values, candidate.value)
|
||
}
|
||
sort.Slice(response.Values, func(i, j int) bool {
|
||
left, right := telemetryCategoryRank(response.Values[i].Category), telemetryCategoryRank(response.Values[j].Category)
|
||
if left != right {
|
||
return left < right
|
||
}
|
||
if response.Values[i].Label != response.Values[j].Label {
|
||
return response.Values[i].Label < response.Values[j].Label
|
||
}
|
||
return response.Values[i].Key < response.Values[j].Key
|
||
})
|
||
categoryIndexes := map[string]int{}
|
||
for _, value := range response.Values {
|
||
index, exists := categoryIndexes[value.Category]
|
||
if !exists {
|
||
categoryIndexes[value.Category] = len(response.Categories)
|
||
response.Categories = append(response.Categories, LatestTelemetryCategory{Key: value.Category, Label: telemetryCategoryLabel(value.Category)})
|
||
index = len(response.Categories) - 1
|
||
}
|
||
response.Categories[index].Count++
|
||
}
|
||
response.Evidence = fmt.Sprintf("scanned %d newest RAW frames; selected %d newest scalar values by unified metric/source field", len(frames), len(response.Values))
|
||
return response
|
||
}
|
||
|
||
func telemetrySourceLookupKey(protocol, sourceField string) string {
|
||
return strings.ToUpper(strings.TrimSpace(protocol)) + "\x00" + strings.TrimSpace(sourceField)
|
||
}
|
||
|
||
func latestTelemetryObservedTime(frame RawFrameRow) (time.Time, bool) {
|
||
if parsed, ok := parseLatestTelemetryTime(frame.ServerTime); ok {
|
||
return parsed, true
|
||
}
|
||
return parseLatestTelemetryTime(frame.DeviceTime)
|
||
}
|
||
|
||
func parseLatestTelemetryTime(value string) (time.Time, bool) {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return time.Time{}, false
|
||
}
|
||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
||
if parsed, err := time.Parse(layout, value); err == nil {
|
||
return parsed, true
|
||
}
|
||
}
|
||
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||
for _, layout := range []string{"2006-01-02 15:04:05.999999999", "2006-01-02 15:04:05", "2006-01-02T15:04:05.999999999", "2006-01-02T15:04:05"} {
|
||
if parsed, err := time.ParseInLocation(layout, value, shanghai); err == nil {
|
||
return parsed, true
|
||
}
|
||
}
|
||
return time.Time{}, false
|
||
}
|
||
|
||
func latestTelemetryQuality(frame RawFrameRow, now time.Time) (string, string, int64, *int64) {
|
||
serverTime, serverOK := parseLatestTelemetryTime(frame.ServerTime)
|
||
deviceTime, deviceOK := parseLatestTelemetryTime(frame.DeviceTime)
|
||
var delay *int64
|
||
if serverOK && deviceOK {
|
||
seconds := int64(math.Abs(serverTime.Sub(deviceTime).Seconds()))
|
||
delay = &seconds
|
||
}
|
||
if status := strings.TrimSpace(frame.ParseStatus); status != "" && !strings.EqualFold(status, "ok") {
|
||
reason := "原始帧解析状态为 " + status
|
||
if detail := strings.TrimSpace(frame.ParseError); detail != "" {
|
||
reason += ":" + detail
|
||
}
|
||
return "warning", reason, telemetryFreshnessSeconds(serverTime, serverOK, now), delay
|
||
}
|
||
if !serverOK {
|
||
return "warning", "缺少可解析的服务接收时间", 0, delay
|
||
}
|
||
if serverTime.After(now.Add(latestTelemetryStaleAfter)) {
|
||
return "warning", "服务接收时间晚于平台当前时间超过 5 分钟", 0, delay
|
||
}
|
||
freshness := telemetryFreshnessSeconds(serverTime, true, now)
|
||
if delay != nil && *delay > int64(latestTelemetryStaleAfter/time.Second) {
|
||
return "warning", "设备时间与服务接收时间相差超过 5 分钟", freshness, delay
|
||
}
|
||
if freshness > int64(latestTelemetryStaleAfter/time.Second) {
|
||
return "stale", "最新值距平台当前时间超过 5 分钟", freshness, delay
|
||
}
|
||
return "good", "解析成功且接收时间在 5 分钟内", freshness, delay
|
||
}
|
||
|
||
func telemetryFreshnessSeconds(serverTime time.Time, valid bool, now time.Time) int64 {
|
||
if !valid || serverTime.After(now) {
|
||
return 0
|
||
}
|
||
return int64(now.Sub(serverTime).Seconds())
|
||
}
|
||
|
||
func isTelemetryScalar(value any) bool {
|
||
switch value.(type) {
|
||
case nil, map[string]any, []any:
|
||
return false
|
||
case string, bool, float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, json.Number:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func normalizeTelemetryValue(value any, valueType string) any {
|
||
if valueType != "boolean" {
|
||
return value
|
||
}
|
||
switch typed := value.(type) {
|
||
case bool:
|
||
return typed
|
||
case float64:
|
||
return typed != 0
|
||
case json.Number:
|
||
number, err := typed.Float64()
|
||
return err == nil && number != 0
|
||
case string:
|
||
return strings.EqualFold(typed, "true") || typed == "1"
|
||
default:
|
||
return value
|
||
}
|
||
}
|
||
|
||
func latestTelemetryRawMetadata(sourceField string) (string, string) {
|
||
label, unit := rawMetricMetadata(sourceField)
|
||
protocol := strings.ToUpper(strings.Split(sourceField, ".")[0])
|
||
label = strings.TrimSuffix(label, " · "+protocol)
|
||
return label, unit
|
||
}
|
||
|
||
func normalizeTelemetryCategory(category string) string {
|
||
switch strings.ToLower(strings.TrimSpace(category)) {
|
||
case "driving", "vehicle":
|
||
return "vehicle"
|
||
case "energy", "battery":
|
||
return "battery"
|
||
case "safety", "alarm":
|
||
return "alarm"
|
||
case "location":
|
||
return "location"
|
||
case "quality":
|
||
return "quality"
|
||
default:
|
||
return "extension"
|
||
}
|
||
}
|
||
|
||
func telemetrySourceCategory(sourceField string) string {
|
||
normalized := strings.ToLower(sourceField)
|
||
switch {
|
||
case strings.Contains(normalized, "alarm") || strings.Contains(normalized, "fault") || strings.Contains(normalized, "warning"):
|
||
return "alarm"
|
||
case strings.Contains(normalized, "fuel_cell") || strings.Contains(normalized, "fuelcell") || strings.Contains(normalized, "fc_stack"):
|
||
return "fuel-cell"
|
||
case strings.Contains(normalized, "motor") || strings.Contains(normalized, "engine") || strings.Contains(normalized, "rpm"):
|
||
return "motor"
|
||
case strings.Contains(normalized, "battery") || strings.Contains(normalized, "soc") || strings.Contains(normalized, "cell_") || strings.Contains(normalized, "voltage") || strings.Contains(normalized, "current"):
|
||
return "battery"
|
||
case strings.Contains(normalized, "location") || strings.Contains(normalized, "longitude") || strings.Contains(normalized, "latitude") || strings.Contains(normalized, "direction") || strings.Contains(normalized, "altitude"):
|
||
return "location"
|
||
case strings.Contains(normalized, ".vehicle.") || strings.HasPrefix(normalized, "vehicle.") || strings.Contains(normalized, "speed") || strings.Contains(normalized, "mileage") || strings.Contains(normalized, "gear") || strings.Contains(normalized, "brake") || strings.Contains(normalized, "accelerator"):
|
||
return "vehicle"
|
||
default:
|
||
return "extension"
|
||
}
|
||
}
|
||
|
||
func telemetryCategoryRank(category string) int {
|
||
for index, key := range []string{"vehicle", "motor", "fuel-cell", "battery", "location", "alarm", "quality", "extension"} {
|
||
if category == key {
|
||
return index
|
||
}
|
||
}
|
||
return 99
|
||
}
|
||
|
||
func telemetryCategoryLabel(category string) string {
|
||
labels := map[string]string{"vehicle": "整车数据", "motor": "驱动电机", "fuel-cell": "燃料电池", "battery": "电池", "location": "定位", "alarm": "报警", "quality": "数据质量", "extension": "扩展字段"}
|
||
if label := labels[category]; label != "" {
|
||
return label
|
||
}
|
||
return category
|
||
}
|
||
|
||
func metricDefinitions() []MetricDefinition {
|
||
allProtocols := append([]string(nil), canonicalVehicleProtocols...)
|
||
return []MetricDefinition{
|
||
{Key: "speed_kmh", Label: "速度", Description: "车辆最新行驶速度", Unit: "km/h", Category: "driving", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.speed_kmh", "JT808": "jt808.location.speed_kmh", "YUTONG_MQTT": "yutong_mqtt.data.meter_speed"}, Searchable: true, Chartable: true, Alertable: true},
|
||
{Key: "soc_percent", Label: "SOC", Description: "动力电池剩余电量百分比", Unit: "%", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960", "YUTONG_MQTT"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.soc_percent", "YUTONG_MQTT": "yutong_mqtt.data.battery_capacity_soc"}, Searchable: true, Chartable: true, Alertable: true},
|
||
{Key: "alarm_active", Label: "协议告警位", Description: "协议报文是否携带活动告警", Unit: "", Category: "safety", ValueType: "boolean", Protocols: []string{"GB32960", "JT808"}, SourceFields: map[string]string{"GB32960": "gb32960.alarm.general_alarm_flag", "JT808": "jt808.location.alarm_flag"}, Searchable: true, Chartable: false, Alertable: true},
|
||
{Key: "freshness_sec", Label: "离线时长", Description: "最新数据距当前时间的秒数", Unit: "s", Category: "quality", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.updated_at"}, Searchable: true, Chartable: true, Alertable: true},
|
||
{Key: "data_delay_sec", Label: "数据延迟", Description: "服务接收时间与设备事件时间的差值", Unit: "s", Category: "quality", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "received_at-event_time"}, Searchable: true, Chartable: true, Alertable: true},
|
||
{Key: "total_mileage_km", Label: "总里程", Description: "车辆累计行驶里程", Unit: "km", Category: "driving", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_mileage_km", "JT808": "jt808.location.total_mileage_km", "YUTONG_MQTT": "yutong.vehicle.total_mileage_km"}, Searchable: true, Chartable: true, Alertable: false},
|
||
{Key: "longitude", Label: "经度", Description: "WGS84/协议归一化经度", Unit: "°", Category: "location", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.longitude"}, Searchable: true, Chartable: false, Alertable: false},
|
||
{Key: "latitude", Label: "纬度", Description: "WGS84/协议归一化纬度", Unit: "°", Category: "location", ValueType: "numeric", Protocols: allProtocols, SourceFields: map[string]string{"PLATFORM": "vehicle_realtime_location.latitude"}, Searchable: true, Chartable: false, Alertable: false},
|
||
}
|
||
}
|
||
|
||
func (s *Service) HistoryData(ctx context.Context, query url.Values) (HistoryDataResponse, error) {
|
||
started := time.Now()
|
||
category := strings.ToLower(strings.TrimSpace(query.Get("category")))
|
||
if category == "" {
|
||
category = "location"
|
||
}
|
||
if category != "location" && category != "raw" && category != "mileage" {
|
||
return HistoryDataResponse{}, clientError{Code: "HISTORY_CATEGORY_INVALID", Message: "历史数据类型仅支持 location、raw 或 mileage"}
|
||
}
|
||
keywords := historyQueryKeywords(query)
|
||
if len(keywords) == 0 {
|
||
return HistoryDataResponse{}, clientError{Code: "VEHICLE_KEY_REQUIRED", Message: "历史查询至少需要一个车牌、VIN 或终端标识"}
|
||
}
|
||
if len(keywords) > 5 {
|
||
return HistoryDataResponse{}, clientError{Code: "VEHICLE_LIMIT_EXCEEDED", Message: "单次历史查询最多支持 5 台车辆"}
|
||
}
|
||
limit := parsePositive(query.Get("limit"), 50)
|
||
if limit > 200 {
|
||
limit = 200
|
||
}
|
||
offset := parsePositive(query.Get("offset"), 0)
|
||
fetchLimit := limit + offset
|
||
if fetchLimit > 1000 {
|
||
fetchLimit = 1000
|
||
}
|
||
rows := make([]HistoryDataRow, 0, fetchLimit*len(keywords))
|
||
total := 0
|
||
for _, keyword := range keywords {
|
||
vehicleRows, vehicleTotal, err := s.historyDataForVehicle(ctx, category, keyword, query, fetchLimit)
|
||
if err != nil {
|
||
return HistoryDataResponse{}, err
|
||
}
|
||
rows = append(rows, vehicleRows...)
|
||
total += vehicleTotal
|
||
}
|
||
sort.SliceStable(rows, func(i, j int) bool {
|
||
if rows[i].DeviceTime == rows[j].DeviceTime {
|
||
return rows[i].VIN < rows[j].VIN
|
||
}
|
||
return rows[i].DeviceTime > rows[j].DeviceTime
|
||
})
|
||
if offset > len(rows) {
|
||
offset = len(rows)
|
||
}
|
||
end := offset + limit
|
||
if end > len(rows) {
|
||
end = len(rows)
|
||
}
|
||
pageRows := append([]HistoryDataRow(nil), rows[offset:end]...)
|
||
columns := historyMetricsForCategory(category)
|
||
if category == "raw" {
|
||
columns = mergeDiscoveredRawMetrics(columns, rows)
|
||
}
|
||
vehicles := map[string]struct{}{}
|
||
sources := map[string]struct{}{}
|
||
for _, row := range rows {
|
||
vehicles[row.VIN] = struct{}{}
|
||
sources[row.Protocol] = struct{}{}
|
||
}
|
||
sourceNames := make([]string, 0, len(sources))
|
||
for source := range sources {
|
||
if source != "" {
|
||
sourceNames = append(sourceNames, source)
|
||
}
|
||
}
|
||
sort.Strings(sourceNames)
|
||
return HistoryDataResponse{
|
||
Category: category, Columns: columns, Rows: pageRows, Total: total, Limit: limit, Offset: offset,
|
||
Summary: HistoryDataSummary{ResultRows: total, VehicleCount: len(vehicles), Sources: sourceNames, QueryDuration: time.Since(started).Milliseconds()},
|
||
AsOf: time.Now().UTC().Format(time.RFC3339),
|
||
}, nil
|
||
}
|
||
|
||
type historyLocationSeriesStore interface {
|
||
HistoryLocationSeries(context.Context, HistoryLocationSeriesQuery) ([]HistoryLocationSeriesBucket, error)
|
||
}
|
||
|
||
type historySeriesMetricSpec struct {
|
||
definition HistoryMetricDefinition
|
||
aggregation string
|
||
value func(HistoryLocationSeriesBucket) *float64
|
||
minimum func(HistoryLocationSeriesBucket) *float64
|
||
maximum func(HistoryLocationSeriesBucket) *float64
|
||
}
|
||
|
||
func historySeriesMetricSpecs() map[string]historySeriesMetricSpec {
|
||
return map[string]historySeriesMetricSpec{
|
||
"speedKmh": {
|
||
definition: HistoryMetricDefinition{Key: "speedKmh", Label: "速度", Unit: "km/h", Category: "location", ValueType: "number", DefaultVisible: true},
|
||
aggregation: "avg", value: func(row HistoryLocationSeriesBucket) *float64 { return row.SpeedAverage },
|
||
minimum: func(row HistoryLocationSeriesBucket) *float64 { return row.SpeedMinimum }, maximum: func(row HistoryLocationSeriesBucket) *float64 { return row.SpeedMaximum },
|
||
},
|
||
"totalMileageKm": {
|
||
definition: HistoryMetricDefinition{Key: "totalMileageKm", Label: "总里程", Unit: "km", Category: "location", ValueType: "number", DefaultVisible: true},
|
||
aggregation: "last", value: func(row HistoryLocationSeriesBucket) *float64 { return row.MileageLast },
|
||
minimum: func(row HistoryLocationSeriesBucket) *float64 { return row.MileageMinimum }, maximum: func(row HistoryLocationSeriesBucket) *float64 { return row.MileageMaximum },
|
||
},
|
||
}
|
||
}
|
||
|
||
func (s *Service) HistorySeries(ctx context.Context, query url.Values) (HistorySeriesResponse, error) {
|
||
started := time.Now()
|
||
keywords := historyQueryKeywords(query)
|
||
if len(keywords) == 0 {
|
||
return HistorySeriesResponse{}, clientError{Code: "VEHICLE_KEY_REQUIRED", Message: "趋势查询至少需要一个车牌、VIN 或终端标识"}
|
||
}
|
||
if len(keywords) > 5 {
|
||
return HistorySeriesResponse{}, clientError{Code: "VEHICLE_LIMIT_EXCEEDED", Message: "单次趋势查询最多支持 5 台车辆"}
|
||
}
|
||
category := strings.ToLower(strings.TrimSpace(query.Get("category")))
|
||
if category == "" {
|
||
category = "location"
|
||
}
|
||
if category != "location" {
|
||
return HistorySeriesResponse{}, clientError{Code: "HISTORY_SERIES_CATEGORY_UNSUPPORTED", Message: "当前仅位置数据支持聚合趋势,原始报文和日里程请查看明细"}
|
||
}
|
||
dateFrom, dateTo, start, end, err := historySeriesWindow(query.Get("dateFrom"), query.Get("dateTo"), time.Now())
|
||
if err != nil {
|
||
return HistorySeriesResponse{}, err
|
||
}
|
||
targetPoints := parsePositive(query.Get("targetPoints"), 240)
|
||
if targetPoints < 60 {
|
||
targetPoints = 60
|
||
}
|
||
if targetPoints > 600 {
|
||
targetPoints = 600
|
||
}
|
||
grainSeconds := historySeriesGrain(end.Sub(start), targetPoints)
|
||
specs := historySeriesMetricSpecs()
|
||
metricKeys := historySeriesMetrics(query.Get("metrics"), specs)
|
||
definitions := make([]HistoryMetricDefinition, 0, len(metricKeys))
|
||
for _, key := range metricKeys {
|
||
definitions = append(definitions, specs[key].definition)
|
||
}
|
||
store, ok := s.store.(historyLocationSeriesStore)
|
||
if !ok {
|
||
return HistorySeriesResponse{}, fmt.Errorf("history series aggregation is unavailable")
|
||
}
|
||
protocol := strings.TrimSpace(query.Get("protocol"))
|
||
type vehicleBuckets struct {
|
||
vin, plate string
|
||
buckets []HistoryLocationSeriesBucket
|
||
}
|
||
vehicles := make([]vehicleBuckets, 0, len(keywords))
|
||
for _, keyword := range keywords {
|
||
vin, resolveErr := s.resolveVehicleVIN(ctx, keyword, protocol)
|
||
if resolveErr != nil {
|
||
return HistorySeriesResponse{}, resolveErr
|
||
}
|
||
if err := authorizeVehicleVIN(ctx, vin); err != nil {
|
||
return HistorySeriesResponse{}, err
|
||
}
|
||
scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{
|
||
"dateFrom": {dateFrom},
|
||
"dateTo": {dateTo},
|
||
})
|
||
if scopeErr != nil {
|
||
return HistorySeriesResponse{}, scopeErr
|
||
}
|
||
buckets, aggregateErr := store.HistoryLocationSeries(ctx, HistoryLocationSeriesQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), GrainSeconds: grainSeconds})
|
||
if aggregateErr != nil {
|
||
return HistorySeriesResponse{}, aggregateErr
|
||
}
|
||
vehicles = append(vehicles, vehicleBuckets{vin: vin, plate: s.historySeriesPlate(ctx, vin), buckets: buckets})
|
||
}
|
||
seriesByKey := map[string]*HistorySeries{}
|
||
seriesOrder := make([]string, 0)
|
||
groupBuckets := map[string]int{}
|
||
var rawPointCount int64
|
||
for _, vehicle := range vehicles {
|
||
for _, bucket := range vehicle.buckets {
|
||
groupKey := vehicle.vin + "\x00" + bucket.Protocol
|
||
groupBuckets[groupKey]++
|
||
rawPointCount += bucket.Count
|
||
for _, metricKey := range metricKeys {
|
||
spec := specs[metricKey]
|
||
key := groupKey + "\x00" + metricKey
|
||
series := seriesByKey[key]
|
||
if series == nil {
|
||
series = &HistorySeries{VIN: vehicle.vin, Plate: vehicle.plate, Protocol: bucket.Protocol, Metric: metricKey, Label: spec.definition.Label, Unit: spec.definition.Unit, Aggregation: spec.aggregation, Points: []HistorySeriesPoint{}}
|
||
seriesByKey[key] = series
|
||
seriesOrder = append(seriesOrder, key)
|
||
}
|
||
series.Points = append(series.Points, HistorySeriesPoint{Time: historySeriesPointTime(bucket.Time), Value: spec.value(bucket), Min: spec.minimum(bucket), Max: spec.maximum(bucket), Count: bucket.Count})
|
||
}
|
||
}
|
||
}
|
||
series := make([]HistorySeries, 0, len(seriesOrder))
|
||
returnedPoints := 0
|
||
for _, key := range seriesOrder {
|
||
current := *seriesByKey[key]
|
||
returnedPoints += len(current.Points)
|
||
series = append(series, current)
|
||
}
|
||
windowBuckets := int(math.Ceil(end.Sub(start).Seconds() / float64(grainSeconds)))
|
||
groupCount := len(groupBuckets)
|
||
if groupCount == 0 {
|
||
groupCount = len(vehicles)
|
||
}
|
||
expectedBuckets := windowBuckets * groupCount
|
||
actualBuckets := 0
|
||
for _, count := range groupBuckets {
|
||
actualBuckets += count
|
||
}
|
||
missingBuckets := expectedBuckets - actualBuckets
|
||
if missingBuckets < 0 {
|
||
missingBuckets = 0
|
||
}
|
||
evidence := fmt.Sprintf("TDengine INTERVAL(%ds),按车辆与协议分区;空窗不补值", grainSeconds)
|
||
return HistorySeriesResponse{Metrics: definitions, Series: series, DateFrom: start.UTC().Format(time.RFC3339), DateTo: end.UTC().Format(time.RFC3339), AsOf: time.Now().UTC().Format(time.RFC3339), Summary: HistorySeriesSummary{RawPointCount: rawPointCount, BucketCount: actualBuckets, ReturnedPointCount: returnedPoints, SeriesCount: len(series), GrainSeconds: grainSeconds, TargetPoints: targetPoints, ExpectedBucketCount: expectedBuckets, MissingBucketCount: missingBuckets, QueryDuration: time.Since(started).Milliseconds(), Complete: missingBuckets == 0, Evidence: evidence}}, nil
|
||
}
|
||
|
||
func historySeriesMetrics(raw string, specs map[string]historySeriesMetricSpec) []string {
|
||
requested := strings.FieldsFunc(raw, func(char rune) bool { return char == ',' || char == ';' })
|
||
if len(requested) == 0 {
|
||
requested = []string{"speedKmh", "totalMileageKm"}
|
||
}
|
||
result := make([]string, 0, len(requested))
|
||
seen := map[string]bool{}
|
||
for _, key := range requested {
|
||
key = strings.TrimSpace(key)
|
||
if _, ok := specs[key]; ok && !seen[key] {
|
||
result = append(result, key)
|
||
seen[key] = true
|
||
}
|
||
}
|
||
if len(result) == 0 {
|
||
return []string{"speedKmh", "totalMileageKm"}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func historySeriesWindow(rawFrom, rawTo string, now time.Time) (string, string, time.Time, time.Time, error) {
|
||
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||
localNow := now.In(shanghai)
|
||
if strings.TrimSpace(rawFrom) == "" {
|
||
rawFrom = time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, shanghai).Format("2006-01-02T15:04")
|
||
}
|
||
if strings.TrimSpace(rawTo) == "" {
|
||
rawTo = localNow.Format("2006-01-02T15:04")
|
||
}
|
||
start, startOK := parseTrackRequestTime(rawFrom)
|
||
end, endOK := parseTrackRequestTime(rawTo)
|
||
if !startOK || !endOK {
|
||
return "", "", time.Time{}, time.Time{}, clientError{Code: "HISTORY_TIME_INVALID", Message: "趋势时间格式无效"}
|
||
}
|
||
if !end.After(start) {
|
||
return "", "", time.Time{}, time.Time{}, clientError{Code: "HISTORY_TIME_ORDER_INVALID", Message: "趋势结束时间必须晚于开始时间"}
|
||
}
|
||
if end.Sub(start) > 31*24*time.Hour {
|
||
return "", "", time.Time{}, time.Time{}, clientError{Code: "HISTORY_TIME_RANGE_TOO_LARGE", Message: "单次趋势分析最长支持 31 天"}
|
||
}
|
||
return rawFrom, rawTo, start, end, nil
|
||
}
|
||
|
||
func historySeriesGrain(duration time.Duration, targetPoints int) int {
|
||
required := int(math.Ceil(duration.Seconds() / float64(targetPoints)))
|
||
for _, grain := range []int{1, 5, 10, 30, 60, 300, 900, 1800, 3600, 21600, 86400} {
|
||
if required <= grain {
|
||
return grain
|
||
}
|
||
}
|
||
return 86400
|
||
}
|
||
|
||
func historySeriesPointTime(value string) string {
|
||
if parsed, ok := parseVehicleServiceTime(value); ok {
|
||
return parsed.UTC().Format(time.RFC3339)
|
||
}
|
||
return value
|
||
}
|
||
|
||
func (s *Service) historySeriesPlate(ctx context.Context, vin string) string {
|
||
page, err := s.store.Vehicles(ctx, url.Values{"keyword": {vin}, "limit": {"1"}})
|
||
if err == nil && len(page.Items) > 0 {
|
||
return page.Items[0].Plate
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func historyQueryKeywords(query url.Values) []string {
|
||
raw := firstNonEmpty(query.Get("keywords"), query.Get("keyword"), query.Get("vin"))
|
||
parts := strings.FieldsFunc(raw, func(char rune) bool { return char == ',' || char == ';' || char == '\n' })
|
||
result := make([]string, 0, len(parts))
|
||
seen := map[string]struct{}{}
|
||
for _, part := range parts {
|
||
part = strings.TrimSpace(part)
|
||
if part == "" {
|
||
continue
|
||
}
|
||
key := strings.ToLower(part)
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
result = append(result, part)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (s *Service) historyDataForVehicle(ctx context.Context, category, keyword string, query url.Values, fetchLimit int) ([]HistoryDataRow, int, error) {
|
||
protocol := strings.TrimSpace(query.Get("protocol"))
|
||
switch category {
|
||
case "location":
|
||
resolved := url.Values{"keyword": {keyword}, "limit": {strconv.Itoa(fetchLimit)}, "offset": {"0"}}
|
||
copyHistoryFilters(resolved, query, protocol)
|
||
resolvedQuery, err := s.resolveVehicleQuery(ctx, resolved)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
resolvedQuery, err = applyPrincipalHistoryTimeScope(ctx, resolvedQuery.Get("vin"), resolvedQuery)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
page, err := s.store.HistoryLocationsFromTDengine(ctx, resolvedQuery)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
rows := make([]HistoryDataRow, 0, len(page.Items))
|
||
for index, item := range page.Items {
|
||
quality, reason := historyLocationQuality(item)
|
||
rows = append(rows, HistoryDataRow{ID: "location-" + item.VIN + "-" + strconv.Itoa(index) + "-" + item.DeviceTime, VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: quality, QualityReason: reason, Values: map[string]any{"speedKmh": item.SpeedKmh, "totalMileageKm": item.TotalMileageKm, "longitude": item.Longitude, "latitude": item.Latitude}})
|
||
}
|
||
return rows, page.Total, nil
|
||
case "raw":
|
||
resolvedVIN, err := s.resolveVehicleVIN(ctx, keyword, protocol)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
page, err := s.RawFrames(ctx, RawFrameQuery{VIN: resolvedVIN, Protocol: protocol, DateFrom: query.Get("dateFrom"), DateTo: query.Get("dateTo"), IncludeFields: true, Limit: fetchLimit})
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
rows := make([]HistoryDataRow, 0, len(page.Items))
|
||
for _, item := range page.Items {
|
||
values := map[string]any{"frameType": item.FrameType, "rawSizeBytes": item.RawSizeBytes}
|
||
for key, value := range item.ParsedFields {
|
||
values[key] = value
|
||
}
|
||
quality, reason := historyRawQuality(item)
|
||
rows = append(rows, HistoryDataRow{ID: item.ID, VIN: item.VIN, Plate: item.Plate, Protocol: item.Protocol, DeviceTime: item.DeviceTime, ServerTime: item.ServerTime, Quality: quality, QualityReason: reason, EvidenceID: item.ID, Values: values})
|
||
}
|
||
return rows, page.Total, nil
|
||
case "mileage":
|
||
resolved := url.Values{"keyword": {keyword}, "limit": {strconv.Itoa(fetchLimit)}, "offset": {"0"}}
|
||
copyHistoryFilters(resolved, query, protocol)
|
||
resolvedQuery, err := s.resolveVehicleQuery(ctx, resolved)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
|
||
return nil, 0, err
|
||
}
|
||
page, err := s.store.DailyMileage(ctx, resolvedQuery)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
rows := make([]HistoryDataRow, 0, len(page.Items))
|
||
for index, item := range page.Items {
|
||
quality, reason := historyMileageQuality(item)
|
||
rows = append(rows, HistoryDataRow{ID: "mileage-" + item.VIN + "-" + strconv.Itoa(index) + "-" + item.Date, VIN: item.VIN, Plate: item.Plate, Protocol: item.Source, DeviceTime: item.Date, ServerTime: item.Date, Quality: quality, QualityReason: reason, Values: map[string]any{"startMileageKm": item.StartMileageKm, "endMileageKm": item.EndMileageKm, "dailyMileageKm": item.DailyMileageKm}})
|
||
}
|
||
return rows, page.Total, nil
|
||
}
|
||
return nil, 0, nil
|
||
}
|
||
|
||
func historyLocationQuality(item HistoryLocationRow) (string, string) {
|
||
if !validTrackCoordinate(item) {
|
||
return "warning", "坐标超出有效范围或为无效零点"
|
||
}
|
||
if item.SpeedKmh < 0 || item.SpeedKmh > 200 {
|
||
return "warning", fmt.Sprintf("速度 %.2f km/h 超出 0–200 km/h 基础校验范围", item.SpeedKmh)
|
||
}
|
||
if item.TotalMileageKm < 0 {
|
||
return "warning", "总里程为负值"
|
||
}
|
||
deviceTime, deviceOK := parseVehicleServiceTime(item.DeviceTime)
|
||
serverTime, serverOK := parseVehicleServiceTime(item.ServerTime)
|
||
if !deviceOK || !serverOK {
|
||
return "warning", "设备时间或服务接收时间无法解析"
|
||
}
|
||
delaySeconds := int64(math.Abs(serverTime.Sub(deviceTime).Seconds()))
|
||
if delaySeconds > int64(latestTelemetryStaleAfter/time.Second) {
|
||
return "warning", fmt.Sprintf("设备时间与服务接收时间相差 %d 秒,超过 5 分钟", delaySeconds)
|
||
}
|
||
return "normal", "坐标、速度、里程及设备/服务时间通过基础校验"
|
||
}
|
||
|
||
func historyRawQuality(item RawFrameRow) (string, string) {
|
||
status := strings.TrimSpace(item.ParseStatus)
|
||
if status != "" && !strings.EqualFold(status, "ok") && !strings.EqualFold(status, "success") && !strings.EqualFold(status, "parsed") {
|
||
reason := "原始报文解析状态为 " + status
|
||
if detail := strings.TrimSpace(item.ParseError); detail != "" {
|
||
reason += ":" + detail
|
||
}
|
||
return "warning", reason
|
||
}
|
||
deviceTime, deviceOK := parseVehicleServiceTime(item.DeviceTime)
|
||
serverTime, serverOK := parseVehicleServiceTime(item.ServerTime)
|
||
if !deviceOK || !serverOK {
|
||
return "warning", "设备时间或服务接收时间无法解析"
|
||
}
|
||
delaySeconds := int64(math.Abs(serverTime.Sub(deviceTime).Seconds()))
|
||
if delaySeconds > int64(latestTelemetryStaleAfter/time.Second) {
|
||
return "warning", fmt.Sprintf("设备时间与服务接收时间相差 %d 秒,超过 5 分钟", delaySeconds)
|
||
}
|
||
return "normal", "报文解析成功,设备时间与服务接收时间关系正常"
|
||
}
|
||
|
||
func historyMileageQuality(item DailyMileageRow) (string, string) {
|
||
severity := strings.ToLower(strings.TrimSpace(item.AnomalySeverity))
|
||
if severity != "" && severity != "normal" && severity != "good" && severity != "ok" {
|
||
quality := "warning"
|
||
if strings.Contains(severity, "error") || strings.Contains(severity, "critical") || strings.Contains(severity, "严重") {
|
||
quality = "error"
|
||
}
|
||
return quality, "里程聚合记录标记异常:" + item.AnomalySeverity
|
||
}
|
||
if item.StartMileageKm < 0 || item.EndMileageKm < 0 || item.DailyMileageKm < 0 {
|
||
return "warning", "起始、结束或日里程存在负值"
|
||
}
|
||
if item.EndMileageKm < item.StartMileageKm {
|
||
return "warning", "结束里程小于起始里程"
|
||
}
|
||
calculated := item.EndMileageKm - item.StartMileageKm
|
||
tolerance := math.Max(1, calculated*0.05)
|
||
if math.Abs(calculated-item.DailyMileageKm) > tolerance {
|
||
return "warning", fmt.Sprintf("日里程 %.2f km 与起止里程差 %.2f km 不一致", item.DailyMileageKm, calculated)
|
||
}
|
||
if item.DailyMileageKm > 1500 {
|
||
return "warning", fmt.Sprintf("单日里程 %.2f km 超过 1500 km 基础校验范围", item.DailyMileageKm)
|
||
}
|
||
return "normal", "起止里程与日里程关系通过基础校验"
|
||
}
|
||
|
||
func copyHistoryFilters(target, source url.Values, protocol string) {
|
||
if protocol != "" {
|
||
target.Set("protocol", protocol)
|
||
}
|
||
if value := strings.TrimSpace(source.Get("dateFrom")); value != "" {
|
||
target.Set("dateFrom", value)
|
||
}
|
||
if value := strings.TrimSpace(source.Get("dateTo")); value != "" {
|
||
target.Set("dateTo", value)
|
||
}
|
||
}
|
||
|
||
func historyLocationMetrics() []HistoryMetricDefinition {
|
||
return []HistoryMetricDefinition{
|
||
{Key: "speedKmh", Label: "速度", Unit: "km/h", Category: "location", ValueType: "number", DefaultVisible: true},
|
||
{Key: "totalMileageKm", Label: "总里程", Unit: "km", Category: "location", ValueType: "number", DefaultVisible: true},
|
||
{Key: "longitude", Label: "经度", Unit: "°", Category: "location", ValueType: "number", DefaultVisible: true},
|
||
{Key: "latitude", Label: "纬度", Unit: "°", Category: "location", ValueType: "number", DefaultVisible: true},
|
||
}
|
||
}
|
||
|
||
func historyRawMetrics() []HistoryMetricDefinition {
|
||
return []HistoryMetricDefinition{
|
||
{Key: "frameType", Label: "报文类型", Category: "raw", ValueType: "string", DefaultVisible: true},
|
||
{Key: "rawSizeBytes", Label: "报文大小", Unit: "B", Category: "raw", ValueType: "number", DefaultVisible: true},
|
||
}
|
||
}
|
||
|
||
func historyMileageMetrics() []HistoryMetricDefinition {
|
||
return []HistoryMetricDefinition{
|
||
{Key: "startMileageKm", Label: "起始里程", Unit: "km", Category: "mileage", ValueType: "number", DefaultVisible: true},
|
||
{Key: "endMileageKm", Label: "结束里程", Unit: "km", Category: "mileage", ValueType: "number", DefaultVisible: true},
|
||
{Key: "dailyMileageKm", Label: "日里程", Unit: "km", Category: "mileage", ValueType: "number", DefaultVisible: true},
|
||
}
|
||
}
|
||
|
||
func historyMetricsForCategory(category string) []HistoryMetricDefinition {
|
||
switch category {
|
||
case "raw":
|
||
return historyRawMetrics()
|
||
case "mileage":
|
||
return historyMileageMetrics()
|
||
default:
|
||
return historyLocationMetrics()
|
||
}
|
||
}
|
||
|
||
func mergeDiscoveredRawMetrics(base []HistoryMetricDefinition, rows []HistoryDataRow) []HistoryMetricDefinition {
|
||
result := append([]HistoryMetricDefinition(nil), base...)
|
||
seen := map[string]struct{}{}
|
||
for _, metric := range result {
|
||
seen[metric.Key] = struct{}{}
|
||
}
|
||
keys := make([]string, 0)
|
||
for _, row := range rows {
|
||
for key := range row.Values {
|
||
if _, ok := seen[key]; !ok {
|
||
seen[key] = struct{}{}
|
||
keys = append(keys, key)
|
||
}
|
||
}
|
||
}
|
||
sort.Strings(keys)
|
||
for _, key := range keys {
|
||
label, unit := rawMetricMetadata(key)
|
||
result = append(result, HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: len(result) < 7})
|
||
}
|
||
return result
|
||
}
|
||
|
||
func rawMetricMetadata(key string) (string, string) {
|
||
parts := strings.Split(key, ".")
|
||
tail := parts[len(parts)-1]
|
||
protocol := strings.ToUpper(parts[0])
|
||
labels := map[string]string{"speed_kmh": "速度", "total_mileage_km": "总里程", "avg_voltage_v": "平均电压", "soc_percent": "SOC", "longitude": "经度", "latitude": "纬度", "direction": "方向", "current_a": "电流", "temperature_c": "温度"}
|
||
label := labels[tail]
|
||
if label == "" {
|
||
label = strings.ReplaceAll(tail, "_", " ")
|
||
}
|
||
if protocol != "" {
|
||
label += " · " + protocol
|
||
}
|
||
unit := ""
|
||
switch {
|
||
case strings.HasSuffix(tail, "_kmh"):
|
||
unit = "km/h"
|
||
case strings.HasSuffix(tail, "_km"):
|
||
unit = "km"
|
||
case strings.HasSuffix(tail, "_percent"):
|
||
unit = "%"
|
||
case strings.HasSuffix(tail, "_voltage_v") || strings.HasSuffix(tail, "_v"):
|
||
unit = "V"
|
||
case strings.HasSuffix(tail, "_current_a") || strings.HasSuffix(tail, "_a"):
|
||
unit = "A"
|
||
case strings.HasSuffix(tail, "_temperature_c") || strings.HasSuffix(tail, "_c"):
|
||
unit = "℃"
|
||
}
|
||
return label, unit
|
||
}
|
||
|
||
func (s *Service) CreateHistoryExport(ctx context.Context, request HistoryExportRequest) (HistoryExportJob, error) {
|
||
principal, ok := PrincipalFromContext(ctx)
|
||
if !ok {
|
||
return HistoryExportJob{}, clientError{Code: "EXPORT_OWNER_REQUIRED", Message: "导出任务需要已认证账号"}
|
||
}
|
||
ownerID := historyExportOwnerID(principal)
|
||
if ownerID == "" {
|
||
return HistoryExportJob{}, clientError{Code: "EXPORT_OWNER_REQUIRED", Message: "当前账号缺少可持久化的导出身份"}
|
||
}
|
||
request.Keywords = normalizedKeywords(request.Keywords)
|
||
if len(request.Keywords) == 0 || len(request.Keywords) > 5 {
|
||
return HistoryExportJob{}, clientError{Code: "EXPORT_SCOPE_INVALID", Message: "导出任务需要 1 至 5 台车辆"}
|
||
}
|
||
request.Format = strings.ToLower(strings.TrimSpace(request.Format))
|
||
if request.Format == "" {
|
||
request.Format = "csv"
|
||
}
|
||
if request.Format != "csv" {
|
||
return HistoryExportJob{}, clientError{Code: "EXPORT_FORMAT_INVALID", Message: "当前版本的受控导出仅支持 CSV"}
|
||
}
|
||
if request.Category == "" {
|
||
request.Category = "location"
|
||
}
|
||
request.Category = strings.ToLower(strings.TrimSpace(request.Category))
|
||
if request.Category != "location" && request.Category != "raw" && request.Category != "mileage" {
|
||
return HistoryExportJob{}, clientError{Code: "EXPORT_CATEGORY_INVALID", Message: "导出类型仅支持 location、raw 或 mileage"}
|
||
}
|
||
dateFrom, dateTo, _, _, windowErr := historySeriesWindow(request.DateFrom, request.DateTo, time.Now())
|
||
if windowErr != nil {
|
||
return HistoryExportJob{}, windowErr
|
||
}
|
||
request.DateFrom, request.DateTo = dateFrom, dateTo
|
||
request.Metrics = normalizedKeywords(request.Metrics)
|
||
if len(request.Metrics) > 32 {
|
||
return HistoryExportJob{}, clientError{Code: "EXPORT_METRIC_LIMIT_EXCEEDED", Message: "单次导出最多支持 32 个指标"}
|
||
}
|
||
scopes, err := s.resolveHistoryExportScopes(ctx, request)
|
||
if err != nil {
|
||
return HistoryExportJob{}, err
|
||
}
|
||
identifier, err := randomExportID()
|
||
if err != nil {
|
||
return HistoryExportJob{}, err
|
||
}
|
||
now := time.Now().UTC().Format(time.RFC3339)
|
||
vehicleVINs := make([]string, 0, len(scopes))
|
||
for _, scope := range scopes {
|
||
vehicleVINs = append(vehicleVINs, scope.VIN)
|
||
}
|
||
creator := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "account")
|
||
job := &HistoryExportJob{
|
||
ID: identifier, Name: "历史数据_" + time.Now().Format("20060102_150405") + "_" + safeExportNamePart(creator),
|
||
Status: "queued", Progress: 0, Format: request.Format, Category: request.Category, Protocol: request.Protocol,
|
||
Keywords: append([]string(nil), request.Keywords...), Metrics: append([]string(nil), request.Metrics...),
|
||
VehicleVINs: vehicleVINs, VehicleScopes: append([]HistoryExportVehicleScope(nil), scopes...),
|
||
DateFrom: request.DateFrom, DateTo: request.DateTo,
|
||
OwnerID: ownerID, OwnerSubjectID: principal.SubjectID, OwnerName: principal.Name, OwnerUsername: principal.Username,
|
||
OwnerRole: principal.Role, OwnerUserType: principal.UserType, AuthProvider: principal.AuthProvider,
|
||
CustomerRef: principal.CustomerRef, TenantRef: principal.TenantRef,
|
||
CreatedAt: now, UpdatedAt: now,
|
||
Evidence: "账号归属与车辆/时间 Scope 已固化;单并发流式任务;最多 1,000,000 行;完成文件原子发布",
|
||
}
|
||
s.exportsMu.Lock()
|
||
s.exports[job.ID] = job
|
||
if err := s.persistHistoryExportsLocked(); err != nil {
|
||
delete(s.exports, job.ID)
|
||
s.exportsMu.Unlock()
|
||
return HistoryExportJob{}, fmt.Errorf("persist export job: %w", err)
|
||
}
|
||
s.exportsMu.Unlock()
|
||
go s.runHistoryExport(job.ID, request, scopes)
|
||
return copyHistoryExportJob(job), nil
|
||
}
|
||
|
||
func (s *Service) ListHistoryExports(ctx context.Context) []HistoryExportJob {
|
||
principal, _ := PrincipalFromContext(ctx)
|
||
s.exportsMu.RLock()
|
||
result := make([]HistoryExportJob, 0, len(s.exports))
|
||
for _, job := range s.exports {
|
||
if canAccessHistoryExport(principal, job) {
|
||
result = append(result, copyHistoryExportJob(job))
|
||
}
|
||
}
|
||
s.exportsMu.RUnlock()
|
||
sort.Slice(result, func(i, j int) bool { return result[i].CreatedAt > result[j].CreatedAt })
|
||
if len(result) > 20 {
|
||
result = result[:20]
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (s *Service) HistoryExportFile(ctx context.Context, id string) (string, string, error) {
|
||
principal, _ := PrincipalFromContext(ctx)
|
||
s.exportsMu.RLock()
|
||
job := s.exports[id]
|
||
if job == nil || !canAccessHistoryExport(principal, job) {
|
||
s.exportsMu.RUnlock()
|
||
return "", "", clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"}
|
||
}
|
||
copy := copyHistoryExportJob(job)
|
||
path := job.filePath
|
||
s.exportsMu.RUnlock()
|
||
if copy.Status != "completed" || path == "" {
|
||
return "", "", clientError{Code: "EXPORT_NOT_READY", Message: "导出文件尚未生成"}
|
||
}
|
||
if err := s.ensureHistoryExportScopeActive(ctx, copy); err != nil {
|
||
return "", "", err
|
||
}
|
||
return path, copy.Name + ".csv", nil
|
||
}
|
||
|
||
func historyExportOwnerID(principal Principal) string {
|
||
provider := strings.TrimSpace(principal.AuthProvider)
|
||
if provider == "" {
|
||
provider = "unknown"
|
||
}
|
||
if subject := strings.TrimSpace(principal.SubjectID); subject != "" {
|
||
return provider + ":subject:" + subject
|
||
}
|
||
if username := strings.TrimSpace(principal.Username); username != "" {
|
||
return provider + ":username:" + strings.ToLower(username)
|
||
}
|
||
if name := strings.TrimSpace(principal.Name); name != "" {
|
||
return provider + ":name:" + strings.ToLower(name)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func canAccessHistoryExport(principal Principal, job *HistoryExportJob) bool {
|
||
if job == nil {
|
||
return false
|
||
}
|
||
if principal.Role == "admin" || principal.UserType == "admin" {
|
||
return true
|
||
}
|
||
ownerID := historyExportOwnerID(principal)
|
||
return ownerID != "" && job.OwnerID != "" && ownerID == job.OwnerID
|
||
}
|
||
|
||
func safeExportNamePart(value string) string {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return "account"
|
||
}
|
||
var builder strings.Builder
|
||
for _, char := range value {
|
||
switch {
|
||
case char >= 'a' && char <= 'z', char >= 'A' && char <= 'Z', char >= '0' && char <= '9', char == '-', char == '_':
|
||
builder.WriteRune(char)
|
||
default:
|
||
builder.WriteRune('_')
|
||
}
|
||
if builder.Len() >= 32 {
|
||
break
|
||
}
|
||
}
|
||
result := strings.Trim(builder.String(), "_")
|
||
if result == "" {
|
||
return "account"
|
||
}
|
||
return result
|
||
}
|
||
|
||
func (s *Service) resolveHistoryExportScopes(ctx context.Context, request HistoryExportRequest) ([]HistoryExportVehicleScope, error) {
|
||
scopes := make([]HistoryExportVehicleScope, 0, len(request.Keywords))
|
||
seen := map[string]bool{}
|
||
for _, keyword := range request.Keywords {
|
||
vin, err := s.resolveVehicleVIN(ctx, keyword, request.Protocol)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||
if vin == "" || seen[vin] {
|
||
continue
|
||
}
|
||
if err := authorizeVehicleVIN(ctx, vin); err != nil {
|
||
return nil, err
|
||
}
|
||
scoped, err := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{
|
||
"dateFrom": {request.DateFrom},
|
||
"dateTo": {request.DateTo},
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if request.Category == "mileage" {
|
||
fromDay := strings.Split(scoped.Get("dateFrom"), "T")[0]
|
||
toDay := strings.Split(scoped.Get("dateTo"), "T")[0]
|
||
if err := authorizeVehicleDailyEvidenceDate(ctx, vin, fromDay); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := authorizeVehicleDailyEvidenceDate(ctx, vin, toDay); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
seen[vin] = true
|
||
scopes = append(scopes, HistoryExportVehicleScope{VIN: vin, DateFrom: scoped.Get("dateFrom"), DateTo: scoped.Get("dateTo")})
|
||
}
|
||
if len(scopes) == 0 {
|
||
return nil, clientError{Code: "EXPORT_SCOPE_INVALID", Message: "导出任务未解析到可授权车辆"}
|
||
}
|
||
return scopes, nil
|
||
}
|
||
|
||
type historyExportAuthorizationStore interface {
|
||
HistoryExportScopeActive(context.Context, HistoryExportJob) (bool, error)
|
||
}
|
||
|
||
func (s *Service) ensureHistoryExportScopeActive(ctx context.Context, job HistoryExportJob) error {
|
||
if job.OwnerUserType != "customer" {
|
||
return nil
|
||
}
|
||
if job.OwnerSubjectID == "" {
|
||
return clientError{Code: "EXPORT_SCOPE_REVOKED", Message: "客户导出缺少可复核的账号标识,任务已停止"}
|
||
}
|
||
store, ok := s.store.(historyExportAuthorizationStore)
|
||
if !ok {
|
||
return clientError{Code: "EXPORT_SCOPE_REVOKED", Message: "当前数据存储无法复核客户授权,任务已停止"}
|
||
}
|
||
active, err := store.HistoryExportScopeActive(ctx, job)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !active {
|
||
return clientError{Code: "EXPORT_SCOPE_REVOKED", Message: "账号状态或车辆授权已变化,导出任务/下载已停止"}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) historyExportJob(id string) (HistoryExportJob, bool) {
|
||
s.exportsMu.RLock()
|
||
defer s.exportsMu.RUnlock()
|
||
job := s.exports[id]
|
||
if job == nil {
|
||
return HistoryExportJob{}, false
|
||
}
|
||
return copyHistoryExportJob(job), true
|
||
}
|
||
|
||
func (s *Service) runHistoryExport(id string, request HistoryExportRequest, scopes []HistoryExportVehicleScope) {
|
||
s.exportSlots <- struct{}{}
|
||
defer func() { <-s.exportSlots }()
|
||
s.updateHistoryExport(id, func(job *HistoryExportJob) { job.Status = "running"; job.Progress = 1 })
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||
defer cancel()
|
||
job, ok := s.historyExportJob(id)
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := s.ensureHistoryExportScopeActive(ctx, job); err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
store, ok := s.store.(interface {
|
||
HistoryExportCount(context.Context, HistoryExportStoreQuery) (int64, error)
|
||
HistoryExportBatch(context.Context, HistoryExportStoreQuery, HistoryExportCursor, int) ([]HistoryDataRow, HistoryExportCursor, error)
|
||
})
|
||
if !ok {
|
||
s.failHistoryExport(id, errors.New("当前数据存储不支持流式历史导出"))
|
||
return
|
||
}
|
||
queries := make([]HistoryExportStoreQuery, 0, len(scopes))
|
||
var totalRows int64
|
||
for _, scope := range scopes {
|
||
query := HistoryExportStoreQuery{Category: request.Category, VIN: scope.VIN, Protocol: request.Protocol, DateFrom: scope.DateFrom, DateTo: scope.DateTo, Metrics: append([]string(nil), request.Metrics...)}
|
||
count, err := store.HistoryExportCount(ctx, query)
|
||
if err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
totalRows += count
|
||
if totalRows > 1_000_000 {
|
||
s.failHistoryExport(id, fmt.Errorf("结果 %d 行,超过单任务 1,000,000 行上限,请缩小车辆或时间范围", totalRows))
|
||
return
|
||
}
|
||
queries = append(queries, query)
|
||
}
|
||
s.updateHistoryExport(id, func(job *HistoryExportJob) { job.TotalRows = totalRows; job.Progress = 3 })
|
||
columns := historyExportColumns(request.Category, request.Metrics)
|
||
if err := os.MkdirAll(s.exportDir, 0o750); err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
path := filepath.Join(s.exportDir, id+".csv")
|
||
partPath := path + ".part"
|
||
_ = os.Remove(partPath)
|
||
file, err := os.OpenFile(partPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o640)
|
||
if err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
completed := false
|
||
defer func() {
|
||
if !completed {
|
||
_ = file.Close()
|
||
_ = os.Remove(partPath)
|
||
}
|
||
}()
|
||
if _, err := file.WriteString("\uFEFF"); err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
buffered := bufio.NewWriterSize(file, 1<<20)
|
||
writer := csv.NewWriter(buffered)
|
||
if err := writeHistoryExportMetadata(writer, request, job, columns); err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
var processed int64
|
||
for _, query := range queries {
|
||
cursor := HistoryExportCursor{}
|
||
for {
|
||
currentJob, exists := s.historyExportJob(id)
|
||
if !exists {
|
||
return
|
||
}
|
||
if err := s.ensureHistoryExportScopeActive(ctx, currentJob); err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
rows, nextCursor, err := store.HistoryExportBatch(ctx, query, cursor, 5000)
|
||
if err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
if len(rows) == 0 {
|
||
break
|
||
}
|
||
if processed+int64(len(rows)) > 1_000_000 {
|
||
s.failHistoryExport(id, errors.New("导出期间数据增长后超过 1,000,000 行上限,任务已安全停止"))
|
||
return
|
||
}
|
||
for _, row := range rows {
|
||
if err := writer.Write(historyExportRecord(row, columns)); err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
}
|
||
writer.Flush()
|
||
if err := writer.Error(); err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
if err := buffered.Flush(); err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
processed += int64(len(rows))
|
||
stat, _ := file.Stat()
|
||
progress := 95
|
||
if totalRows > 0 {
|
||
progress = 3 + int(float64(processed)/float64(totalRows)*92)
|
||
if progress > 95 {
|
||
progress = 95
|
||
}
|
||
}
|
||
s.updateHistoryExport(id, func(job *HistoryExportJob) {
|
||
job.ProcessedRows = processed
|
||
job.Progress = progress
|
||
if stat != nil {
|
||
job.FileSizeBytes = stat.Size()
|
||
}
|
||
})
|
||
if nextCursor == cursor {
|
||
s.failHistoryExport(id, errors.New("导出游标未推进,任务已安全停止"))
|
||
return
|
||
}
|
||
cursor = nextCursor
|
||
}
|
||
}
|
||
writer.Flush()
|
||
writeErr := writer.Error()
|
||
bufferErr := buffered.Flush()
|
||
syncErr := file.Sync()
|
||
closeErr := file.Close()
|
||
if writeErr != nil {
|
||
s.failHistoryExport(id, writeErr)
|
||
return
|
||
}
|
||
if bufferErr != nil {
|
||
s.failHistoryExport(id, bufferErr)
|
||
return
|
||
}
|
||
if syncErr != nil {
|
||
s.failHistoryExport(id, syncErr)
|
||
return
|
||
}
|
||
if closeErr != nil {
|
||
s.failHistoryExport(id, closeErr)
|
||
return
|
||
}
|
||
if err := os.Rename(partPath, path); err != nil {
|
||
s.failHistoryExport(id, err)
|
||
return
|
||
}
|
||
completed = true
|
||
stat, _ := os.Stat(path)
|
||
completedAt := time.Now().UTC().Format(time.RFC3339)
|
||
s.updateHistoryExport(id, func(job *HistoryExportJob) {
|
||
job.Status = "completed"
|
||
job.Progress = 100
|
||
job.RowCount = int(processed)
|
||
job.ProcessedRows = processed
|
||
job.TotalRows = totalRows
|
||
job.CompletedAt = completedAt
|
||
if stat != nil {
|
||
job.FileSizeBytes = stat.Size()
|
||
}
|
||
job.filePath = path
|
||
job.DownloadURL = "/api/v2/exports/" + job.ID + "/download"
|
||
})
|
||
}
|
||
|
||
func (s *Service) updateHistoryExport(id string, update func(*HistoryExportJob)) {
|
||
s.exportsMu.Lock()
|
||
defer s.exportsMu.Unlock()
|
||
if job := s.exports[id]; job != nil {
|
||
update(job)
|
||
job.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||
_ = s.persistHistoryExportsLocked()
|
||
}
|
||
}
|
||
|
||
func (s *Service) failHistoryExport(id string, err error) {
|
||
s.updateHistoryExport(id, func(job *HistoryExportJob) { job.Status = "failed"; job.Error = err.Error(); job.Progress = 0 })
|
||
}
|
||
|
||
func (s *Service) exportIndexPath() string {
|
||
return filepath.Join(s.exportDir, "jobs.json")
|
||
}
|
||
|
||
func (s *Service) persistHistoryExportsLocked() error {
|
||
if err := os.MkdirAll(s.exportDir, 0o750); err != nil {
|
||
return err
|
||
}
|
||
jobs := make([]HistoryExportJob, 0, len(s.exports))
|
||
for _, job := range s.exports {
|
||
jobs = append(jobs, copyHistoryExportJob(job))
|
||
}
|
||
sort.Slice(jobs, func(i, j int) bool { return jobs[i].CreatedAt > jobs[j].CreatedAt })
|
||
if len(jobs) > 100 {
|
||
jobs = jobs[:100]
|
||
}
|
||
contents, err := json.MarshalIndent(jobs, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
temporary := s.exportIndexPath() + ".tmp"
|
||
if err := os.WriteFile(temporary, contents, 0o640); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(temporary, s.exportIndexPath())
|
||
}
|
||
|
||
func (s *Service) loadHistoryExports() {
|
||
contents, err := os.ReadFile(s.exportIndexPath())
|
||
if err != nil {
|
||
return
|
||
}
|
||
var jobs []HistoryExportJob
|
||
if json.Unmarshal(contents, &jobs) != nil {
|
||
return
|
||
}
|
||
now := time.Now().UTC().Format(time.RFC3339)
|
||
for index := range jobs {
|
||
job := jobs[index]
|
||
if job.Status == "queued" || job.Status == "running" {
|
||
job.Status = "failed"
|
||
job.Progress = 0
|
||
job.Error = "服务重启,任务未完成,请重新创建导出"
|
||
job.UpdatedAt = now
|
||
}
|
||
if job.Status == "completed" {
|
||
job.filePath = filepath.Join(s.exportDir, job.ID+".csv")
|
||
if _, err := os.Stat(job.filePath); err != nil {
|
||
job.Status = "failed"
|
||
job.Progress = 0
|
||
job.Error = "导出文件已不存在,请重新创建导出"
|
||
job.DownloadURL = ""
|
||
job.filePath = ""
|
||
job.UpdatedAt = now
|
||
}
|
||
}
|
||
s.exports[job.ID] = &job
|
||
}
|
||
s.exportsMu.Lock()
|
||
_ = s.persistHistoryExportsLocked()
|
||
s.exportsMu.Unlock()
|
||
}
|
||
|
||
func randomExportID() (string, error) {
|
||
bytes := make([]byte, 8)
|
||
if _, err := rand.Read(bytes); err != nil {
|
||
return "", err
|
||
}
|
||
return "exp_" + hex.EncodeToString(bytes), nil
|
||
}
|
||
|
||
func copyHistoryExportJob(job *HistoryExportJob) HistoryExportJob {
|
||
if job == nil {
|
||
return HistoryExportJob{}
|
||
}
|
||
copy := *job
|
||
copy.Keywords = append([]string(nil), job.Keywords...)
|
||
copy.Metrics = append([]string(nil), job.Metrics...)
|
||
copy.VehicleVINs = append([]string(nil), job.VehicleVINs...)
|
||
copy.VehicleScopes = append([]HistoryExportVehicleScope(nil), job.VehicleScopes...)
|
||
return copy
|
||
}
|
||
|
||
func selectedExportMetrics(columns []HistoryMetricDefinition, requested []string) []HistoryMetricDefinition {
|
||
if len(requested) == 0 {
|
||
return append([]HistoryMetricDefinition(nil), columns...)
|
||
}
|
||
wanted := map[string]struct{}{}
|
||
for _, key := range requested {
|
||
wanted[key] = struct{}{}
|
||
}
|
||
result := make([]HistoryMetricDefinition, 0, len(requested))
|
||
for _, column := range columns {
|
||
if _, ok := wanted[column.Key]; ok {
|
||
result = append(result, column)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func historyExportColumns(category string, requested []string) []HistoryMetricDefinition {
|
||
base := historyMetricsForCategory(category)
|
||
selected := selectedExportMetrics(base, requested)
|
||
if category != "raw" || len(requested) == 0 {
|
||
return selected
|
||
}
|
||
known := map[string]bool{}
|
||
for _, column := range selected {
|
||
known[column.Key] = true
|
||
}
|
||
for _, key := range requested {
|
||
if known[key] {
|
||
continue
|
||
}
|
||
label, unit := rawMetricMetadata(key)
|
||
selected = append(selected, HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: true})
|
||
known[key] = true
|
||
}
|
||
return selected
|
||
}
|
||
|
||
func writeHistoryExportMetadata(writer *csv.Writer, request HistoryExportRequest, job HistoryExportJob, columns []HistoryMetricDefinition) error {
|
||
rows := [][]string{
|
||
{"导出元数据", "查询开始", request.DateFrom, "查询结束", request.DateTo, "车辆", strings.Join(request.Keywords, "、"), "数据类型", request.Category, "协议", firstNonEmpty(request.Protocol, "全部")},
|
||
{"导出审计", "创建账号", firstNonEmpty(job.OwnerUsername, job.OwnerName), "显示名称", job.OwnerName, "角色", firstNonEmpty(job.OwnerUserType, job.OwnerRole), "客户", job.CustomerRef, "创建时间", job.CreatedAt, "生成时间", time.Now().UTC().Format(time.RFC3339)},
|
||
{"车辆 Scope", strings.Join(job.VehicleVINs, "、"), "授权窗口", job.DateFrom + " 至 " + job.DateTo},
|
||
{"指标与单位", strings.Join(exportMetricLabels(columns), ";")},
|
||
append([]string{"设备时间", "服务时间", "车牌", "VIN", "协议", "数据质量", "质量原因", "证据ID"}, exportMetricLabels(columns)...),
|
||
}
|
||
for _, row := range rows {
|
||
if err := writer.Write(row); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func historyExportRecord(row HistoryDataRow, columns []HistoryMetricDefinition) []string {
|
||
record := []string{row.DeviceTime, row.ServerTime, row.Plate, row.VIN, row.Protocol, row.Quality, row.QualityReason, row.EvidenceID}
|
||
for _, column := range columns {
|
||
value, ok := row.Values[column.Key]
|
||
if !ok || value == nil {
|
||
record = append(record, "")
|
||
continue
|
||
}
|
||
record = append(record, fmt.Sprint(value))
|
||
}
|
||
return record
|
||
}
|
||
|
||
func exportMetricLabels(columns []HistoryMetricDefinition) []string {
|
||
labels := make([]string, 0, len(columns))
|
||
for _, column := range columns {
|
||
label := column.Label
|
||
if column.Unit != "" {
|
||
label += "(" + column.Unit + ")"
|
||
}
|
||
labels = append(labels, label)
|
||
}
|
||
return labels
|
||
}
|
||
|
||
func summarizeTrack(points []HistoryLocationRow) TrackPlaybackSummary {
|
||
summary := TrackPlaybackSummary{PointCount: len(points)}
|
||
if len(points) == 0 {
|
||
return summary
|
||
}
|
||
summary.StartTime = points[0].DeviceTime
|
||
summary.EndTime = points[len(points)-1].DeviceTime
|
||
var speedTotal float64
|
||
var speedCount int
|
||
var coordinateDistance float64
|
||
for index, point := range points {
|
||
if point.SpeedKmh >= 0 {
|
||
speedTotal += point.SpeedKmh
|
||
speedCount++
|
||
}
|
||
if point.SpeedKmh > summary.MaximumSpeedKmh {
|
||
summary.MaximumSpeedKmh = point.SpeedKmh
|
||
}
|
||
if index > 0 && validTrackCoordinate(points[index-1]) && validTrackCoordinate(point) {
|
||
previousTime, previousOK := parseVehicleServiceTime(points[index-1].DeviceTime)
|
||
currentTime, currentOK := parseVehicleServiceTime(point.DeviceTime)
|
||
if previousOK && currentOK && currentTime.After(previousTime) && currentTime.Sub(previousTime) <= time.Duration(trackGapThresholdSeconds)*time.Second {
|
||
coordinateDistance += haversineKm(points[index-1].Latitude, points[index-1].Longitude, point.Latitude, point.Longitude)
|
||
}
|
||
}
|
||
}
|
||
if speedCount > 0 {
|
||
summary.AverageSpeedKmh = speedTotal / float64(speedCount)
|
||
}
|
||
mileageDistance := points[len(points)-1].TotalMileageKm - points[0].TotalMileageKm
|
||
if points[0].Protocol == points[len(points)-1].Protocol && mileageDistance >= 0 && mileageDistance < 10000 {
|
||
summary.DistanceKm = mileageDistance
|
||
} else {
|
||
summary.DistanceKm = coordinateDistance
|
||
}
|
||
start, startOK := parseVehicleServiceTime(summary.StartTime)
|
||
end, endOK := parseVehicleServiceTime(summary.EndTime)
|
||
if startOK && endOK && end.After(start) {
|
||
summary.DurationSeconds = int64(end.Sub(start).Seconds())
|
||
}
|
||
return summary
|
||
}
|
||
|
||
func summarizeTrackSources(points []HistoryLocationRow) []TrackPlaybackSource {
|
||
byProtocol := map[string]*TrackPlaybackSource{}
|
||
for _, point := range points {
|
||
protocol := strings.TrimSpace(point.Protocol)
|
||
if protocol == "" {
|
||
protocol = "UNKNOWN"
|
||
}
|
||
source := byProtocol[protocol]
|
||
if source == nil {
|
||
source = &TrackPlaybackSource{Protocol: protocol, StartTime: point.DeviceTime}
|
||
byProtocol[protocol] = source
|
||
}
|
||
source.PointCount++
|
||
source.EndTime = point.DeviceTime
|
||
}
|
||
keys := make([]string, 0, len(byProtocol))
|
||
for key := range byProtocol {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
result := make([]TrackPlaybackSource, 0, len(keys))
|
||
for _, key := range keys {
|
||
result = append(result, *byProtocol[key])
|
||
}
|
||
return result
|
||
}
|
||
|
||
const (
|
||
trackGapThresholdSeconds = int64(10 * 60)
|
||
trackStopMinimumSeconds = int64(3 * 60)
|
||
trackStopSpeedKmh = 3.0
|
||
trackDriftSpeedKmh = 220.0
|
||
trackEventLimit = 64
|
||
)
|
||
|
||
func trackCoverage(query url.Values, total, fetched, processed, returned int, truncated, sampled bool) TrackCoverage {
|
||
coverage := TrackCoverage{
|
||
RequestedStart: strings.TrimSpace(query.Get("dateFrom")),
|
||
RequestedEnd: strings.TrimSpace(query.Get("dateTo")),
|
||
TotalPoints: total,
|
||
FetchedPoints: fetched,
|
||
ProcessedPoints: processed,
|
||
ReturnedPoints: returned,
|
||
Complete: !truncated,
|
||
LimitReasons: []string{},
|
||
}
|
||
if truncated {
|
||
coverage.LimitReasons = append(coverage.LimitReasons, "latest_source_slice")
|
||
}
|
||
if processed < fetched {
|
||
coverage.LimitReasons = append(coverage.LimitReasons, "quality_filter")
|
||
}
|
||
if sampled {
|
||
coverage.LimitReasons = append(coverage.LimitReasons, "map_sampling")
|
||
}
|
||
if truncated {
|
||
coverage.Evidence = fmt.Sprintf("查询共 %d 个源点,仅加工最新 %d 个;摘要不代表完整时间窗", total, fetched)
|
||
} else {
|
||
coverage.Evidence = fmt.Sprintf("查询窗 %d 个源点已完整读取", total)
|
||
}
|
||
return coverage
|
||
}
|
||
|
||
func analyzeTrackPoints(raw []HistoryLocationRow) ([]HistoryLocationRow, TrackQuality) {
|
||
quality := TrackQuality{RawPoints: len(raw)}
|
||
points := make([]HistoryLocationRow, 0, len(raw))
|
||
lastByProtocol := map[string]HistoryLocationRow{}
|
||
for _, point := range raw {
|
||
if !validTrackCoordinate(point) {
|
||
quality.InvalidCoordinatePoints++
|
||
continue
|
||
}
|
||
protocol := firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN")
|
||
if previous, ok := lastByProtocol[protocol]; ok {
|
||
if point.DeviceTime == previous.DeviceTime && point.Protocol == previous.Protocol &&
|
||
math.Abs(point.Longitude-previous.Longitude) < 0.000001 && math.Abs(point.Latitude-previous.Latitude) < 0.000001 {
|
||
quality.DuplicatePoints++
|
||
continue
|
||
}
|
||
previousTime, previousOK := parseVehicleServiceTime(previous.DeviceTime)
|
||
currentTime, currentOK := parseVehicleServiceTime(point.DeviceTime)
|
||
if previousOK && currentOK && currentTime.After(previousTime) {
|
||
seconds := currentTime.Sub(previousTime).Seconds()
|
||
distance := haversineKm(previous.Latitude, previous.Longitude, point.Latitude, point.Longitude)
|
||
impliedSpeed := distance / seconds * 3600
|
||
if seconds <= 300 && distance >= 0.5 && impliedSpeed > trackDriftSpeedKmh {
|
||
quality.DriftPoints++
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
points = append(points, point)
|
||
lastByProtocol[protocol] = point
|
||
}
|
||
quality.ValidPoints = len(points)
|
||
issues := quality.InvalidCoordinatePoints + quality.DuplicatePoints + quality.DriftPoints
|
||
if issues == 0 {
|
||
quality.Status = "good"
|
||
quality.Evidence = "坐标、重复点、漂移和时间间隔检查未发现异常"
|
||
} else {
|
||
quality.Status = "warning"
|
||
quality.Evidence = fmt.Sprintf("过滤无效坐标 %d、重复 %d、疑似漂移 %d;检测到大间隔 %d", quality.InvalidCoordinatePoints, quality.DuplicatePoints, quality.DriftPoints, quality.LargeGapCount)
|
||
}
|
||
return points, quality
|
||
}
|
||
|
||
func selectTrackPrimarySource(points []HistoryLocationRow, requestedProtocol string) ([]HistoryLocationRow, string, int) {
|
||
requestedProtocol = strings.ToUpper(strings.TrimSpace(requestedProtocol))
|
||
counts := map[string]int{}
|
||
latest := map[string]time.Time{}
|
||
for _, point := range points {
|
||
protocol := strings.ToUpper(firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN"))
|
||
counts[protocol]++
|
||
if observedAt, ok := parseVehicleServiceTime(point.DeviceTime); ok && observedAt.After(latest[protocol]) {
|
||
latest[protocol] = observedAt
|
||
}
|
||
}
|
||
selected := requestedProtocol
|
||
if selected == "" {
|
||
protocols := make([]string, 0, len(counts))
|
||
for protocol := range counts {
|
||
protocols = append(protocols, protocol)
|
||
}
|
||
sort.Slice(protocols, func(i, j int) bool {
|
||
if counts[protocols[i]] != counts[protocols[j]] {
|
||
return counts[protocols[i]] > counts[protocols[j]]
|
||
}
|
||
if !latest[protocols[i]].Equal(latest[protocols[j]]) {
|
||
return latest[protocols[i]].After(latest[protocols[j]])
|
||
}
|
||
return protocols[i] < protocols[j]
|
||
})
|
||
if len(protocols) > 0 {
|
||
selected = protocols[0]
|
||
}
|
||
}
|
||
result := make([]HistoryLocationRow, 0, counts[selected])
|
||
for _, point := range points {
|
||
if strings.EqualFold(firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN"), selected) {
|
||
result = append(result, point)
|
||
}
|
||
}
|
||
return result, selected, len(points) - len(result)
|
||
}
|
||
|
||
func applyTrackSequenceQuality(points []HistoryLocationRow, quality *TrackQuality) {
|
||
quality.SourceSwitches = 0
|
||
quality.LargeGapCount = 0
|
||
quality.MaximumGapSeconds = 0
|
||
for index := 1; index < len(points); index++ {
|
||
if points[index].Protocol != points[index-1].Protocol {
|
||
quality.SourceSwitches++
|
||
}
|
||
previousTime, previousOK := parseVehicleServiceTime(points[index-1].DeviceTime)
|
||
currentTime, currentOK := parseVehicleServiceTime(points[index].DeviceTime)
|
||
if !previousOK || !currentOK || !currentTime.After(previousTime) {
|
||
continue
|
||
}
|
||
gap := int64(currentTime.Sub(previousTime).Seconds())
|
||
if gap > quality.MaximumGapSeconds {
|
||
quality.MaximumGapSeconds = gap
|
||
}
|
||
if gap > trackGapThresholdSeconds {
|
||
quality.LargeGapCount++
|
||
}
|
||
}
|
||
issues := quality.InvalidCoordinatePoints + quality.DuplicatePoints + quality.DriftPoints + quality.LargeGapCount
|
||
if issues == 0 {
|
||
quality.Status = "good"
|
||
quality.Evidence = "主来源坐标、重复点、漂移和时间间隔检查未发现异常"
|
||
} else {
|
||
quality.Status = "warning"
|
||
quality.Evidence = fmt.Sprintf("主来源过滤无效坐标 %d、重复 %d、疑似漂移 %d;检测到大间隔 %d", quality.InvalidCoordinatePoints, quality.DuplicatePoints, quality.DriftPoints, quality.LargeGapCount)
|
||
}
|
||
}
|
||
|
||
func applyTrackPrimarySourceCoverage(coverage *TrackCoverage, protocol string, alternatePoints int) {
|
||
if alternatePoints <= 0 {
|
||
return
|
||
}
|
||
coverage.LimitReasons = append(coverage.LimitReasons, "primary_source_projection")
|
||
coverage.Evidence += fmt.Sprintf(";地图选用 %s 主来源,另 %d 个有效点保留在来源统计", firstNonEmpty(protocol, "UNKNOWN"), alternatePoints)
|
||
}
|
||
|
||
func buildTrackSegments(points []HistoryLocationRow) []TrackSegment {
|
||
if len(points) == 0 {
|
||
return []TrackSegment{}
|
||
}
|
||
if len(points) == 1 {
|
||
return []TrackSegment{{Index: 0, Type: "point", Title: "单点", StartTime: points[0].DeviceTime, EndTime: points[0].DeviceTime, PointCount: 1}}
|
||
}
|
||
segments := make([]TrackSegment, 0)
|
||
for index := 1; index < len(points); index++ {
|
||
previous, current := points[index-1], points[index]
|
||
previousTime, previousOK := parseVehicleServiceTime(previous.DeviceTime)
|
||
currentTime, currentOK := parseVehicleServiceTime(current.DeviceTime)
|
||
duration := int64(0)
|
||
validInterval := previousOK && currentOK && currentTime.After(previousTime)
|
||
if validInterval {
|
||
duration = int64(currentTime.Sub(previousTime).Seconds())
|
||
}
|
||
distance := haversineKm(previous.Latitude, previous.Longitude, current.Latitude, current.Longitude)
|
||
typeName, title := "moving", "行驶"
|
||
switch {
|
||
case !validInterval || currentTime.Sub(previousTime) > time.Duration(trackGapThresholdSeconds)*time.Second:
|
||
typeName, title = "gap", "数据间隔"
|
||
case previous.SpeedKmh <= trackStopSpeedKmh && current.SpeedKmh <= trackStopSpeedKmh && distance <= 0.15:
|
||
typeName, title = "stopped", "停车(GPS 推断)"
|
||
}
|
||
if typeName == "gap" {
|
||
distance = 0
|
||
}
|
||
if len(segments) > 0 && segments[len(segments)-1].Type == typeName && segments[len(segments)-1].EndIndex == index-1 {
|
||
segment := &segments[len(segments)-1]
|
||
segment.EndIndex = index
|
||
segment.EndTime = current.DeviceTime
|
||
segment.DurationSeconds += duration
|
||
segment.DistanceKm += distance
|
||
segment.PointCount++
|
||
continue
|
||
}
|
||
segments = append(segments, TrackSegment{
|
||
Index: len(segments), Type: typeName, Title: title,
|
||
StartTime: previous.DeviceTime, EndTime: current.DeviceTime,
|
||
DurationSeconds: duration, DistanceKm: distance, PointCount: 2,
|
||
StartIndex: index - 1, EndIndex: index,
|
||
})
|
||
}
|
||
return segments
|
||
}
|
||
|
||
func buildTrackStops(points []HistoryLocationRow, segments []TrackSegment) []TrackStop {
|
||
stops := make([]TrackStop, 0)
|
||
for _, segment := range segments {
|
||
if segment.Type != "stopped" || segment.DurationSeconds < trackStopMinimumSeconds {
|
||
continue
|
||
}
|
||
longitude, latitude := 0.0, 0.0
|
||
count := 0
|
||
for index := segment.StartIndex; index <= segment.EndIndex && index < len(points); index++ {
|
||
longitude += points[index].Longitude
|
||
latitude += points[index].Latitude
|
||
count++
|
||
}
|
||
if count > 0 {
|
||
longitude /= float64(count)
|
||
latitude /= float64(count)
|
||
}
|
||
stops = append(stops, TrackStop{
|
||
Index: len(stops), StartTime: segment.StartTime, EndTime: segment.EndTime,
|
||
DurationSeconds: segment.DurationSeconds, PointCount: segment.PointCount,
|
||
Longitude: longitude, Latitude: latitude,
|
||
Evidence: "由连续低速且小位移位置点推断,不代表点火状态",
|
||
startIndex: segment.StartIndex, endIndex: segment.EndIndex,
|
||
})
|
||
}
|
||
return stops
|
||
}
|
||
|
||
func buildTrackEvents(points []HistoryLocationRow, segments []TrackSegment, stops []TrackStop) []TrackPlaybackEvent {
|
||
if len(points) == 0 {
|
||
return []TrackPlaybackEvent{}
|
||
}
|
||
events := []TrackPlaybackEvent{trackEvent(0, "start", "开始行驶", points[0])}
|
||
for _, stop := range stops {
|
||
events = append(events, trackEvent(stop.startIndex, "stop", "停车(GPS 推断)", points[stop.startIndex]))
|
||
}
|
||
for _, segment := range segments {
|
||
if segment.Type == "gap" && segment.EndIndex < len(points) {
|
||
events = append(events, trackEvent(segment.EndIndex, "gap", "数据间隔", points[segment.EndIndex]))
|
||
}
|
||
}
|
||
for index := 1; index < len(points)-1; index++ {
|
||
delta := points[index].SpeedKmh - points[index-1].SpeedKmh
|
||
typeName, title := "", ""
|
||
switch {
|
||
case points[index].Protocol != points[index-1].Protocol:
|
||
typeName, title = "source_switch", "数据源切换"
|
||
case delta >= 25:
|
||
typeName, title = "acceleration", "急加速"
|
||
case delta <= -25:
|
||
typeName, title = "braking", "急减速"
|
||
}
|
||
if typeName != "" {
|
||
events = append(events, trackEvent(index, typeName, title, points[index]))
|
||
}
|
||
}
|
||
events = append(events, trackEvent(len(points)-1, "end", "结束行驶", points[len(points)-1]))
|
||
sort.SliceStable(events, func(i, j int) bool { return events[i].Index < events[j].Index })
|
||
if len(events) > trackEventLimit {
|
||
limited := append([]TrackPlaybackEvent(nil), events[:trackEventLimit-1]...)
|
||
limited = append(limited, events[len(events)-1])
|
||
events = limited
|
||
}
|
||
return events
|
||
}
|
||
|
||
func trackEvent(index int, eventType, title string, point HistoryLocationRow) TrackPlaybackEvent {
|
||
return TrackPlaybackEvent{Index: index, Type: eventType, Title: title, Time: point.DeviceTime, SpeedKmh: point.SpeedKmh, SOCPercent: point.SOCPercent, SOCAvailable: point.SOCAvailable, DirectionDeg: point.DirectionDeg, AlarmFlag: point.AlarmFlag, Longitude: point.Longitude, Latitude: point.Latitude}
|
||
}
|
||
|
||
func trackKeyIndexes(events []TrackPlaybackEvent, segments []TrackSegment, stops []TrackStop) []int {
|
||
indexes := make([]int, 0, len(events)+len(segments)*2+len(stops))
|
||
for _, event := range events {
|
||
indexes = append(indexes, event.Index)
|
||
}
|
||
for _, segment := range segments {
|
||
indexes = append(indexes, segment.StartIndex, segment.EndIndex)
|
||
}
|
||
for _, stop := range stops {
|
||
indexes = append(indexes, (stop.startIndex+stop.endIndex)/2)
|
||
}
|
||
return indexes
|
||
}
|
||
|
||
func sampleTrackPoints(points []HistoryLocationRow, maxPoints int) []HistoryLocationRow {
|
||
result, _ := sampleTrackPointsWithKeys(points, maxPoints, nil)
|
||
return result
|
||
}
|
||
|
||
func sampleTrackPointsWithKeys(points []HistoryLocationRow, maxPoints int, keyIndexes []int) ([]HistoryLocationRow, []int) {
|
||
if len(points) <= maxPoints {
|
||
indexes := make([]int, len(points))
|
||
for index := range points {
|
||
indexes[index] = index
|
||
}
|
||
return append([]HistoryLocationRow(nil), points...), indexes
|
||
}
|
||
if maxPoints < 2 {
|
||
maxPoints = 2
|
||
}
|
||
selected := map[int]struct{}{0: {}, len(points) - 1: {}}
|
||
for _, index := range keyIndexes {
|
||
if index >= 0 && index < len(points) {
|
||
selected[index] = struct{}{}
|
||
}
|
||
}
|
||
indexes := sortedTrackIndexes(selected)
|
||
if len(indexes) > maxPoints {
|
||
indexes = uniformlySelectTrackIndexes(indexes, maxPoints)
|
||
selected = make(map[int]struct{}, len(indexes))
|
||
for _, index := range indexes {
|
||
selected[index] = struct{}{}
|
||
}
|
||
}
|
||
remaining := maxPoints - len(selected)
|
||
for slot := 1; slot <= remaining; slot++ {
|
||
index := int(math.Round(float64(slot) * float64(len(points)-1) / float64(remaining+1)))
|
||
selected[index] = struct{}{}
|
||
}
|
||
for len(selected) < maxPoints {
|
||
indexes = sortedTrackIndexes(selected)
|
||
largestGap, midpoint := 0, -1
|
||
for index := 1; index < len(indexes); index++ {
|
||
gap := indexes[index] - indexes[index-1]
|
||
if gap > largestGap {
|
||
largestGap = gap
|
||
midpoint = indexes[index-1] + gap/2
|
||
}
|
||
}
|
||
if midpoint < 0 || largestGap <= 1 {
|
||
break
|
||
}
|
||
selected[midpoint] = struct{}{}
|
||
}
|
||
indexes = sortedTrackIndexes(selected)
|
||
if len(indexes) > maxPoints {
|
||
indexes = uniformlySelectTrackIndexes(indexes, maxPoints)
|
||
}
|
||
result := make([]HistoryLocationRow, 0, len(indexes))
|
||
for _, index := range indexes {
|
||
result = append(result, points[index])
|
||
}
|
||
return result, indexes
|
||
}
|
||
|
||
func sortedTrackIndexes(selected map[int]struct{}) []int {
|
||
indexes := make([]int, 0, len(selected))
|
||
for index := range selected {
|
||
indexes = append(indexes, index)
|
||
}
|
||
sort.Ints(indexes)
|
||
return indexes
|
||
}
|
||
|
||
func uniformlySelectTrackIndexes(indexes []int, limit int) []int {
|
||
if len(indexes) <= limit {
|
||
return append([]int(nil), indexes...)
|
||
}
|
||
result := make([]int, 0, limit)
|
||
for slot := 0; slot < limit; slot++ {
|
||
index := int(math.Round(float64(slot) * float64(len(indexes)-1) / float64(limit-1)))
|
||
result = append(result, indexes[index])
|
||
}
|
||
return result
|
||
}
|
||
|
||
func nearestSampledIndex(original int, sampled []int) int {
|
||
if len(sampled) == 0 {
|
||
return 0
|
||
}
|
||
position := sort.SearchInts(sampled, original)
|
||
if position == 0 {
|
||
return 0
|
||
}
|
||
if position == len(sampled) {
|
||
return len(sampled) - 1
|
||
}
|
||
if original-sampled[position-1] <= sampled[position]-original {
|
||
return position - 1
|
||
}
|
||
return position
|
||
}
|
||
|
||
func validTrackCoordinate(point HistoryLocationRow) bool {
|
||
return point.Longitude >= 72 && point.Longitude <= 138 && point.Latitude >= 0.8 && point.Latitude <= 56
|
||
}
|
||
|
||
func haversineKm(lat1, lon1, lat2, lon2 float64) float64 {
|
||
const earthRadiusKm = 6371.0088
|
||
toRadians := math.Pi / 180
|
||
dLat := (lat2 - lat1) * toRadians
|
||
dLon := (lon2 - lon1) * toRadians
|
||
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1*toRadians)*math.Cos(lat2*toRadians)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||
return earthRadiusKm * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||
}
|
||
|
||
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
|
||
}
|
||
if err := authorizeVehicleVIN(ctx, query.VIN); err != nil {
|
||
return Page[RawFrameRow]{}, err
|
||
}
|
||
scoped, err := applyPrincipalHistoryTimeScope(ctx, query.VIN, url.Values{
|
||
"dateFrom": {query.DateFrom},
|
||
"dateTo": {query.DateTo},
|
||
})
|
||
if err != nil {
|
||
return Page[RawFrameRow]{}, err
|
||
}
|
||
query.DateFrom = scoped.Get("dateFrom")
|
||
query.DateTo = scoped.Get("dateTo")
|
||
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
|
||
}
|
||
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); 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
|
||
}
|
||
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
|
||
return Page[DailyMileageRow]{}, err
|
||
}
|
||
resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery)
|
||
if err != nil {
|
||
return Page[DailyMileageRow]{}, err
|
||
}
|
||
resolvedQuery, err = normalizeDailyMileageWindow(resolvedQuery, time.Now())
|
||
if err != nil {
|
||
return Page[DailyMileageRow]{}, err
|
||
}
|
||
return s.store.DailyMileage(ctx, resolvedQuery)
|
||
}
|
||
|
||
func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||
if err != nil {
|
||
return MileageStatistics{}, err
|
||
}
|
||
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
|
||
return MileageStatistics{}, err
|
||
}
|
||
resolvedQuery, err = normalizeMileageVINSelection(resolvedQuery)
|
||
if err != nil {
|
||
return MileageStatistics{}, err
|
||
}
|
||
resolvedQuery, err = normalizeMileageStatisticsWindow(resolvedQuery, time.Now())
|
||
if err != nil {
|
||
return MileageStatistics{}, err
|
||
}
|
||
return s.store.MileageStatistics(ctx, resolvedQuery)
|
||
}
|
||
|
||
func normalizeMileageVINSelection(query url.Values) (url.Values, error) {
|
||
next := cloneValues(query)
|
||
seen := map[string]bool{}
|
||
vins := make([]string, 0)
|
||
for _, item := range strings.Split(next.Get("vins"), ",") {
|
||
vin := strings.TrimSpace(item)
|
||
if vin == "" || seen[vin] {
|
||
continue
|
||
}
|
||
seen[vin] = true
|
||
vins = append(vins, vin)
|
||
}
|
||
if len(vins) > 20 {
|
||
return nil, clientError{Code: "TOO_MANY_VEHICLES", Message: "里程查询一次最多选择 20 辆车"}
|
||
}
|
||
if len(vins) == 0 {
|
||
next.Del("vins")
|
||
} else {
|
||
next.Set("vins", strings.Join(vins, ","))
|
||
}
|
||
return next, nil
|
||
}
|
||
|
||
func normalizeMileageStatisticsWindow(query url.Values, now time.Time) (url.Values, error) {
|
||
next := cloneValues(query)
|
||
dateTo := strings.TrimSpace(next.Get("dateTo"))
|
||
if dateTo == "" {
|
||
dateTo = now.Format("2006-01-02")
|
||
next.Set("dateTo", dateTo)
|
||
}
|
||
dateFrom := strings.TrimSpace(next.Get("dateFrom"))
|
||
if dateFrom == "" {
|
||
parsedTo, _ := time.ParseInLocation("2006-01-02", dateTo, now.Location())
|
||
dateFrom = parsedTo.AddDate(0, 0, -29).Format("2006-01-02")
|
||
next.Set("dateFrom", dateFrom)
|
||
}
|
||
from, fromErr := time.ParseInLocation("2006-01-02", dateFrom, now.Location())
|
||
to, toErr := time.ParseInLocation("2006-01-02", dateTo, now.Location())
|
||
if fromErr != nil || toErr != nil || from.After(to) {
|
||
return nil, clientError{Code: "INVALID_DATE_RANGE", Message: "统计日期范围无效"}
|
||
}
|
||
if to.Sub(from) > 365*24*time.Hour {
|
||
return nil, clientError{Code: "DATE_RANGE_TOO_LARGE", Message: "车辆统计最多查询 366 个自然日"}
|
||
}
|
||
return next, nil
|
||
}
|
||
|
||
func normalizeDailyMileageWindow(query url.Values, now time.Time) (url.Values, error) {
|
||
if strings.TrimSpace(query.Get("dateFrom")) == "" && strings.TrimSpace(query.Get("dateTo")) == "" {
|
||
return cloneValues(query), nil
|
||
}
|
||
return normalizeMileageStatisticsWindow(query, now)
|
||
}
|
||
|
||
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, err := applyPrincipalVehicleScope(ctx, query)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if strings.TrimSpace(resolved.Get("keywords")) != "" {
|
||
return resolved, nil
|
||
}
|
||
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 != "" {
|
||
if principal, ok := PrincipalFromContext(ctx); ok && principal.UserType == "customer" && !principal.CanVIN(resolvedVIN) {
|
||
return nil, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"}
|
||
}
|
||
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"}}
|
||
vehicleQuery, err := applyPrincipalVehicleScope(ctx, vehicleQuery)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
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 applyPrincipalVehicleScope(ctx context.Context, query url.Values) (url.Values, error) {
|
||
next := cloneValues(query)
|
||
principal, ok := PrincipalFromContext(ctx)
|
||
if !ok || principal.UserType != "customer" {
|
||
return next, nil
|
||
}
|
||
allowed := make(map[string]bool, len(principal.VehicleVINs))
|
||
for _, vin := range principal.VehicleVINs {
|
||
allowed[strings.ToUpper(strings.TrimSpace(vin))] = true
|
||
}
|
||
for _, value := range []string{next.Get("vin"), next.Get("vins")} {
|
||
for _, vin := range strings.Split(value, ",") {
|
||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||
if vin != "" && isLikelyVIN(vin) && !allowed[vin] {
|
||
return nil, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"}
|
||
}
|
||
}
|
||
}
|
||
if len(principal.VehicleVINs) == 0 {
|
||
next.Set("scopeVins", "__NO_VEHICLE_SCOPE__")
|
||
} else {
|
||
next.Set("scopeVins", strings.Join(principal.VehicleVINs, ","))
|
||
}
|
||
validFromByVIN := make(map[string]string, len(principal.VehicleGrants))
|
||
shanghai := time.FixedZone("Asia/Shanghai", 8*60*60)
|
||
for _, grant := range principal.VehicleGrants {
|
||
vin := strings.ToUpper(strings.TrimSpace(grant.VIN))
|
||
if vin == "" || grant.ValidFrom.IsZero() {
|
||
continue
|
||
}
|
||
validFrom := grant.ValidFrom.In(shanghai)
|
||
firstFullDay := time.Date(validFrom.Year(), validFrom.Month(), validFrom.Day(), 0, 0, 0, 0, shanghai)
|
||
if !validFrom.Equal(firstFullDay) {
|
||
firstFullDay = firstFullDay.AddDate(0, 0, 1)
|
||
}
|
||
validFromByVIN[vin] = firstFullDay.Format("2006-01-02")
|
||
}
|
||
encoded, err := json.Marshal(validFromByVIN)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
next.Set("scopeVehicleFrom", string(encoded))
|
||
return next, nil
|
||
}
|
||
|
||
func applyPrincipalHistoryTimeScope(ctx context.Context, vin string, query url.Values) (url.Values, error) {
|
||
next := cloneValues(query)
|
||
principal, ok := PrincipalFromContext(ctx)
|
||
if !ok || principal.UserType != "customer" {
|
||
return next, nil
|
||
}
|
||
grant, ok := principal.VehicleGrant(vin)
|
||
if !ok || grant.ValidFrom.IsZero() {
|
||
return nil, clientError{Code: "HISTORY_SCOPE_UNAVAILABLE", Message: "当前车辆缺少可验证的历史授权起始时间"}
|
||
}
|
||
cutoff := grant.ValidFrom
|
||
var requestedStart time.Time
|
||
if value := strings.TrimSpace(next.Get("dateTo")); value != "" {
|
||
end, parsed := parseTrackRequestTime(value)
|
||
if !parsed {
|
||
return nil, clientError{Code: "TRACK_TIME_INVALID", Message: "轨迹时间格式无效"}
|
||
}
|
||
if !end.After(cutoff) {
|
||
return nil, clientError{Code: "HISTORY_BEFORE_AUTHORIZATION", Message: "所选时间范围早于该车辆的授权开始时间"}
|
||
}
|
||
}
|
||
if value := strings.TrimSpace(next.Get("dateFrom")); value != "" {
|
||
start, parsed := parseTrackRequestTime(value)
|
||
if !parsed {
|
||
return nil, clientError{Code: "TRACK_TIME_INVALID", Message: "轨迹时间格式无效"}
|
||
}
|
||
requestedStart = start
|
||
}
|
||
if grant.ValidTo != nil && !grant.ValidTo.IsZero() {
|
||
if !requestedStart.IsZero() && !requestedStart.Before(*grant.ValidTo) {
|
||
return nil, clientError{Code: "HISTORY_AFTER_AUTHORIZATION", Message: "所选时间范围晚于该车辆的授权结束时间"}
|
||
}
|
||
if value := strings.TrimSpace(next.Get("dateTo")); value == "" {
|
||
next.Set("dateTo", grant.ValidTo.Format(time.RFC3339))
|
||
} else if end, _ := parseTrackRequestTime(value); end.After(*grant.ValidTo) {
|
||
next.Set("dateTo", grant.ValidTo.Format(time.RFC3339))
|
||
}
|
||
}
|
||
if !requestedStart.IsZero() && !requestedStart.Before(cutoff) {
|
||
return next, nil
|
||
}
|
||
next.Set("dateFrom", cutoff.Format(time.RFC3339))
|
||
return next, nil
|
||
}
|
||
|
||
func requirePrincipalHistoricalGrantScope(ctx context.Context, query url.Values) error {
|
||
principal, ok := PrincipalFromContext(ctx)
|
||
if !ok || principal.UserType != "customer" {
|
||
return nil
|
||
}
|
||
requested := make([]string, 0)
|
||
for _, raw := range []string{query.Get("vin"), query.Get("vins")} {
|
||
for _, vin := range strings.Split(raw, ",") {
|
||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||
if vin != "" {
|
||
requested = append(requested, vin)
|
||
}
|
||
}
|
||
}
|
||
if len(requested) == 0 {
|
||
requested = principal.VehicleVINs
|
||
}
|
||
for _, vin := range requested {
|
||
grant, exists := principal.VehicleGrant(vin)
|
||
if !exists || grant.ValidFrom.IsZero() {
|
||
return clientError{Code: "HISTORY_SCOPE_UNAVAILABLE", Message: "当前车辆缺少可验证的历史授权起始时间"}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func authorizeVehicleVIN(ctx context.Context, vin string) error {
|
||
principal, ok := PrincipalFromContext(ctx)
|
||
if !ok || principal.UserType != "customer" {
|
||
return nil
|
||
}
|
||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||
if vin == "" || !principal.CanVIN(vin) {
|
||
return clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看该车辆"}
|
||
}
|
||
return 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
|
||
}
|