package platform import ( "bufio" "container/heap" "context" "crypto/rand" "crypto/sha256" "encoding/csv" "encoding/hex" "encoding/json" "errors" "fmt" "math" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "lingniu/vehicle-data-platform/apps/api/internal/vehicleprotocol" ) 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 VehicleBusinessRelationStore interface { VehicleBusinessRelation(context.Context, string) (VehicleBusinessRelation, bool, error) } type VehicleBusinessFilterStore interface { VehicleBusinessFilters(context.Context, url.Values) (VehicleBusinessFilters, 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 historyPreferences map[string]HistoryPreferences exportCleanupAudit []HistoryExportCleanupRecord 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 = vehicleprotocol.All() const defaultHistoryExportRetentionDays = 7 const historyExportCleanupBatchLimit = 500 const historyExportCleanupCandidatePreviewLimit = 20 const historyExportCleanupAuditLimit = 500 var allowedHistoryExportRetentionDays = map[int]bool{1: true, 3: true, 7: true, 14: true, 30: true} var allowedHistoryExportCleanupDays = map[int]bool{30: true, 90: true, 180: true, 365: true} 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{}, historyPreferences: map[string]HistoryPreferences{}, exportCleanupAudit: []HistoryExportCleanupRecord{}, exportDir: exportDir, exportSlots: make(chan struct{}, 1)} service.loadHistoryExports() service.loadHistoryExportCleanupAudit() service.loadHistoryPreferences() return service } func (s *Service) DashboardSummary(ctx context.Context) (DashboardSummary, error) { return s.store.DashboardSummary(ctx) } const ( monitorVehicleLimit = 10000 monitorPointLimit = 2000 monitorPointZoom = 11 monitorProvinceMaxZoom = 5 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 } provinceMode := zoom <= monitorProvinceMaxZoom bounds, hasBounds, boundsErr := parseMonitorBounds(query.Get("bounds")) if boundsErr != nil { // The viewport is an optional performance hint. Wide, wrapped or // intermediate map frames can briefly exceed WGS-84 bounds; falling // back to the bounded unfiltered response keeps the map usable. bounds = monitorBounds{} hasBounds = false } result := MonitorMapResponse{ Mode: "clusters", Zoom: zoom, Truncated: vehicles.Total > len(vehicles.Items), Points: []MonitorMapPoint{}, Clusters: []MonitorMapCluster{}, ProvincePoints: []MonitorMapProvincePoint{}, 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++ status := monitorVehicleStatus(vehicle) if provinceMode { result.ProvincePoints = append(result.ProvincePoints, MonitorMapProvincePoint{ Longitude: vehicle.Longitude, Latitude: vehicle.Latitude, Status: status, }) } // Nationwide province counts describe the complete filtered fleet, not // only the current slippy-map rectangle. Bounds resume after drill-down. if hasBounds && !provinceMode && !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: status, }) } 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" } } if provinceMode { result.Mode = "provinces" } 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) } var err error vehicleQuery, err = applyPrincipalVehicleScope(ctx, vehicleQuery) if err != nil { return VehicleIdentityResolution{}, err } vehicles, err := s.store.Vehicles(ctx, vehicleQuery) if err != nil { return VehicleIdentityResolution{}, err } return buildVehicleIdentityResolution(keyword, vehicles.Items), nil } func (s *Service) VehicleBusinessFilters(ctx context.Context) (VehicleBusinessFilters, error) { store, ok := s.store.(VehicleBusinessFilterStore) if !ok { return VehicleBusinessFilters{ Departments: []VehicleBusinessFilterOption{}, ResponsibleUsers: []VehicleBusinessFilterOption{}, Customers: []VehicleBusinessFilterOption{}, Statuses: []VehicleBusinessFilterOption{}, }, nil } query, err := applyPrincipalVehicleScope(ctx, url.Values{}) if err != nil { return VehicleBusinessFilters{}, err } return store.VehicleBusinessFilters(ctx, query) } 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"}, "skipCount": {"1"}} rawQuery := RawFrameQuery{VIN: queryVIN, IncludeFields: true, Limit: 10, SkipCount: true} // Vehicle detail has already resolved and authorized an exact VIN. Keep the // preview on the indexed VIN path and avoid a full result count that the // detail page does not display. mileageQuery := url.Values{"vins": {queryVIN}, "limit": {"20"}, "skipCount": {"1"}} 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 } observedSourceStatus := vehicleSourceStatus(vehicles.Items, realtime.Items, history.Items, raw.Items, mileage.Items) observedSourceStatus = s.enrichVehicleSourceStatus(ctx, queryVIN, observedSourceStatus) availableSources := sourceNamesForProtocol(observedSourceStatus, protocol) sourceStatus := completeVehicleSourceStatus(observedSourceStatus, 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 } var businessRelation *VehicleBusinessRelation if businessStore, ok := s.store.(VehicleBusinessRelationStore); ok { relation, found, err := businessStore.VehicleBusinessRelation(ctx, resolvedVIN) if err != nil { return VehicleDetail{}, err } if found { businessRelation = &relation } } return VehicleDetail{ VIN: resolvedVIN, LookupKey: keyword, LookupResolved: resolvedVIN != "", Resolution: &resolution, Identity: identity, Profile: &profile, BusinessRelation: businessRelation, RealtimeSummary: summary, ServiceStatus: serviceStatus, ServiceOverview: serviceOverview, SourceConsistency: buildVehicleSourceConsistency(sourceStatus, realtime.Items, protocol), // Sources is the vehicle's observed protocol set. SourceStatus may also // contain empty canonical slots for readiness diagnostics, but those // capabilities must not be presented as protocols available to the car. Sources: availableSources, 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"}, "skipCount": {"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, SkipCount: true}) 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) } type trackSourceCandidate struct { Protocol string Page Page[HistoryLocationRow] Raw []HistoryLocationRow Clean []HistoryLocationRow Quality TrackQuality } var trackCandidateProtocols = vehicleprotocol.All() func cloneURLValues(values url.Values) url.Values { next := make(url.Values, len(values)) for key, items := range values { next[key] = append([]string(nil), items...) } return next } func sortedHistoryLocations(items []HistoryLocationRow) []HistoryLocationRow { points := append([]HistoryLocationRow(nil), items...) sort.SliceStable(points, func(i, j int) bool { left, leftOK := parseVehicleServiceTime(points[i].DeviceTime) right, rightOK := parseVehicleServiceTime(points[j].DeviceTime) if leftOK && rightOK && !left.Equal(right) { return left.Before(right) } return points[i].DeviceTime < points[j].DeviceTime }) return points } func (s *Service) trackSourceCandidates(ctx context.Context, query url.Values, requestedProtocol string) ([]trackSourceCandidate, error) { protocols := trackCandidateProtocols if requestedProtocol != "" { protocols = []string{requestedProtocol} } candidates := make([]trackSourceCandidate, 0, len(protocols)) for _, protocol := range protocols { sourceQuery := cloneURLValues(query) sourceQuery.Set("protocol", protocol) page, err := s.store.HistoryLocationsFromTDengine(ctx, sourceQuery) if err != nil { return nil, err } raw := sortedHistoryLocations(page.Items) clean, quality := analyzeTrackPoints(raw) candidates = append(candidates, trackSourceCandidate{ Protocol: protocol, Page: page, Raw: raw, Clean: clean, Quality: quality, }) } return candidates, nil } 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") requestedProtocol := strings.ToUpper(strings.TrimSpace(query.Get("protocol"))) candidates, err := s.trackSourceCandidates(ctx, resolvedQuery, requestedProtocol) if err != nil { return TrackPlaybackResponse{}, err } allCleanPoints := make([]HistoryLocationRow, 0) for _, candidate := range candidates { allCleanPoints = append(allCleanPoints, candidate.Clean...) } points, selectedProtocol, alternateSourcePoints := selectTrackPrimarySource(allCleanPoints, requestedProtocol) selected := trackSourceCandidate{Protocol: selectedProtocol, Clean: []HistoryLocationRow{}} for _, candidate := range candidates { if strings.EqualFold(candidate.Protocol, selectedProtocol) { selected = candidate break } } quality := selected.Quality quality.SelectedProtocol = selectedProtocol quality.AlternateSourcePoints = alternateSourcePoints quality.ValidPoints = len(points) applyTrackSequenceQuality(points, &quality) truncated := selected.Page.Total > len(selected.Raw) result := TrackPlaybackResponse{ Points: []HistoryLocationRow{}, Events: []TrackPlaybackEvent{}, Sources: summarizeTrackSources(allCleanPoints), Segments: []TrackSegment{}, Stops: []TrackStop{}, Total: selected.Page.Total, Truncated: truncated, Quality: quality, AsOf: time.Now().UTC().Format(time.RFC3339), } result.Coverage = trackCoverage(resolvedQuery, selected.Page.Total, len(selected.Raw), 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, selected.Page.Total, len(selected.Raw), 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 { definitions, err := store.MetricDefinitions(ctx) if err != nil { return nil, err } return enrichMetricDefinitions(definitions), nil } return enrichMetricDefinitions(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 := telemetryDefinitionsBySource(definitions) 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 } candidateKey := normalizedProtocol + "\x00" + key if previous, exists := candidates[candidateKey]; exists && (!observedOK || (previous.timeValid && !observed.After(previous.sortTime))) { continue } label, unit := latestTelemetryRawMetadata(sourceField) category := telemetryCategoryForSource(normalizedProtocol, sourceField, definitionBySource) valueType := "dynamic" description := "" value := rawValue if reference, ok := gb32960ReferenceForSource(sourceField); ok { label = reference.Label unit = reference.Unit description = reference.Description } if unified { label = definition.Label unit = definition.Unit valueType = definition.ValueType description = definition.Description value = normalizeTelemetryValue(rawValue, definition.ValueType) if reference, ok := gb32960ReferenceForSource(sourceField); ok { label = reference.Label unit = reference.Unit description = reference.Description } label, description = protocolTelemetryMetadata(normalizedProtocol, key, label, description) } displayValue := protocolDisplayValue(sourceField, value) candidates[candidateKey] = latestTelemetryCandidate{ value: LatestTelemetryValue{ Key: key, SourceField: sourceField, Label: label, Description: description, Unit: unit, Category: category, ValueType: valueType, Value: value, DisplayValue: displayValue, 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 { if left, right := telemetryProtocolRank(response.Values[i].Protocol), telemetryProtocolRank(response.Values[j].Protocol); left != right { return left < right } 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; retained %d newest scalar values independently by protocol and metric/source field", len(frames), len(response.Values)) return response } func protocolTelemetryMetadata(protocol, key, label, description string) (string, string) { if key != "total_mileage_km" { return label, description } switch protocol { case "JT808": return "GPS 总里程", "JT/T 808 定位终端累计的 GPS 里程,不等同于车辆仪表盘里程" case "GB32960", "YUTONG_MQTT": return "仪表盘总里程", "车辆总线或车厂平台上报的仪表盘累计里程" default: return label, description } } 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" case "fuel-cell", "fuel_cell", "fuelcell", "hydrogen": return "fuel-cell" case "motor", "engine": return "motor" 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") || strings.Contains(normalized, "hydrogen"): 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 telemetryDefinitionsBySource(definitions []MetricDefinition) map[string]MetricDefinition { definitionBySource := make(map[string]MetricDefinition, len(definitions)*2) for _, definition := range definitions { for protocol, sourceField := range definition.SourceFields { definitionBySource[telemetrySourceLookupKey(protocol, sourceField)] = definition } } return definitionBySource } func telemetryCategoryForSource(protocol, sourceField string, definitions map[string]MetricDefinition) string { if definition, ok := definitions[telemetrySourceLookupKey(protocol, sourceField)]; ok { return normalizeTelemetryCategory(definition.Category) } return telemetrySourceCategory(sourceField) } 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 telemetryProtocolRank(protocol string) int { for index, candidate := range canonicalVehicleProtocols { if protocol == candidate { return index } } return len(canonicalVehicleProtocols) } 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: "车辆累计行驶里程;JT808 为 GPS 里程,其余为仪表盘里程", 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_mqtt.data.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}, {Key: "vehicle_status", Label: "车辆状态", Description: "GB/T 32960 整车状态码", Unit: "", Category: "vehicle", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.vehicle_status"}, Searchable: true, Chartable: false, Alertable: true}, {Key: "charge_status", Label: "充电状态", Description: "GB/T 32960 充电状态码", Unit: "", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.charge_status"}, Searchable: true, Chartable: false, Alertable: true}, {Key: "total_voltage_v", Label: "总电压", Description: "动力电池总电压", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_voltage_v"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "total_current_a", Label: "总电流", Description: "动力电池总电流", Unit: "A", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.total_current_a"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "insulation_kohm", Label: "绝缘电阻", Description: "整车绝缘电阻", Unit: "kΩ", Category: "safety", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.vehicle.insulation_kohm"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "fuel_cell_voltage_v", Label: "燃料电池电压", Description: "燃料电池系统输出电压", Unit: "V", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.fuel_cell_voltage_v"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "fuel_cell_current_a", Label: "燃料电池电流", Description: "燃料电池系统输出电流", Unit: "A", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.fuel_cell_current_a"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "hydrogen_consumption_kg_per_100km", Label: "百公里氢耗", Description: "燃料电池系统百公里氢气消耗量", Unit: "kg/100km", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.hydrogen_consumption_kg_per_100km"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "hydrogen_concentration_percent", Label: "最高氢浓度", Description: "氢气浓度最高值", Unit: "%", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_concentration_percent"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "hydrogen_pressure_mpa", Label: "最高氢气压力", Description: "燃料电池系统氢气压力探针的最高读数", Unit: "MPa", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_pressure_mpa"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "hydrogen_temperature_c", Label: "最高氢气温度", Description: "燃料电池系统氢气温度探针的最高读数", Unit: "℃", Category: "fuel-cell", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.fuel_cell.max_hydrogen_temperature_c"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "engine_speed_rpm", Label: "发动机转速", Description: "发动机曲轴转速", Unit: "rpm", Category: "vehicle", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.engine.crank_speed_rpm"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "max_cell_voltage_v", Label: "单体最高电压", Description: "动力电池单体电压最高值", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.max_voltage_v"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "min_cell_voltage_v", Label: "单体最低电压", Description: "动力电池单体电压最低值", Unit: "V", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.min_voltage_v"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "max_battery_temperature_c", Label: "电池最高温度", Description: "动力电池温度探针最高值", Unit: "℃", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.max_temp_c"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "min_battery_temperature_c", Label: "电池最低温度", Description: "动力电池温度探针最低值", Unit: "℃", Category: "energy", ValueType: "numeric", Protocols: []string{"GB32960"}, SourceFields: map[string]string{"GB32960": "gb32960.extreme.min_temp_c"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "gnss_satellite_count", Label: "GNSS 卫星数", Description: "JT/T 808 定位终端参与定位的卫星数量", Unit: "颗", Category: "location", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.gnss_satellite_count"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "network_signal_strength", Label: "通信信号强度", Description: "JT/T 808 终端无线通信信号强度", Unit: "", Category: "quality", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.network_signal_strength"}, Searchable: true, Chartable: true, Alertable: true}, {Key: "fuel_l", Label: "油量", Description: "JT/T 808 终端上报的车辆油量", Unit: "L", Category: "vehicle", ValueType: "numeric", Protocols: []string{"JT808"}, SourceFields: map[string]string{"JT808": "jt808.location.additional.fuel_l"}, Searchable: true, Chartable: true, Alertable: true}, } } func (s *Service) HistoryData(ctx context.Context, query url.Values) (HistoryDataResponse, error) { started := time.Now() dateFrom, dateTo, _, _, windowErr := historySeriesWindow(query.Get("dateFrom"), query.Get("dateTo"), time.Now()) if windowErr != nil { return HistoryDataResponse{}, windowErr } query = cloneURLValues(query) query.Set("dateFrom", dateFrom) query.Set("dateTo", dateTo) 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) if category == "raw" { return s.historyRawData(ctx, query, keywords, limit, offset, started) } fetchLimit := limit + offset if fetchLimit > 1000 { fetchLimit = 1000 } type vehicleHistoryResult struct { rows []HistoryDataRow total int err error } perVehicle := make([]vehicleHistoryResult, len(keywords)) var vehicleQueries sync.WaitGroup for index, keyword := range keywords { vehicleQueries.Add(1) go func(index int, keyword string) { defer vehicleQueries.Done() perVehicle[index].rows, perVehicle[index].total, perVehicle[index].err = s.historyDataForVehicle(ctx, category, keyword, query, fetchLimit) }(index, keyword) } vehicleQueries.Wait() rows := make([]HistoryDataRow, 0, fetchLimit*len(keywords)) vehicleResults := make([]HistoryQueryVehicleResult, 0, len(keywords)) total := 0 for index, keyword := range keywords { vehicleResultData := perVehicle[index] if vehicleResultData.err != nil { return HistoryDataResponse{}, vehicleResultData.err } rows = append(rows, vehicleResultData.rows...) total += vehicleResultData.total vehicleResult := HistoryQueryVehicleResult{Keyword: keyword, RowCount: vehicleResultData.total, Status: "no_data"} if vehicleResultData.total > 0 { vehicleResult.Status = "matched" } if len(vehicleResultData.rows) > 0 { vehicleResult.VIN = vehicleResultData.rows[0].VIN vehicleResult.Plate = vehicleResultData.rows[0].Plate } vehicleResults = append(vehicleResults, vehicleResult) } 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) segments := []LatestTelemetryCategory{} if category == "raw" { definitions, err := s.metricDefinitions(ctx) if err != nil { return HistoryDataResponse{}, err } columns = mergeDiscoveredRawMetrics(columns, rows, definitions) segments = historyRawSegments(columns) } 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, Segments: segments, Rows: pageRows, Total: total, Limit: limit, Offset: offset, Summary: HistoryDataSummary{ResultRows: total, VehicleCount: len(vehicles), RequestedVehicleCount: len(keywords), Vehicles: vehicleResults, Sources: sourceNames, QueryDuration: time.Since(started).Milliseconds()}, AsOf: time.Now().UTC().Format(time.RFC3339), }, nil } type rawHistoryScan struct { rows []HistoryDataRow allTotal int filteredTotal int segmentCounts map[string]int fields map[string]struct{} sources map[string]struct{} vin string plate string } type rawHistoryBatchStore interface { HistoryExportBatch(context.Context, HistoryExportStoreQuery, HistoryExportCursor, int) ([]HistoryDataRow, HistoryExportCursor, error) } type historyLatestRowHeap []HistoryDataRow func (items historyLatestRowHeap) Len() int { return len(items) } func (items historyLatestRowHeap) Less(left, right int) bool { return historyRowBefore(items[right], items[left]) } func (items historyLatestRowHeap) Swap(left, right int) { items[left], items[right] = items[right], items[left] } func (items *historyLatestRowHeap) Push(value any) { *items = append(*items, value.(HistoryDataRow)) } func (items *historyLatestRowHeap) Pop() any { current := *items last := len(current) - 1 value := current[last] *items = current[:last] return value } func historyRowBefore(left, right HistoryDataRow) bool { if left.DeviceTime != right.DeviceTime { return left.DeviceTime > right.DeviceTime } if left.VIN != right.VIN { return left.VIN < right.VIN } if left.Protocol != right.Protocol { return left.Protocol < right.Protocol } return left.ID < right.ID } func retainLatestHistoryRow(rows *historyLatestRowHeap, row HistoryDataRow, limit int) { if limit <= 0 { return } if rows.Len() < limit { heap.Push(rows, row) return } if historyRowBefore(row, (*rows)[0]) { (*rows)[0] = row heap.Fix(rows, 0) } } func validRawSegment(segment string) bool { if segment == "" { return true } for _, candidate := range []string{"vehicle", "motor", "fuel-cell", "battery", "location", "alarm", "quality", "extension"} { if segment == candidate { return true } } return false } func historyRawRowCategories(row HistoryDataRow, definitions map[string]MetricDefinition) map[string]struct{} { categories := map[string]struct{}{} for key := range row.Values { if key == "frameType" || key == "rawSizeBytes" { continue } protocol := strings.ToUpper(strings.TrimSpace(row.Protocol)) if parts := strings.SplitN(key, ".", 2); len(parts) == 2 && strings.TrimSpace(parts[0]) != "" { protocol = strings.ToUpper(strings.TrimSpace(parts[0])) } categories[telemetryCategoryForSource(protocol, key, definitions)] = struct{}{} } return categories } func (s *Service) historyRawData(ctx context.Context, query url.Values, keywords []string, limit, offset int, started time.Time) (HistoryDataResponse, error) { segment := strings.ToLower(strings.TrimSpace(query.Get("rawSegment"))) if !validRawSegment(segment) { return HistoryDataResponse{}, clientError{Code: "HISTORY_RAW_SEGMENT_INVALID", Message: "原始报文分段类型无效"} } store, ok := s.store.(rawHistoryBatchStore) if !ok { return HistoryDataResponse{}, errors.New("当前数据存储不支持原始报文精确分段查询") } catalog, err := s.metricDefinitions(ctx) if err != nil { return HistoryDataResponse{}, err } definitions := telemetryDefinitionsBySource(catalog) keep := limit + offset scans := make([]rawHistoryScan, len(keywords)) errs := make([]error, len(keywords)) var group sync.WaitGroup for index, keyword := range keywords { group.Add(1) go func(index int, keyword string) { defer group.Done() resolvedVIN, resolveErr := s.resolveVehicleVIN(ctx, keyword, query.Get("protocol")) if resolveErr != nil { errs[index] = resolveErr return } if authorizeErr := authorizeVehicleVIN(ctx, resolvedVIN); authorizeErr != nil { errs[index] = authorizeErr return } scoped, scopeErr := applyPrincipalHistoryTimeScope(ctx, resolvedVIN, url.Values{ "dateFrom": {query.Get("dateFrom")}, "dateTo": {query.Get("dateTo")}, }) if scopeErr != nil { errs[index] = scopeErr return } scans[index], errs[index] = scanRawHistoryVehicle(ctx, store, HistoryExportStoreQuery{ Category: "raw", VIN: resolvedVIN, Protocol: strings.TrimSpace(query.Get("protocol")), DateFrom: scoped.Get("dateFrom"), DateTo: scoped.Get("dateTo"), }, segment, definitions, keep) }(index, keyword) } group.Wait() latest := &historyLatestRowHeap{} heap.Init(latest) segmentCounts := map[string]int{} fields := map[string]struct{}{} sources := map[string]struct{}{} vehicleResults := make([]HistoryQueryVehicleResult, 0, len(keywords)) allTotal := 0 total := 0 for index, keyword := range keywords { if errs[index] != nil { return HistoryDataResponse{}, errs[index] } scan := scans[index] allTotal += scan.allTotal total += scan.filteredTotal for key, count := range scan.segmentCounts { segmentCounts[key] += count } for key := range scan.fields { fields[key] = struct{}{} } for source := range scan.sources { sources[source] = struct{}{} } for _, row := range scan.rows { retainLatestHistoryRow(latest, row, keep) } status := "no_data" if scan.filteredTotal > 0 { status = "matched" } vehicleResults = append(vehicleResults, HistoryQueryVehicleResult{ Keyword: keyword, VIN: scan.vin, Plate: scan.plate, RowCount: scan.filteredTotal, Status: status, }) } rows := append([]HistoryDataRow(nil), (*latest)...) sort.SliceStable(rows, func(left, right int) bool { return historyRowBefore(rows[left], rows[right]) }) if offset > len(rows) { offset = len(rows) } end := offset + limit if end > len(rows) { end = len(rows) } pageRows := append([]HistoryDataRow(nil), rows[offset:end]...) sourceNames := make([]string, 0, len(sources)) for source := range sources { if source != "" { sourceNames = append(sourceNames, source) } } sort.Strings(sourceNames) vehicleCount := 0 for _, item := range vehicleResults { if item.RowCount > 0 { vehicleCount++ } } columns := mergeDiscoveredRawMetricKeys(historyRawMetrics(), fields, catalog) return HistoryDataResponse{ Category: "raw", Columns: columns, Segments: historyRawSegmentsFromCounts(segmentCounts), SegmentCountsExact: true, AllTotal: allTotal, Rows: pageRows, Total: total, Limit: limit, Offset: offset, Summary: HistoryDataSummary{ ResultRows: total, VehicleCount: vehicleCount, RequestedVehicleCount: len(keywords), Vehicles: vehicleResults, Sources: sourceNames, QueryDuration: time.Since(started).Milliseconds(), }, AsOf: time.Now().UTC().Format(time.RFC3339), }, nil } func scanRawHistoryVehicle(ctx context.Context, store rawHistoryBatchStore, query HistoryExportStoreQuery, segment string, definitions map[string]MetricDefinition, keep int) (rawHistoryScan, error) { result := rawHistoryScan{ segmentCounts: map[string]int{}, fields: map[string]struct{}{}, sources: map[string]struct{}{}, vin: query.VIN, } latest := &historyLatestRowHeap{} heap.Init(latest) cursor := HistoryExportCursor{} const batchSize = 5000 for { batch, next, err := store.HistoryExportBatch(ctx, query, cursor, batchSize) if err != nil { return rawHistoryScan{}, err } if len(batch) == 0 { break } for _, row := range batch { result.allTotal++ if result.vin == "" { result.vin = row.VIN } if result.plate == "" { result.plate = row.Plate } categories := historyRawRowCategories(row, definitions) for category := range categories { result.segmentCounts[category]++ } for key := range row.Values { if key != "frameType" && key != "rawSizeBytes" { result.fields[key] = struct{}{} } } if segment != "" { if _, matches := categories[segment]; !matches { continue } } result.filteredTotal++ if row.Protocol != "" { result.sources[row.Protocol] = struct{}{} } retainLatestHistoryRow(latest, row, keep) } if len(batch) < batchSize { break } if next == cursor { return rawHistoryScan{}, errors.New("原始报文分段扫描游标未推进") } cursor = next } result.rows = append([]HistoryDataRow(nil), (*latest)...) return result, 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 } type vehicleSeriesResult struct { vehicle vehicleBuckets err error } perVehicleSeries := make([]vehicleSeriesResult, len(keywords)) var seriesQueries sync.WaitGroup for index, keyword := range keywords { seriesQueries.Add(1) go func(index int, keyword string) { defer seriesQueries.Done() vin, resolveErr := s.resolveVehicleVIN(ctx, keyword, protocol) if resolveErr != nil { perVehicleSeries[index].err = resolveErr return } if authErr := authorizeVehicleVIN(ctx, vin); authErr != nil { perVehicleSeries[index].err = authErr return } scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{ "dateFrom": {dateFrom}, "dateTo": {dateTo}, }) if scopeErr != nil { perVehicleSeries[index].err = scopeErr return } buckets, aggregateErr := store.HistoryLocationSeries(ctx, HistoryLocationSeriesQuery{VIN: vin, Protocol: protocol, DateFrom: scopedWindow.Get("dateFrom"), DateTo: scopedWindow.Get("dateTo"), GrainSeconds: grainSeconds}) if aggregateErr != nil { perVehicleSeries[index].err = aggregateErr return } perVehicleSeries[index].vehicle = vehicleBuckets{vin: vin, plate: s.historySeriesPlate(ctx, vin), buckets: buckets} }(index, keyword) } seriesQueries.Wait() vehicles := make([]vehicleBuckets, 0, len(keywords)) for _, result := range perVehicleSeries { if result.err != nil { return HistorySeriesResponse{}, result.err } vehicles = append(vehicles, result.vehicle) } 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 := parseHistoryRequestTime(rawFrom) end, endOK := parseHistoryRequestTime(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 parseHistoryRequestTime(value string) (time.Time, bool) { if parsed, ok := parseTrackRequestTime(value); ok { return parsed, true } shanghai := time.FixedZone("Asia/Shanghai", 8*60*60) parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), shanghai) return parsed, err == 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, catalog ...[]MetricDefinition) []HistoryMetricDefinition { keys := map[string]struct{}{} for _, row := range rows { for key := range row.Values { keys[key] = struct{}{} } } definitions := []MetricDefinition{} if len(catalog) > 0 { definitions = catalog[0] } return mergeDiscoveredRawMetricKeys(base, keys, definitions) } func mergeDiscoveredRawMetricKeys(base []HistoryMetricDefinition, discovered map[string]struct{}, catalog []MetricDefinition) []HistoryMetricDefinition { result := append([]HistoryMetricDefinition(nil), base...) definitions := telemetryDefinitionsBySource(catalog) seen := map[string]struct{}{} for _, metric := range result { seen[metric.Key] = struct{}{} } keys := make([]string, 0, len(discovered)) for key := range discovered { if _, ok := seen[key]; ok { continue } keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { label, unit := rawMetricMetadata(key) protocol := strings.ToUpper(strings.TrimSpace(strings.SplitN(key, ".", 2)[0])) metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: telemetryCategoryForSource(protocol, key, definitions), ValueType: "dynamic", DefaultVisible: len(result) < 7} if reference, ok := gb32960ReferenceForSource(key); ok { metric.Label = reference.Label + " · GB32960" metric.Description = reference.Description metric.Unit = reference.Unit metric.ValueMappings = reference.ValueMappings } result = append(result, metric) } return result } func historyRawSegments(columns []HistoryMetricDefinition) []LatestTelemetryCategory { counts := map[string]int{} for _, column := range columns { if column.Category == "raw" || column.Category == "" { continue } counts[column.Category]++ } return historyRawSegmentsFromCounts(counts) } func historyRawSegmentsFromCounts(counts map[string]int) []LatestTelemetryCategory { keys := make([]string, 0, len(counts)) for key := range counts { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { if left, right := telemetryCategoryRank(keys[i]), telemetryCategoryRank(keys[j]); left != right { return left < right } return keys[i] < keys[j] }) segments := make([]LatestTelemetryCategory, 0, len(keys)) for _, key := range keys { segments = append(segments, LatestTelemetryCategory{Key: key, Label: telemetryCategoryLabel(key), Count: counts[key]}) } return segments } func rawMetricMetadata(key string) (string, string) { if reference, ok := gb32960ReferenceForSource(key); ok { return reference.Label + " · GB32960", reference.Unit } parts := strings.Split(key, ".") tail := parts[len(parts)-1] protocol := strings.ToUpper(parts[0]) labels := map[string]string{ "speed_kmh": "速度", "recorder_speed_kmh": "行驶记录仪速度", "total_mileage_km": "总里程", "avg_voltage_v": "平均电压", "soc_percent": "SOC", "longitude": "经度", "latitude": "纬度", "direction": "方向", "direction_deg": "方向角", "altitude_m": "海拔", "current_a": "电流", "temperature_c": "温度", "vehicle_status": "车辆状态", "charge_status": "充电状态", "running_mode": "运行模式", "total_voltage_v": "总电压", "total_current_a": "总电流", "dc_dc_status": "DC/DC 状态", "gear": "挡位", "insulation_kohm": "绝缘电阻", "accelerator_pct": "加速踏板开度", "brake_pct": "制动踏板状态", "state": "工作状态", "controller_temperature_c": "控制器温度", "speed_rpm": "转速", "torque_nm": "转矩", "motor_temperature_c": "驱动电机温度", "controller_voltage_v": "控制器输入电压", "controller_current_a": "控制器母线电流", "fuel_cell_voltage_v": "燃料电池电压", "fuel_cell_current_a": "燃料电池电流", "hydrogen_consumption_kg_per_100km": "百公里氢耗", "temperature_probe_count": "温度探针数量", "max_hydrogen_temperature_c": "最高氢气温度", "max_hydrogen_temperature_probe_id": "最高氢温探针编号", "max_hydrogen_concentration_fraction": "最高氢气浓度占比", "max_hydrogen_concentration_percent": "最高氢浓度", "max_hydrogen_concentration_ppm": "最高氢浓度原始值", "max_hydrogen_concentration_probe_id": "最高氢浓度探针编号", "max_hydrogen_pressure_mpa": "最高氢气压力", "max_hydrogen_pressure_probe_id": "最高氢压探针编号", "engine_status": "发动机状态", "crank_speed_rpm": "发动机转速", "fuel_rate": "燃料消耗率", "max_voltage_subsystem_no": "最高电压子系统编号", "max_voltage_cell_no": "最高电压单体编号", "max_voltage_v": "单体最高电压", "min_voltage_subsystem_no": "最低电压子系统编号", "min_voltage_cell_no": "最低电压单体编号", "min_voltage_v": "单体最低电压", "max_temp_subsystem_no": "最高温度子系统编号", "max_temp_probe_no": "最高温度探针编号", "max_temp_c": "电池最高温度", "min_temp_subsystem_no": "最低温度子系统编号", "min_temp_probe_no": "最低温度探针编号", "min_temp_c": "电池最低温度", "max_alarm_level": "最高告警等级", "general_alarm_flag": "通用告警标志", "alarm_flag": "告警标志", "status_flag": "状态标志", "fuel_l": "油量", "manual_alarm_event_id": "人工确认告警事件编号", "carriage_temperature_c": "车厢温度", "network_signal_strength": "通信信号强度", "gnss_satellite_count": "GNSS 卫星数", "device_time": "设备时间", "hydrogen_mass_kg": "储氢质量", "gd_fc_vehicle_hydrogen_mass_kg": "储氢质量", "stack_temp_c": "电堆温度", "gd_fc_demo_stack_temp_c": "电堆温度", "controller_temp_c": "控制器温度", "gd_fc_dcdc_controller_temp_c": "燃料电池 DC/DC 控制器温度", } label := labels[tail] if label == "" { label = humanizeRawMetricTail(tail) } if protocol != "" { label += " · " + protocol } unit := "" switch { case strings.HasSuffix(tail, "_kmh"): unit = "km/h" case strings.HasSuffix(tail, "_kg_per_100km"): unit = "kg/100km" 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 = "℃" case strings.HasSuffix(tail, "_rpm"): unit = "rpm" case strings.HasSuffix(tail, "_nm"): unit = "N·m" case strings.HasSuffix(tail, "_mpa"): unit = "MPa" case strings.HasSuffix(tail, "_kohm"): unit = "kΩ" case strings.HasSuffix(tail, "_ppm"): unit = "ppm" case strings.HasSuffix(tail, "_kg"): unit = "kg" case strings.HasSuffix(tail, "_deg"): unit = "°" case strings.HasSuffix(tail, "_m"): unit = "m" case strings.HasSuffix(tail, "_l"): unit = "L" } return label, unit } func humanizeRawMetricTail(tail string) string { tokenLabels := map[string]string{ "max": "最高", "min": "最低", "avg": "平均", "total": "总", "vehicle": "车辆", "battery": "电池", "fuel": "燃料", "cell": "电池", "hydrogen": "氢气", "motor": "电机", "engine": "发动机", "controller": "控制器", "stack": "电堆", "inlet": "入口", "outlet": "出口", "pressure": "压力", "temperature": "温度", "temp": "温度", "voltage": "电压", "current": "电流", "speed": "速度", "mileage": "里程", "status": "状态", "count": "数量", "probe": "探针", "serial": "序号", "subsystem": "子系统", "signal": "信号", "strength": "强度", "alarm": "告警", "level": "等级", "rate": "速率", "mass": "质量", "consumption": "消耗量", "capacity": "容量", "soc": "SOC", } var builder strings.Builder for _, token := range strings.Split(tail, "_") { if label := tokenLabels[token]; label != "" { builder.WriteString(label) } else if token != "" && !isRawMetricUnitToken(token) { if builder.Len() > 0 { builder.WriteByte(' ') } builder.WriteString(strings.ToUpper(token)) } } if builder.Len() == 0 { return strings.ReplaceAll(tail, "_", " ") } return builder.String() } func isRawMetricUnitToken(token string) bool { switch token { case "kmh", "km", "v", "a", "c", "rpm", "nm", "mpa", "kohm", "ppm", "kg", "pct", "percent", "deg", "m", "l": return true default: return false } } func (s *Service) CreateHistoryExport(ctx context.Context, request HistoryExportRequest) (HistoryExportJob, error) { return s.createHistoryExport(ctx, request, "") } func (s *Service) createHistoryExport(ctx context.Context, request HistoryExportRequest, rebuiltFrom string) (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 个指标"} } if request.RetentionDays == 0 { request.RetentionDays = s.historyRetentionDaysForPrincipal(principal) } if !allowedHistoryExportRetentionDays[request.RetentionDays] { return HistoryExportJob{}, clientError{Code: "EXPORT_RETENTION_INVALID", Message: "文件保留期仅支持 1、3、7、14 或 30 天"} } 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, RebuiltFrom: rebuiltFrom, RetentionDays: request.RetentionDays, CreatedAt: now, UpdatedAt: now, Evidence: fmt.Sprintf("账号归属与车辆/时间 Scope 已固化;单并发流式任务;最多 1,000,000 行;完成文件原子发布;文件保留 %d 天", request.RetentionDays), } if rebuiltFrom != "" { job.Evidence += ";由任务 " + rebuiltFrom + " 按原范围重建,原记录保留" } 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 { return s.ListHistoryExportsPage(ctx, HistoryExportQuery{Limit: 20}).Items } func (s *Service) ListHistoryExportsPage(ctx context.Context, query HistoryExportQuery) HistoryExportPage { principal, _ := PrincipalFromContext(ctx) query.Search = strings.ToLower(strings.TrimSpace(query.Search)) query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.Scope = strings.ToLower(strings.TrimSpace(query.Scope)) query.OwnerScope = strings.ToLower(strings.TrimSpace(query.OwnerScope)) query.Sort = strings.ToLower(strings.TrimSpace(query.Sort)) if query.Scope != "archived" { query.Scope = "current" } if query.OwnerScope != "mine" { query.OwnerScope = "all" } if query.Sort != "expiry" { query.Sort = "recent" } if query.Limit != 10 && query.Limit != 20 && query.Limit != 50 { query.Limit = 10 } if query.Offset < 0 { query.Offset = 0 } now := time.Now().UTC() s.exportsMu.Lock() if s.refreshHistoryExportAvailabilityLocked(now) { _ = s.persistHistoryExportsLocked() } all := make([]HistoryExportJob, 0, len(s.exports)) ownerID := historyExportOwnerID(principal) for _, job := range s.exports { if canAccessHistoryExport(principal, job) && (query.OwnerScope != "mine" || ownerID != "" && job.OwnerID == ownerID) { all = append(all, copyHistoryExportJob(job)) } } s.exportsMu.Unlock() summary := HistoryExportSummary{Total: len(all)} filtered := make([]HistoryExportJob, 0, len(all)) for _, job := range all { if job.ArchivedAt != "" { summary.Archived++ } else { summary.Current++ } if historyExportMatchesScope(job, query.Scope) { switch job.Status { case "queued", "running": summary.Active++ case "completed": summary.Completed++ if historyExportExpiresSoon(job, now) { summary.Expiring++ } case "failed", "expired": summary.Recoverable++ case "cancelled": summary.Cancelled++ } } if historyExportMatchesQuery(job, query, now) { filtered = append(filtered, job) } } sortHistoryExports(filtered, query, now) start := query.Offset if start > len(filtered) { start = len(filtered) } end := start + query.Limit if end > len(filtered) { end = len(filtered) } return HistoryExportPage{Items: append([]HistoryExportJob{}, filtered[start:end]...), Total: len(filtered), Limit: query.Limit, Offset: query.Offset, Summary: summary} } func historyExportTimestamp(value string) int64 { parsed, err := time.Parse(time.RFC3339, value) if err != nil { return 0 } return parsed.UnixNano() } func historyExportScopeActivity(job HistoryExportJob, scope string) int64 { if scope == "archived" { if archivedAt := historyExportTimestamp(job.ArchivedAt); archivedAt > 0 { return archivedAt } } return historyExportTimestamp(job.CreatedAt) } func historyExportFutureExpiry(job HistoryExportJob, now time.Time) (int64, bool) { if job.Status != "completed" { return 0, false } expiresAt := historyExportTimestamp(job.ExpiresAt) return expiresAt, expiresAt > now.UnixNano() } func sortHistoryExports(jobs []HistoryExportJob, query HistoryExportQuery, now time.Time) { sort.SliceStable(jobs, func(i, j int) bool { left, right := jobs[i], jobs[j] if query.Sort == "expiry" { leftExpiry, leftExpires := historyExportFutureExpiry(left, now) rightExpiry, rightExpires := historyExportFutureExpiry(right, now) if leftExpires != rightExpires { return leftExpires } if leftExpires && leftExpiry != rightExpiry { return leftExpiry < rightExpiry } } leftActivity := historyExportScopeActivity(left, query.Scope) rightActivity := historyExportScopeActivity(right, query.Scope) if leftActivity != rightActivity { return leftActivity > rightActivity } leftCreated := historyExportTimestamp(left.CreatedAt) rightCreated := historyExportTimestamp(right.CreatedAt) if leftCreated != rightCreated { return leftCreated > rightCreated } return left.ID > right.ID }) } func historyExportMatchesScope(job HistoryExportJob, scope string) bool { return scope == "archived" && job.ArchivedAt != "" || scope != "archived" && job.ArchivedAt == "" } func historyExportExpiresSoon(job HistoryExportJob, now time.Time) bool { if job.Status != "completed" || job.ExpiresAt == "" { return false } expiresAt, err := time.Parse(time.RFC3339, job.ExpiresAt) if err != nil { return false } return expiresAt.After(now) && !expiresAt.After(now.Add(24*time.Hour)) } func historyExportMatchesQuery(job HistoryExportJob, query HistoryExportQuery, now time.Time) bool { if !historyExportMatchesScope(job, query.Scope) { return false } switch query.Status { case "active": if job.Status != "queued" && job.Status != "running" { return false } case "failed": if job.Status != "failed" && job.Status != "expired" { return false } case "expiring": if !historyExportExpiresSoon(job, now) { return false } case "completed", "cancelled": if job.Status != query.Status { return false } } if query.Search == "" { return true } values := []string{job.ID, job.Name, job.Protocol, job.Category, job.Status, job.OwnerName, job.OwnerUsername, job.DateFrom, job.DateTo, job.CreatedAt, job.CompletedAt, job.RebuiltFrom, job.ArchivedBy, job.Evidence} values = append(values, job.Keywords...) values = append(values, job.VehicleVINs...) for _, value := range values { if strings.Contains(strings.ToLower(value), query.Search) { return true } } return false } func (s *Service) RebuildHistoryExport(ctx context.Context, id string) (HistoryExportJob, error) { principal, ok := PrincipalFromContext(ctx) if !ok { return HistoryExportJob{}, clientError{Code: "EXPORT_OWNER_REQUIRED", Message: "重建导出任务需要已认证账号"} } id = strings.TrimSpace(id) s.exportsMu.Lock() if s.refreshHistoryExportAvailabilityLocked(time.Now().UTC()) { _ = s.persistHistoryExportsLocked() } job := s.exports[id] if job == nil || !canAccessHistoryExport(principal, job) { s.exportsMu.Unlock() return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"} } copy := copyHistoryExportJob(job) for _, candidate := range s.exports { if candidate != nil && candidate.ID != copy.ID && canAccessHistoryExport(principal, candidate) && (candidate.Status == "queued" || candidate.Status == "running") && sameHistoryExportRequest(candidate, ©) { s.exportsMu.Unlock() return HistoryExportJob{}, clientError{Code: "EXPORT_REBUILD_ALREADY_ACTIVE", Message: "相同车辆、时间和字段范围已在生成,无需重复重建"} } } s.exportsMu.Unlock() if copy.Status != "failed" && copy.Status != "expired" { return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_REBUILDABLE", Message: "仅失败或文件已过期的任务可以按原范围重建"} } return s.createHistoryExport(ctx, historyExportRequestFromJob(copy), copy.ID) } func sameHistoryExportRequest(left, right *HistoryExportJob) bool { if left == nil || right == nil || left.Category != right.Category || left.Protocol != right.Protocol || left.DateFrom != right.DateFrom || left.DateTo != right.DateTo { return false } return equalStrings(left.Keywords, right.Keywords) && equalStrings(left.Metrics, right.Metrics) } func equalStrings(left, right []string) bool { if len(left) != len(right) { return false } for index := range left { if left[index] != right[index] { return false } } return true } func historyExportRequestFromJob(job HistoryExportJob) HistoryExportRequest { return HistoryExportRequest{ Keywords: append([]string(nil), job.Keywords...), Category: job.Category, Protocol: job.Protocol, DateFrom: job.DateFrom, DateTo: job.DateTo, Metrics: append([]string(nil), job.Metrics...), Format: "csv", RetentionDays: normalizedHistoryRetentionDays(job.RetentionDays), } } func (s *Service) BatchHistoryExports(ctx context.Context, request HistoryExportBatchRequest) (HistoryExportBatchResult, error) { action := strings.ToLower(strings.TrimSpace(request.Action)) if action != "cancel" && action != "rebuild" && action != "archive" && action != "restore" { return HistoryExportBatchResult{}, clientError{Code: "EXPORT_BATCH_ACTION_INVALID", Message: "批量操作仅支持 cancel、rebuild、archive 或 restore"} } ids := normalizedKeywords(request.IDs) if len(ids) == 0 || len(ids) > 20 { return HistoryExportBatchResult{}, clientError{Code: "EXPORT_BATCH_SCOPE_INVALID", Message: "批量操作需要选择 1 至 20 个任务"} } result := HistoryExportBatchResult{Action: action, Requested: len(ids), Succeeded: []HistoryExportJob{}, Skipped: []HistoryExportBatchSkipped{}} for _, id := range ids { var job HistoryExportJob var err error if action == "cancel" { job, err = s.CancelHistoryExport(ctx, id) } else if action == "rebuild" { job, err = s.RebuildHistoryExport(ctx, id) } else { job, err = s.SetHistoryExportArchived(ctx, id, action == "archive") } if err == nil { result.Succeeded = append(result.Succeeded, job) continue } code := "EXPORT_BATCH_ITEM_FAILED" if clientErr, ok := asClientError(err); ok { code = clientErr.Code } result.Skipped = append(result.Skipped, HistoryExportBatchSkipped{ID: id, Code: code, Message: err.Error()}) } return result, nil } func (s *Service) SetHistoryExportArchived(ctx context.Context, id string, archived bool) (HistoryExportJob, error) { principal, ok := PrincipalFromContext(ctx) if !ok { return HistoryExportJob{}, clientError{Code: "EXPORT_OWNER_REQUIRED", Message: "整理导出任务需要已认证账号"} } id = strings.TrimSpace(id) s.exportsMu.Lock() defer s.exportsMu.Unlock() job := s.exports[id] if job == nil || !canAccessHistoryExport(principal, job) { return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"} } if archived && (job.Status == "queued" || job.Status == "running") { return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_ARCHIVABLE", Message: "生成中的任务不能归档,请等待完成或先取消"} } if archived == (job.ArchivedAt != "") { return copyHistoryExportJob(job), nil } now := time.Now().UTC().Format(time.RFC3339) actor := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "account") if archived { job.ArchivedAt = now job.ArchivedBy = actor job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";任务由 " + actor + " 归档,原范围、结果与文件策略保持不变" } else { job.ArchivedAt = "" job.ArchivedBy = "" job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";任务由 " + actor + " 移回当前任务" } job.UpdatedAt = now if err := s.persistHistoryExportsLocked(); err != nil { return HistoryExportJob{}, fmt.Errorf("persist archived export job: %w", err) } return copyHistoryExportJob(job), nil } func historyExportCleanupPrincipal(ctx context.Context) (Principal, error) { principal, ok := PrincipalFromContext(ctx) if !ok { return Principal{}, clientError{Code: "EXPORT_CLEANUP_ADMIN_REQUIRED", Message: "归档清理需要已认证管理员"} } if !strings.EqualFold(strings.TrimSpace(principal.Role), "admin") { return Principal{}, clientError{Code: "EXPORT_CLEANUP_ADMIN_REQUIRED", Message: "只有管理员可以预演或执行归档清理"} } return principal, nil } func normalizedHistoryExportCleanupQuery(query HistoryExportCleanupQuery) (HistoryExportCleanupQuery, error) { if !allowedHistoryExportCleanupDays[query.OlderThanDays] { return HistoryExportCleanupQuery{}, clientError{Code: "EXPORT_CLEANUP_WINDOW_INVALID", Message: "归档清理周期仅支持 30、90、180 或 365 天"} } query.OwnerScope = strings.ToLower(strings.TrimSpace(query.OwnerScope)) if query.OwnerScope != "mine" { query.OwnerScope = "all" } return query, nil } func historyExportCleanupCandidate(job *HistoryExportJob) HistoryExportCleanupCandidate { return HistoryExportCleanupCandidate{ ID: job.ID, Name: job.Name, OwnerUsername: firstNonEmpty(job.OwnerUsername, job.OwnerName, "未知账号"), Status: job.Status, ArchivedAt: job.ArchivedAt, FileSizeBytes: job.FileSizeBytes, ProtectedAt: job.CleanupProtectedAt, ProtectedBy: job.CleanupProtectedBy, ProtectionReason: job.CleanupProtectionReason, } } func historyExportCleanupToken(query HistoryExportCleanupQuery, candidates []*HistoryExportJob) string { hash := sha256.New() _, _ = hash.Write([]byte(strconv.Itoa(query.OlderThanDays) + "\x00" + query.OwnerScope + "\x00")) for _, job := range candidates { _, _ = hash.Write([]byte(job.ID + "\x00" + job.UpdatedAt + "\x00")) } return "cleanup_" + hex.EncodeToString(hash.Sum(nil)[:12]) } func (s *Service) historyExportCleanupPreviewLocked(principal Principal, query HistoryExportCleanupQuery, now time.Time) (HistoryExportCleanupPreview, []*HistoryExportJob) { cutoff := now.Add(-time.Duration(query.OlderThanDays) * 24 * time.Hour) ownerID := historyExportOwnerID(principal) eligible := make([]*HistoryExportJob, 0) protected := make([]*HistoryExportJob, 0) archivedCount := 0 for _, job := range s.exports { if job == nil || !canAccessHistoryExport(principal, job) || job.ArchivedAt == "" { continue } if query.OwnerScope == "mine" && (ownerID == "" || job.OwnerID != ownerID) { continue } archivedCount++ archivedAt, err := time.Parse(time.RFC3339, job.ArchivedAt) if err != nil || archivedAt.After(cutoff) || job.Status == "queued" || job.Status == "running" { continue } if job.CleanupProtectedAt != "" { protected = append(protected, job) } else { eligible = append(eligible, job) } } sort.SliceStable(eligible, func(i, j int) bool { left, right := historyExportTimestamp(eligible[i].ArchivedAt), historyExportTimestamp(eligible[j].ArchivedAt) if left != right { return left < right } return eligible[i].ID < eligible[j].ID }) sort.SliceStable(protected, func(i, j int) bool { left, right := historyExportTimestamp(protected[i].ArchivedAt), historyExportTimestamp(protected[j].ArchivedAt) if left != right { return left < right } return protected[i].ID < protected[j].ID }) planned := eligible if len(planned) > historyExportCleanupBatchLimit { planned = planned[:historyExportCleanupBatchLimit] } candidatePreview := planned if len(candidatePreview) > historyExportCleanupCandidatePreviewLimit { candidatePreview = candidatePreview[:historyExportCleanupCandidatePreviewLimit] } protectedPreview := protected if len(protectedPreview) > historyExportCleanupCandidatePreviewLimit { protectedPreview = protectedPreview[:historyExportCleanupCandidatePreviewLimit] } preview := HistoryExportCleanupPreview{ OlderThanDays: query.OlderThanDays, OwnerScope: query.OwnerScope, Cutoff: cutoff.Format(time.RFC3339), GeneratedAt: now.Format(time.RFC3339), ArchivedCount: archivedCount, CandidateCount: len(eligible), PlannedCount: len(planned), ProtectedCount: len(protected), Candidates: make([]HistoryExportCleanupCandidate, 0, len(candidatePreview)), Protected: make([]HistoryExportCleanupCandidate, 0, len(protectedPreview)), PreviewToken: historyExportCleanupToken(query, planned), } for _, job := range planned { if job.filePath != "" { preview.FileCount++ preview.FileSizeBytes += job.FileSizeBytes } } for _, job := range candidatePreview { preview.Candidates = append(preview.Candidates, historyExportCleanupCandidate(job)) } for _, job := range protectedPreview { preview.Protected = append(preview.Protected, historyExportCleanupCandidate(job)) } return preview, planned } func (s *Service) PreviewHistoryExportCleanup(ctx context.Context, query HistoryExportCleanupQuery) (HistoryExportCleanupPreview, error) { principal, err := historyExportCleanupPrincipal(ctx) if err != nil { return HistoryExportCleanupPreview{}, err } query, err = normalizedHistoryExportCleanupQuery(query) if err != nil { return HistoryExportCleanupPreview{}, err } now := time.Now().UTC() s.exportsMu.Lock() defer s.exportsMu.Unlock() if s.refreshHistoryExportAvailabilityLocked(now) { _ = s.persistHistoryExportsLocked() } preview, _ := s.historyExportCleanupPreviewLocked(principal, query, now) return preview, nil } func (s *Service) CleanupHistoryExports(ctx context.Context, request HistoryExportCleanupRequest) (HistoryExportCleanupResult, error) { principal, err := historyExportCleanupPrincipal(ctx) if err != nil { return HistoryExportCleanupResult{}, err } query, err := normalizedHistoryExportCleanupQuery(HistoryExportCleanupQuery{OlderThanDays: request.OlderThanDays, OwnerScope: request.OwnerScope}) if err != nil { return HistoryExportCleanupResult{}, err } if strings.TrimSpace(request.PreviewToken) == "" { return HistoryExportCleanupResult{}, clientError{Code: "EXPORT_CLEANUP_PREVIEW_REQUIRED", Message: "执行清理前必须先完成预演"} } now := time.Now().UTC() s.exportsMu.Lock() defer s.exportsMu.Unlock() if s.refreshHistoryExportAvailabilityLocked(now) { _ = s.persistHistoryExportsLocked() } preview, planned := s.historyExportCleanupPreviewLocked(principal, query, now) if request.PreviewToken != preview.PreviewToken { return HistoryExportCleanupResult{}, clientError{Code: "EXPORT_CLEANUP_PREVIEW_STALE", Message: "归档范围已经变化,请重新预演后再执行"} } if len(planned) == 0 { return HistoryExportCleanupResult{}, clientError{Code: "EXPORT_CLEANUP_EMPTY", Message: "当前没有符合条件且未受保护的归档任务"} } deleted := make([]HistoryExportCleanupCandidate, 0, len(planned)) for _, job := range planned { deleted = append(deleted, historyExportCleanupCandidate(job)) } actor := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "account") recordID, randomErr := randomExportID() if randomErr != nil { recordID = "cleanup_" + strconv.FormatInt(now.UnixNano(), 10) } else { recordID = strings.Replace(recordID, "exp_", "cleanup_", 1) } record := HistoryExportCleanupRecord{ ID: recordID, Actor: actor, OwnerScope: query.OwnerScope, OlderThanDays: query.OlderThanDays, Cutoff: preview.Cutoff, Requested: preview.PlannedCount, Cleaned: len(deleted), Protected: preview.ProtectedCount, FileCount: preview.FileCount, FileSizeBytes: preview.FileSizeBytes, CandidateDigest: preview.PreviewToken, ExecutedAt: now.Format(time.RFC3339), } previousAudit := append([]HistoryExportCleanupRecord(nil), s.exportCleanupAudit...) s.exportCleanupAudit = append([]HistoryExportCleanupRecord{record}, s.exportCleanupAudit...) if len(s.exportCleanupAudit) > historyExportCleanupAuditLimit { s.exportCleanupAudit = s.exportCleanupAudit[:historyExportCleanupAuditLimit] } if err := s.persistHistoryExportCleanupAuditLocked(); err != nil { s.exportCleanupAudit = previousAudit return HistoryExportCleanupResult{}, fmt.Errorf("persist export cleanup audit: %w", err) } for _, job := range planned { delete(s.exports, job.ID) } if err := s.persistHistoryExportsLocked(); err != nil { for _, job := range planned { s.exports[job.ID] = job } s.exportCleanupAudit = previousAudit if rollbackErr := s.persistHistoryExportCleanupAuditLocked(); rollbackErr != nil { return HistoryExportCleanupResult{}, fmt.Errorf("persist export cleanup: %w; rollback cleanup audit: %v", err, rollbackErr) } return HistoryExportCleanupResult{}, fmt.Errorf("persist export cleanup: %w", err) } for _, job := range planned { if job.filePath == "" { continue } if removeErr := os.Remove(job.filePath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) { return HistoryExportCleanupResult{}, fmt.Errorf("export cleanup committed but archived file removal failed: %w", removeErr) } } return HistoryExportCleanupResult{Record: record, Deleted: deleted}, nil } func (s *Service) HistoryExportCleanupAudit(ctx context.Context, limit int) ([]HistoryExportCleanupRecord, error) { if _, err := historyExportCleanupPrincipal(ctx); err != nil { return nil, err } if limit <= 0 || limit > 50 { limit = 20 } s.exportsMu.RLock() defer s.exportsMu.RUnlock() if limit > len(s.exportCleanupAudit) { limit = len(s.exportCleanupAudit) } return append([]HistoryExportCleanupRecord(nil), s.exportCleanupAudit[:limit]...), nil } func (s *Service) SetHistoryExportCleanupProtection(ctx context.Context, id string, request HistoryExportCleanupProtectionRequest) (HistoryExportJob, error) { principal, err := historyExportCleanupPrincipal(ctx) if err != nil { return HistoryExportJob{}, err } reason := truncateHistoryPreferenceText(request.Reason, 120) if request.Protected && reason == "" { return HistoryExportJob{}, clientError{Code: "EXPORT_CLEANUP_PROTECTION_REASON_REQUIRED", Message: "长期保留需要填写审计原因"} } id = strings.TrimSpace(id) s.exportsMu.Lock() defer s.exportsMu.Unlock() job := s.exports[id] if job == nil || !canAccessHistoryExport(principal, job) { return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"} } if job.ArchivedAt == "" || job.Status == "queued" || job.Status == "running" { return HistoryExportJob{}, clientError{Code: "EXPORT_CLEANUP_PROTECTION_INVALID", Message: "只有已结束的审计归档任务可以设置长期保留"} } now := time.Now().UTC().Format(time.RFC3339) actor := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "account") if request.Protected { job.CleanupProtectedAt = now job.CleanupProtectedBy = actor job.CleanupProtectionReason = reason job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";" + actor + " 设置长期保留:" + reason } else { job.CleanupProtectedAt = "" job.CleanupProtectedBy = "" job.CleanupProtectionReason = "" job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";" + actor + " 取消长期保留,任务重新进入归档清理范围" } job.UpdatedAt = now if err := s.persistHistoryExportsLocked(); err != nil { return HistoryExportJob{}, fmt.Errorf("persist export cleanup protection: %w", err) } return copyHistoryExportJob(job), nil } func (s *Service) CancelHistoryExport(ctx context.Context, id string) (HistoryExportJob, error) { principal, ok := PrincipalFromContext(ctx) if !ok { return HistoryExportJob{}, clientError{Code: "EXPORT_OWNER_REQUIRED", Message: "取消导出任务需要已认证账号"} } id = strings.TrimSpace(id) s.exportsMu.Lock() defer s.exportsMu.Unlock() job := s.exports[id] if job == nil || !canAccessHistoryExport(principal, job) { return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"} } if job.Status == "cancelled" { return copyHistoryExportJob(job), nil } if job.Status != "queued" && job.Status != "running" { return HistoryExportJob{}, clientError{Code: "EXPORT_NOT_CANCELLABLE", Message: "仅排队中或执行中的导出任务可以取消"} } now := time.Now().UTC().Format(time.RFC3339) actor := firstNonEmpty(strings.TrimSpace(principal.Username), strings.TrimSpace(principal.Name), "account") job.Status = "cancelled" job.Error = "" job.DownloadURL = "" job.CancelledAt = now job.CancelledBy = actor job.UpdatedAt = now job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";任务由 " + actor + " 主动取消,已处理进度与原范围记录保留" if err := s.persistHistoryExportsLocked(); err != nil { return HistoryExportJob{}, fmt.Errorf("persist cancelled export job: %w", err) } return copyHistoryExportJob(job), nil } func (s *Service) HistoryExportFile(ctx context.Context, id string) (string, string, error) { principal, _ := PrincipalFromContext(ctx) s.exportsMu.Lock() if s.refreshHistoryExportAvailabilityLocked(time.Now().UTC()) { _ = s.persistHistoryExportsLocked() } job := s.exports[id] if job == nil || !canAccessHistoryExport(principal, job) { s.exportsMu.Unlock() return "", "", clientError{Code: "EXPORT_NOT_FOUND", Message: "导出任务不存在"} } copy := copyHistoryExportJob(job) path := job.filePath s.exportsMu.Unlock() if copy.Status == "expired" { return "", "", clientError{Code: "EXPORT_EXPIRED", Message: "导出文件已过期,请按原范围重建"} } 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 (s *Service) refreshHistoryExportAvailabilityLocked(now time.Time) bool { changed := false for _, job := range s.exports { if job == nil || job.Status != "completed" { continue } expiresAt, err := time.Parse(time.RFC3339, job.ExpiresAt) if err != nil { completedAt, completedErr := time.Parse(time.RFC3339, firstNonEmpty(job.CompletedAt, job.UpdatedAt, job.CreatedAt)) if completedErr != nil { completedAt = now } expiresAt = completedAt.Add(time.Duration(normalizedHistoryRetentionDays(job.RetentionDays)) * 24 * time.Hour) job.RetentionDays = normalizedHistoryRetentionDays(job.RetentionDays) job.ExpiresAt = expiresAt.Format(time.RFC3339) changed = true } missing := job.filePath == "" if !missing { _, statErr := os.Stat(job.filePath) missing = statErr != nil } if now.Before(expiresAt) && !missing { continue } if job.filePath != "" { _ = os.Remove(job.filePath) } job.Status = "expired" job.DownloadURL = "" job.filePath = "" job.ExpiredAt = now.Format(time.RFC3339) job.UpdatedAt = job.ExpiredAt if missing { job.Error = "导出文件已不存在,请按原范围重建" } else { job.Error = "文件保留期已结束,请按原范围重建" } job.Evidence = strings.TrimSuffix(job.Evidence, ";") + ";文件已失效,原车辆、时间与字段范围继续保留" changed = true } return changed } 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 }() started := s.updateHistoryExportIf(id, func(job *HistoryExportJob) bool { if job.Status != "queued" { return false } job.Status = "running" job.Progress = 1 return true }) if !started { return } 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.updateHistoryExportIf(id, func(job *HistoryExportJob) bool { if job.Status == "cancelled" { return false } job.TotalRows = totalRows job.Progress = 3 return true }) 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 currentJob.Status == "cancelled" { 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.updateHistoryExportIf(id, func(job *HistoryExportJob) bool { if job.Status == "cancelled" { return false } job.ProcessedRows = processed job.Progress = progress if stat != nil { job.FileSizeBytes = stat.Size() } return true }) 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 currentJob, exists := s.historyExportJob(id); !exists || currentJob.Status == "cancelled" { 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) published := s.updateHistoryExportIf(id, func(job *HistoryExportJob) bool { if job.Status == "cancelled" { return false } job.Status = "completed" job.Progress = 100 job.RowCount = int(processed) job.ProcessedRows = processed job.TotalRows = totalRows job.CompletedAt = completedAt job.RetentionDays = normalizedHistoryRetentionDays(job.RetentionDays) job.ExpiresAt = time.Now().UTC().Add(time.Duration(job.RetentionDays) * 24 * time.Hour).Format(time.RFC3339) if stat != nil { job.FileSizeBytes = stat.Size() } job.filePath = path job.DownloadURL = "/api/v2/exports/" + job.ID + "/download" return true }) if !published { _ = os.Remove(path) } } 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) updateHistoryExportIf(id string, update func(*HistoryExportJob) bool) bool { s.exportsMu.Lock() defer s.exportsMu.Unlock() job := s.exports[id] if job == nil || !update(job) { return false } job.UpdatedAt = time.Now().UTC().Format(time.RFC3339) _ = s.persistHistoryExportsLocked() return true } func (s *Service) failHistoryExport(id string, err error) { s.updateHistoryExportIf(id, func(job *HistoryExportJob) bool { if job.Status == "cancelled" { return false } job.Status = "failed" job.Error = err.Error() job.Progress = 0 return true }) } func (s *Service) HistoryPreferences(ctx context.Context) (HistoryPreferences, error) { principal, ok := PrincipalFromContext(ctx) if !ok { return HistoryPreferences{}, clientError{Code: "HISTORY_PREFERENCES_OWNER_REQUIRED", Message: "历史偏好需要已认证账号"} } ownerID := historyExportOwnerID(principal) if ownerID == "" { return HistoryPreferences{}, clientError{Code: "HISTORY_PREFERENCES_OWNER_REQUIRED", Message: "当前账号缺少可持久化的偏好身份"} } s.exportsMu.RLock() preference, exists := s.historyPreferences[ownerID] s.exportsMu.RUnlock() if !exists { preference = HistoryPreferences{RetentionDays: defaultHistoryExportRetentionDays, FieldViews: []HistoryFieldView{}} } return copyHistoryPreferences(preference), nil } func (s *Service) UpdateHistoryPreferences(ctx context.Context, input HistoryPreferences) (HistoryPreferences, error) { principal, ok := PrincipalFromContext(ctx) if !ok { return HistoryPreferences{}, clientError{Code: "HISTORY_PREFERENCES_OWNER_REQUIRED", Message: "保存历史偏好需要已认证账号"} } ownerID := historyExportOwnerID(principal) if ownerID == "" { return HistoryPreferences{}, clientError{Code: "HISTORY_PREFERENCES_OWNER_REQUIRED", Message: "当前账号缺少可持久化的偏好身份"} } if input.RetentionDays != 0 && !allowedHistoryExportRetentionDays[input.RetentionDays] { return HistoryPreferences{}, clientError{Code: "HISTORY_RETENTION_INVALID", Message: "导出文件保留时间仅支持 1、3、7、14 或 30 天"} } input.RetentionDays = normalizedHistoryRetentionDays(input.RetentionDays) if len(input.FieldViews) > 8 { return HistoryPreferences{}, clientError{Code: "HISTORY_FIELD_VIEW_LIMIT_EXCEEDED", Message: "每个账号最多保存 8 个字段视图"} } views := make([]HistoryFieldView, 0, len(input.FieldViews)) seen := map[string]bool{} for _, candidate := range input.FieldViews { candidate.Name = truncateHistoryPreferenceText(candidate.Name, 24) candidate.Category = strings.ToLower(strings.TrimSpace(candidate.Category)) if candidate.Category != "location" && candidate.Category != "raw" && candidate.Category != "mileage" { return HistoryPreferences{}, clientError{Code: "HISTORY_FIELD_VIEW_INVALID", Message: "字段视图类型仅支持 location、raw 或 mileage"} } candidate.Keys = normalizedKeywords(candidate.Keys) if candidate.Name == "" || len(candidate.Keys) == 0 || len(candidate.Keys) > 32 { return HistoryPreferences{}, clientError{Code: "HISTORY_FIELD_VIEW_INVALID", Message: "字段视图需要名称和 1 至 32 个字段"} } candidate.ID = truncateHistoryPreferenceText(candidate.ID, 80) if candidate.ID == "" { candidate.ID = truncateHistoryPreferenceText(candidate.Category+":"+candidate.Name, 80) } key := candidate.Category + ":" + strings.ToLower(candidate.Name) if seen[key] { continue } seen[key] = true views = append(views, candidate) } now := time.Now().UTC().Format(time.RFC3339) s.exportsMu.Lock() current := s.historyPreferences[ownerID] updated := HistoryPreferences{Revision: current.Revision + 1, RetentionDays: input.RetentionDays, FieldViews: views, UpdatedAt: now} s.historyPreferences[ownerID] = updated if err := s.persistHistoryPreferencesLocked(); err != nil { if current.Revision == 0 && len(current.FieldViews) == 0 && current.UpdatedAt == "" { delete(s.historyPreferences, ownerID) } else { s.historyPreferences[ownerID] = current } s.exportsMu.Unlock() return HistoryPreferences{}, fmt.Errorf("persist history preferences: %w", err) } s.exportsMu.Unlock() return copyHistoryPreferences(updated), nil } func (s *Service) historyRetentionDaysForPrincipal(principal Principal) int { ownerID := historyExportOwnerID(principal) if ownerID == "" { return defaultHistoryExportRetentionDays } s.exportsMu.RLock() days := s.historyPreferences[ownerID].RetentionDays s.exportsMu.RUnlock() return normalizedHistoryRetentionDays(days) } func truncateHistoryPreferenceText(value string, limit int) string { runes := []rune(strings.TrimSpace(value)) if len(runes) > limit { runes = runes[:limit] } return string(runes) } func copyHistoryPreferences(preference HistoryPreferences) HistoryPreferences { copy := preference copy.RetentionDays = normalizedHistoryRetentionDays(copy.RetentionDays) copy.FieldViews = make([]HistoryFieldView, len(preference.FieldViews)) for index, view := range preference.FieldViews { copy.FieldViews[index] = view copy.FieldViews[index].Keys = append([]string(nil), view.Keys...) } return copy } func (s *Service) exportIndexPath() string { return filepath.Join(s.exportDir, "jobs.json") } func (s *Service) historyPreferencesPath() string { return filepath.Join(s.exportDir, "history-preferences.json") } func (s *Service) historyExportCleanupAuditPath() string { return filepath.Join(s.exportDir, "cleanup-audit.json") } func (s *Service) persistHistoryPreferencesLocked() error { if err := os.MkdirAll(s.exportDir, 0o750); err != nil { return err } contents, err := json.MarshalIndent(s.historyPreferences, "", " ") if err != nil { return err } temporary := s.historyPreferencesPath() + ".tmp" if err := os.WriteFile(temporary, contents, 0o640); err != nil { return err } return os.Rename(temporary, s.historyPreferencesPath()) } func (s *Service) loadHistoryPreferences() { contents, err := os.ReadFile(s.historyPreferencesPath()) if err != nil { return } preferences := map[string]HistoryPreferences{} if json.Unmarshal(contents, &preferences) != nil { return } for ownerID, preference := range preferences { if strings.TrimSpace(ownerID) == "" { continue } s.historyPreferences[ownerID] = copyHistoryPreferences(preference) } } 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 }) 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) persistHistoryExportCleanupAuditLocked() error { if err := os.MkdirAll(s.exportDir, 0o750); err != nil { return err } contents, err := json.MarshalIndent(s.exportCleanupAudit, "", " ") if err != nil { return err } temporary := s.historyExportCleanupAuditPath() + ".tmp" if err := os.WriteFile(temporary, contents, 0o640); err != nil { return err } return os.Rename(temporary, s.historyExportCleanupAuditPath()) } func (s *Service) loadHistoryExportCleanupAudit() { contents, err := os.ReadFile(s.historyExportCleanupAuditPath()) if err != nil { return } var records []HistoryExportCleanupRecord if json.Unmarshal(contents, &records) != nil { return } if len(records) > historyExportCleanupAuditLimit { records = records[:historyExportCleanupAuditLimit] } s.exportCleanupAudit = append([]HistoryExportCleanupRecord(nil), records...) } 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 = "expired" job.Progress = 0 job.Error = "导出文件已不存在,请按原范围重建" job.DownloadURL = "" job.filePath = "" job.ExpiredAt = now 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.RetentionDays = normalizedHistoryRetentionDays(copy.RetentionDays) 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 normalizedHistoryRetentionDays(days int) int { if allowedHistoryExportRetentionDays[days] { return days } return defaultHistoryExportRetentionDays } 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) metric := HistoryMetricDefinition{Key: key, Label: label, Unit: unit, Category: "raw", ValueType: "dynamic", DefaultVisible: true} if reference, ok := gb32960ReferenceForSource(key); ok { metric.Description = reference.Description metric.ValueMappings = reference.ValueMappings } selected = append(selected, metric) 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)}, {"文件策略", "完成后保留天数", strconv.Itoa(normalizedHistoryRetentionDays(job.RetentionDays))}, {"车辆 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 } if display := protocolDisplayValue(column.Key, value); display != "" { record = append(record, display) } else { 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 && points[0].MileageAvailable && points[len(points)-1].MileageAvailable && mileageDistance >= 0 && mileageDistance < 10000 { summary.DistanceKm = mileageDistance summary.DistanceMethod = "odometer" } else { summary.DistanceKm = coordinateDistance summary.DistanceMethod = "coordinates" } 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)) grouped := map[string][]HistoryLocationRow{} for _, point := range points { protocol := strings.ToUpper(firstNonEmpty(strings.TrimSpace(point.Protocol), "UNKNOWN")) grouped[protocol] = append(grouped[protocol], point) } selected := requestedProtocol if selected == "" { protocols := make([]string, 0, len(grouped)) for protocol := range grouped { protocols = append(protocols, protocol) } sort.Slice(protocols, func(i, j int) bool { left := trackSourceSelectionScore(grouped[protocols[i]]) right := trackSourceSelectionScore(grouped[protocols[j]]) if left.HasMovement != right.HasMovement { return left.HasMovement } if left.DurationSeconds != right.DurationSeconds { return left.DurationSeconds > right.DurationSeconds } if left.CoordinateChanges != right.CoordinateChanges { return left.CoordinateChanges > right.CoordinateChanges } if math.Abs(left.DistanceKm-right.DistanceKm) > 0.001 { return left.DistanceKm > right.DistanceKm } if left.MovingPoints != right.MovingPoints { return left.MovingPoints > right.MovingPoints } if len(grouped[protocols[i]]) != len(grouped[protocols[j]]) { return len(grouped[protocols[i]]) > len(grouped[protocols[j]]) } return protocols[i] < protocols[j] }) if len(protocols) > 0 { selected = protocols[0] } } result := grouped[selected] return result, selected, len(points) - len(result) } type trackSourceScore struct { HasMovement bool DurationSeconds int64 CoordinateChanges int MovingPoints int DistanceKm float64 } func trackSourceSelectionScore(points []HistoryLocationRow) trackSourceScore { score := trackSourceScore{} if len(points) == 0 { return score } if start, startOK := parseVehicleServiceTime(points[0].DeviceTime); startOK { if end, endOK := parseVehicleServiceTime(points[len(points)-1].DeviceTime); endOK && end.After(start) { score.DurationSeconds = int64(end.Sub(start).Seconds()) } } for index, point := range points { if point.SpeedKmh > trackStopSpeedKmh { score.MovingPoints++ } if index == 0 || !validTrackCoordinate(points[index-1]) || !validTrackCoordinate(point) { continue } 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 { continue } distance := haversineKm(points[index-1].Latitude, points[index-1].Longitude, point.Latitude, point.Longitude) score.DistanceKm += distance if distance >= 0.03 { score.CoordinateChanges++ } } score.HasMovement = score.CoordinateChanges >= 2 || score.DistanceKm >= 0.5 || score.MovingPoints >= 3 return score } 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 } result, err := s.store.MileageSummary(ctx, resolvedQuery) if err != nil { return MileageSummary{}, err } if !hydrogenConsumptionAllowed(ctx) { result.TotalPureHydrogenMileageKm = 0 } return result, nil } 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 } result, err := s.store.DailyMileage(ctx, resolvedQuery) if err != nil { return Page[DailyMileageRow]{}, err } if !hydrogenConsumptionAllowed(ctx) { for index := range result.Items { result.Items[index].PureHydrogenMileageKm = 0 result.Items[index].HydrogenConsumptionKg = nil result.Items[index].HydrogenConsumptionKgPer100Km = nil } } return result, nil } 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 } result, err := s.store.MileageStatistics(ctx, resolvedQuery) if err != nil { return MileageStatistics{}, err } if !hydrogenConsumptionAllowed(ctx) { result.PeriodPureHydrogenMileageKm = 0 result.HydrogenMatchedMileageKm = 0 result.HydrogenDataDays = 0 result.PeriodHydrogenConsumptionKg = 0 result.HydrogenConsumptionKgPer100Km = nil for index := range result.Trend { result.Trend[index].PureHydrogenMileageKm = 0 result.Trend[index].HydrogenMatchedMileageKm = 0 result.Trend[index].HydrogenDataDays = 0 result.Trend[index].HydrogenConsumptionKg = nil result.Trend[index].HydrogenConsumptionKgPer100Km = nil } } return result, nil } func hydrogenConsumptionAllowed(ctx context.Context) bool { principal, ok := PrincipalFromContext(ctx) return !ok || (principal.Role != "customer" && principal.UserType != "customer") } 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) > 50_000 { return nil, clientError{Code: "TOO_MANY_VEHICLES", Message: "里程查询一次最多选择 50,000 辆车"} } 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 sourceNamesForProtocol(statuses []VehicleSourceStatus, protocol string) []string { protocol = strings.TrimSpace(protocol) out := make([]string, 0, len(statuses)) for _, status := range statuses { name := strings.TrimSpace(status.Protocol) if name == "" || (protocol != "" && name != protocol) { continue } out = append(out, name) } 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 }