功能:扩展开放平台氢耗溯源与合作站数据

This commit is contained in:
lingniu
2026-09-02 14:05:34 +08:00
parent 8759668d39
commit c6875fbb98
33 changed files with 5692 additions and 475 deletions
@@ -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 {