功能:扩展开放平台氢耗溯源与合作站数据
This commit is contained in:
@@ -43,6 +43,10 @@ type Store interface {
|
||||
OpsHealth(context.Context) (OpsHealth, error)
|
||||
}
|
||||
|
||||
type HydrogenDailyEvidenceStore interface {
|
||||
HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error)
|
||||
}
|
||||
|
||||
type VehicleOverviewBatchStore interface {
|
||||
VehicleServiceOverviews(context.Context, VehicleOverviewBatchQuery) (Page[VehicleServiceOverview], error)
|
||||
}
|
||||
@@ -65,6 +69,14 @@ type VehicleSourceEvidenceStore interface {
|
||||
VehicleSourceEvidence(context.Context, string, string) (VehicleSourceEvidence, error)
|
||||
}
|
||||
|
||||
// RealtimeSnapshotFrameStore exposes the durable per-protocol realtime
|
||||
// projection. The projection is used as a time anchor for bounded TDengine
|
||||
// lookups, so an offline vehicle can still resolve its latest evidence without
|
||||
// scanning all retained RAW history.
|
||||
type RealtimeSnapshotFrameStore interface {
|
||||
LatestRealtimeFrames(context.Context, RawFrameQuery) (Page[RawFrameRow], error)
|
||||
}
|
||||
|
||||
type RawFrameQuery struct {
|
||||
Protocol string `json:"protocol"`
|
||||
VIN string `json:"vin"`
|
||||
@@ -1029,7 +1041,7 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
raw, err := s.RawFrames(ctx, rawQuery)
|
||||
raw, err := s.vehicleDetailRawPreview(ctx, rawQuery)
|
||||
if err != nil {
|
||||
return VehicleDetail{}, err
|
||||
}
|
||||
@@ -1094,6 +1106,142 @@ func (s *Service) VehicleDetail(ctx context.Context, vin string, protocol string
|
||||
}, nil
|
||||
}
|
||||
|
||||
const vehicleDetailRawProjectionLookback = 24 * time.Hour
|
||||
|
||||
type vehicleDetailRawPreviewResult struct {
|
||||
anchor RawFrameRow
|
||||
page Page[RawFrameRow]
|
||||
}
|
||||
|
||||
// vehicleDetailRawPreview preserves the latest-ever semantics without issuing
|
||||
// an unbounded TDengine query. MySQL supplies one persistent anchor per
|
||||
// protocol; TDengine then reads only the day preceding that protocol's actual
|
||||
// latest received time. If historical storage is unavailable, the projection
|
||||
// itself remains useful evidence and the rest of the vehicle detail can load.
|
||||
func (s *Service) vehicleDetailRawPreview(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
|
||||
snapshotStore, ok := s.store.(RealtimeSnapshotFrameStore)
|
||||
if !ok {
|
||||
return s.store.RawFrames(ctx, query)
|
||||
}
|
||||
anchors, err := snapshotStore.LatestRealtimeFrames(ctx, RawFrameQuery{
|
||||
VIN: query.VIN,
|
||||
Protocol: query.Protocol,
|
||||
DateFrom: query.DateFrom,
|
||||
DateTo: query.DateTo,
|
||||
Fields: query.Fields,
|
||||
IncludeFields: query.IncludeFields,
|
||||
Limit: len(canonicalVehicleProtocols),
|
||||
SkipCount: true,
|
||||
})
|
||||
if err != nil {
|
||||
return Page[RawFrameRow]{}, err
|
||||
}
|
||||
if len(anchors.Items) == 0 {
|
||||
limit := query.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
return Page[RawFrameRow]{Items: []RawFrameRow{}, Total: 0, Limit: limit, Offset: 0}, nil
|
||||
}
|
||||
|
||||
results := make(chan vehicleDetailRawPreviewResult, len(anchors.Items))
|
||||
for _, anchor := range anchors.Items {
|
||||
anchor := anchor
|
||||
go func() {
|
||||
bounded, valid := boundedRawPreviewQuery(query, anchor)
|
||||
if !valid {
|
||||
results <- vehicleDetailRawPreviewResult{anchor: anchor}
|
||||
return
|
||||
}
|
||||
page, queryErr := s.store.RawFrames(ctx, bounded)
|
||||
if queryErr != nil {
|
||||
results <- vehicleDetailRawPreviewResult{anchor: anchor}
|
||||
return
|
||||
}
|
||||
results <- vehicleDetailRawPreviewResult{anchor: anchor, page: page}
|
||||
}()
|
||||
}
|
||||
|
||||
items := make([]RawFrameRow, 0, len(anchors.Items)*query.Limit)
|
||||
for range anchors.Items {
|
||||
result := <-results
|
||||
if len(result.page.Items) == 0 {
|
||||
items = append(items, result.anchor)
|
||||
continue
|
||||
}
|
||||
items = append(items, result.page.Items...)
|
||||
}
|
||||
items = newestRawFrames(items, query.Limit)
|
||||
limit := query.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
return Page[RawFrameRow]{Items: items, Total: len(items), Limit: limit, Offset: 0}, nil
|
||||
}
|
||||
|
||||
func boundedRawPreviewQuery(query RawFrameQuery, anchor RawFrameRow) (RawFrameQuery, bool) {
|
||||
anchorTime, ok := parseVehicleServiceTime(firstNonEmpty(anchor.ServerTime, anchor.DeviceTime))
|
||||
if !ok {
|
||||
return RawFrameQuery{}, false
|
||||
}
|
||||
start := anchorTime.Add(-vehicleDetailRawProjectionLookback)
|
||||
end := anchorTime.Add(time.Minute)
|
||||
if value := strings.TrimSpace(query.DateFrom); value != "" {
|
||||
if scopedStart, parsed := parseTrackRequestTime(value); parsed && scopedStart.After(start) {
|
||||
start = scopedStart
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(query.DateTo); value != "" {
|
||||
if scopedEnd, parsed := parseTrackRequestTime(value); parsed && scopedEnd.Before(end) {
|
||||
end = scopedEnd
|
||||
}
|
||||
}
|
||||
if end.Before(start) {
|
||||
return RawFrameQuery{}, false
|
||||
}
|
||||
bounded := query
|
||||
bounded.Protocol = anchor.Protocol
|
||||
bounded.DateFrom = start.Format(time.RFC3339Nano)
|
||||
bounded.DateTo = end.Format(time.RFC3339Nano)
|
||||
bounded.Offset = 0
|
||||
bounded.SkipCount = true
|
||||
return bounded, true
|
||||
}
|
||||
|
||||
func newestRawFrames(items []RawFrameRow, limit int) []RawFrameRow {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
deduplicated := make([]RawFrameRow, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for _, item := range items {
|
||||
key := strings.ToUpper(strings.TrimSpace(item.Protocol)) + "\x00" + strings.TrimSpace(item.ID)
|
||||
if key != "\x00" && seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
deduplicated = append(deduplicated, item)
|
||||
}
|
||||
sort.SliceStable(deduplicated, func(left, right int) bool {
|
||||
leftTime, leftOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[left].ServerTime, deduplicated[left].DeviceTime))
|
||||
rightTime, rightOK := parseVehicleServiceTime(firstNonEmpty(deduplicated[right].ServerTime, deduplicated[right].DeviceTime))
|
||||
if leftOK && rightOK && !leftTime.Equal(rightTime) {
|
||||
return leftTime.After(rightTime)
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
if deduplicated[left].Protocol != deduplicated[right].Protocol {
|
||||
return deduplicated[left].Protocol < deduplicated[right].Protocol
|
||||
}
|
||||
return deduplicated[left].ID < deduplicated[right].ID
|
||||
})
|
||||
if len(deduplicated) > limit {
|
||||
deduplicated = deduplicated[:limit]
|
||||
}
|
||||
return deduplicated
|
||||
}
|
||||
|
||||
func (s *Service) VehicleSourceEvidence(ctx context.Context, vin string, date string) (VehicleSourceEvidence, error) {
|
||||
vin = strings.TrimSpace(vin)
|
||||
if vin == "" {
|
||||
@@ -1587,6 +1735,7 @@ func coverageStatus(sourceCount int, onlineSourceCount int) string {
|
||||
|
||||
func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, statuses []VehicleSourceStatus) []VehicleSourceStatus {
|
||||
scopedWindow, scopeErr := applyPrincipalHistoryTimeScope(ctx, vin, url.Values{})
|
||||
_, hasDurableRawProjection := s.store.(RealtimeSnapshotFrameStore)
|
||||
for index := range statuses {
|
||||
protocol := statuses[index].Protocol
|
||||
if protocol == "" {
|
||||
@@ -1599,7 +1748,7 @@ func (s *Service) enrichVehicleSourceStatus(ctx context.Context, vin string, sta
|
||||
statuses[index].LastSeen = latestString(statuses[index].LastSeen, firstNonEmpty(page.Items[0].ServerTime, page.Items[0].DeviceTime))
|
||||
}
|
||||
}
|
||||
if !statuses[index].HasRaw && scopeErr == nil {
|
||||
if !statuses[index].HasRaw && scopeErr == nil && !hasDurableRawProjection {
|
||||
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
|
||||
@@ -1933,7 +2082,7 @@ func (s *Service) LatestTelemetry(ctx context.Context, vehicleKey string) (Lates
|
||||
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})
|
||||
page, queryErr := s.vehicleDetailRawPreview(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)
|
||||
}
|
||||
@@ -5409,6 +5558,40 @@ func (s *Service) DailyMileage(ctx context.Context, query url.Values) (Page[Dail
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) HydrogenDailyEvidence(ctx context.Context, vin, date string) (HydrogenDailyEvidence, error) {
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
date = strings.TrimSpace(date)
|
||||
if len(vin) != 17 {
|
||||
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_VIN_INVALID", Message: "VIN格式不正确"}
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", date); err != nil {
|
||||
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_DATE_INVALID", Message: "统计日期格式不正确"}
|
||||
}
|
||||
if !hydrogenConsumptionAllowed(ctx) {
|
||||
return HydrogenDailyEvidence{}, clientError{Code: "VEHICLE_PERMISSION_DENIED", Message: "当前账号无权查看氢耗计算证据"}
|
||||
}
|
||||
query := url.Values{"vins": {vin}, "dateFrom": {date}, "dateTo": {date}}
|
||||
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||||
if err != nil {
|
||||
return HydrogenDailyEvidence{}, err
|
||||
}
|
||||
if err := requirePrincipalHistoricalGrantScope(ctx, resolvedQuery); err != nil {
|
||||
return HydrogenDailyEvidence{}, err
|
||||
}
|
||||
store, ok := s.store.(HydrogenDailyEvidenceStore)
|
||||
if !ok {
|
||||
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "当前数据源尚未提供氢耗计算证据"}
|
||||
}
|
||||
result, found, err := store.HydrogenDailyEvidence(ctx, vin, date)
|
||||
if err != nil {
|
||||
return HydrogenDailyEvidence{}, err
|
||||
}
|
||||
if !found {
|
||||
return HydrogenDailyEvidence{}, clientError{Code: "HYDROGEN_EVIDENCE_NOT_FOUND", Message: "未找到该车辆当日氢耗计算证据"}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
|
||||
resolvedQuery, err := s.resolveVehicleQuery(ctx, query)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user