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

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
@@ -543,6 +543,7 @@ func TestHistoryPreferencesPersistPerAccountAndSupplyExportRetention(t *testing.
type countingStore struct {
*MockStore
rawMu sync.Mutex
vehiclesCalls int
vehicleRealtimeCalls int
overviewBatchCalls int
@@ -555,6 +556,17 @@ type countingStore struct {
lastMileageStatisticsQuery url.Values
}
type projectionCountingStore struct {
*countingStore
anchors Page[RawFrameRow]
projectionQueries []RawFrameQuery
}
func (s *projectionCountingStore) LatestRealtimeFrames(_ context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
s.projectionQueries = append(s.projectionQueries, query)
return s.anchors, nil
}
type hydrogenMetricsStore struct{ *MockStore }
func (s *hydrogenMetricsStore) DailyMileage(context.Context, url.Values) (Page[DailyMileageRow], error) {
@@ -572,15 +584,26 @@ func (s *hydrogenMetricsStore) MileageSummary(context.Context, url.Values) (Mile
func (s *hydrogenMetricsStore) MileageStatistics(context.Context, url.Values) (MileageStatistics, error) {
consumption, rate := 3.1, 5.5
return MileageStatistics{
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
PeriodMileageKm: 88.7, PeriodPureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1,
PeriodHydrogenConsumptionKg: consumption, HydrogenConsumptionKgPer100Km: &rate,
Trend: []MileageTrendPoint{{
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 88.7, HydrogenDataDays: 1,
Date: "2026-07-13", MileageKm: 88.7, PureHydrogenMileageKm: 56.2, HydrogenMatchedMileageKm: 56.2, HydrogenDataDays: 1,
HydrogenConsumptionKg: &consumption, HydrogenConsumptionKgPer100Km: &rate,
}},
}, nil
}
func (s *hydrogenMetricsStore) HydrogenDailyEvidence(context.Context, string, string) (HydrogenDailyEvidence, bool, error) {
return HydrogenDailyEvidence{
VIN: "LB9A32A24R0LS1426",
Date: "2026-07-13",
AlgorithmVersion: "PRESSURE_NIST_SEGMENT_MEDIAN_5_SOC_BALANCE_V2",
Intervals: []HydrogenIntervalEvidenceRow{{
Index: 1, StartEventID: "event-start", EndEventID: "event-end", QualityStatus: "OK",
}},
}, true, nil
}
func newCountingStore() *countingStore {
return &countingStore{MockStore: NewMockStore()}
}
@@ -622,6 +645,27 @@ func TestHydrogenConsumptionMetricsRemainAvailableToInternalAccounts(t *testing.
if len(daily.Items) != 1 || daily.Items[0].PureHydrogenMileageKm != 56.2 || daily.Items[0].HydrogenConsumptionKg == nil || *daily.Items[0].HydrogenConsumptionKg != 3.1 {
t.Fatalf("internal daily mileage lost hydrogen metrics: %+v", daily.Items)
}
evidence, err := service.HydrogenDailyEvidence(exportAdminContext(), "LB9A32A24R0LS1426", "2026-07-13")
if err != nil {
t.Fatal(err)
}
if evidence.AlgorithmVersion == "" || len(evidence.Intervals) != 1 || evidence.Intervals[0].StartEventID != "event-start" {
t.Fatalf("internal hydrogen evidence is incomplete: %+v", evidence)
}
}
func TestHydrogenDailyEvidenceIsDeniedToCustomerAccounts(t *testing.T) {
service := NewService(&hydrogenMetricsStore{MockStore: NewMockStore()})
validFrom := time.Date(2026, 7, 12, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
customer := WithPrincipal(context.Background(), Principal{
Name: "业务客户", Role: "customer", UserType: "customer",
VehicleVINs: []string{"LB9A32A24R0LS1426"},
VehicleGrants: []VehicleGrant{{VIN: "LB9A32A24R0LS1426", ValidFrom: validFrom}},
})
_, err := service.HydrogenDailyEvidence(customer, "LB9A32A24R0LS1426", "2026-07-13")
if clientErr, ok := asClientError(err); !ok || clientErr.Code != "VEHICLE_PERMISSION_DENIED" {
t.Fatalf("customer hydrogen evidence should be forbidden, err=%v", err)
}
}
func (s *countingStore) Vehicles(ctx context.Context, query url.Values) (Page[VehicleRow], error) {
@@ -643,7 +687,9 @@ func (s *countingStore) HistoryLocationsFromTDengine(ctx context.Context, query
}
func (s *countingStore) RawFrames(ctx context.Context, query RawFrameQuery) (Page[RawFrameRow], error) {
s.rawMu.Lock()
s.rawFrameQueries = append(s.rawFrameQueries, query)
s.rawMu.Unlock()
return s.MockStore.RawFrames(ctx, query)
}
@@ -692,6 +738,66 @@ func TestVehicleDetailSkipsUnneededTDengineCounts(t *testing.T) {
}
}
func TestVehicleDetailUsesPersistentProjectionAsBoundedHistoricalAnchor(t *testing.T) {
base := newCountingStore()
store := &projectionCountingStore{
countingStore: base,
anchors: Page[RawFrameRow]{Items: []RawFrameRow{
{ID: "snapshot-gb", VIN: "LB9A32A24R0LS1426", Protocol: "GB32960", FrameType: "realtime_projection", DeviceTime: "2025-03-04T12:00:00+08:00", ServerTime: "2025-03-04T12:00:01+08:00", ParsedFields: map[string]any{"gb32960.vehicle.speed_kmh": 0}},
{ID: "snapshot-jt", VIN: "LB9A32A24R0LS1426", Protocol: "JT808", FrameType: "realtime_projection", DeviceTime: "2025-02-03T10:00:00+08:00", ServerTime: "2025-02-03T10:00:01+08:00", ParsedFields: map[string]any{"jt808.location.speed_kmh": 0}},
{ID: "snapshot-mqtt", VIN: "LB9A32A24R0LS1426", Protocol: "YUTONG_MQTT", FrameType: "realtime_projection", DeviceTime: "2025-01-01T08:00:00+08:00", ServerTime: "2025-01-01T08:00:01+08:00", ParsedFields: map[string]any{"yutong_mqtt.data.speed_kmh": 0}},
}},
}
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
if err != nil {
t.Fatal(err)
}
if len(store.projectionQueries) != 1 || store.projectionQueries[0].VIN != "LB9A32A24R0LS1426" {
t.Fatalf("projection queries = %+v", store.projectionQueries)
}
if len(store.rawFrameQueries) != 3 {
t.Fatalf("bounded protocol RAW queries = %+v", store.rawFrameQueries)
}
for _, query := range store.rawFrameQueries {
if query.Protocol == "" || query.DateFrom == "" || query.DateTo == "" || !query.SkipCount {
t.Fatalf("RAW preview must be protocol-specific, time-bounded, and count-free: %+v", query)
}
if strings.HasPrefix(query.DateFrom, "2026-") || strings.HasPrefix(query.DateTo, "2026-") {
t.Fatalf("offline projection should anchor its actual historical day, not today: %+v", query)
}
if !strings.Contains(query.DateFrom, "+08:00") || !strings.Contains(query.DateTo, "+08:00") {
t.Fatalf("bounded RAW preview should preserve the platform timezone: %+v", query)
}
}
if len(detail.Raw.Items) != 3 {
t.Fatalf("bounded preview should preserve every latest protocol row: %+v", detail.Raw)
}
projectionFallbacks := 0
for _, item := range detail.Raw.Items {
if item.FrameType == "realtime_projection" {
projectionFallbacks++
}
}
if projectionFallbacks != 2 {
t.Fatalf("missing projection fallbacks for protocols without RAW test rows: %+v", detail.Raw.Items)
}
}
func TestVehicleDetailDoesNotFallBackToUnboundedRAWWhenProjectionIsEmpty(t *testing.T) {
base := newCountingStore()
store := &projectionCountingStore{countingStore: base, anchors: Page[RawFrameRow]{Items: []RawFrameRow{}}}
detail, err := NewService(store).VehicleDetail(exportAdminContext(), "LB9A32A24R0LS1426", "")
if err != nil {
t.Fatal(err)
}
if len(store.rawFrameQueries) != 0 {
t.Fatalf("empty durable projection must not trigger an unbounded RAW scan: %+v", store.rawFrameQueries)
}
if detail.Raw.Items == nil || len(detail.Raw.Items) != 0 || detail.Raw.Total != 0 {
t.Fatalf("empty projection should return a stable empty preview: %+v", detail.Raw)
}
}
func (s *countingStore) MileageStatistics(ctx context.Context, query url.Values) (MileageStatistics, error) {
s.lastMileageStatisticsQuery = cloneValues(query)
return s.MockStore.MileageStatistics(ctx, query)