feat: 补齐开放平台车辆实况与统计时间字段
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆数据开放平台 API
|
||||
version: 1.7.0
|
||||
version: 1.8.0
|
||||
license:
|
||||
name: Proprietary
|
||||
description: |
|
||||
@@ -210,7 +210,11 @@ paths:
|
||||
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。
|
||||
socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。
|
||||
activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。
|
||||
在线且所选来源速度大于3km/h为行驶中,否则为静止中。
|
||||
离线时 motionStatus=offline;在线且所选来源速度大于3km/h为 driving,否则为 idle。
|
||||
储氢使用可追溯原始帧;最新帧缺少氢量时可有界回补,当前百分比无可信容量分母为 null/UNSUPPORTED;不得用 SOC 替代。
|
||||
仅 GB 最新快照有引用且精确帧氢量 MISSING 时,回查该快照 received_at 向前 5 分钟的含氢量字段原始帧,按采集时间优先、接收时间次序取最新;不回退覆盖 INVALID,离线回补保留真实 STALE。
|
||||
GPS 定位状态独立于在线与陈旧;坐标系可能 UNKNOWN,不能假定统一 GCJ02。
|
||||
补充历史查询共享 3 秒预算,失败或超时降级为 MISSING/UNKNOWN 并保留旧实时字段;MISSING 不等于设备不支持。
|
||||
operationId: queryRealtimeVehicles
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
@@ -608,8 +612,11 @@ components:
|
||||
description: true仅合作站,false仅外部站,省略则返回全部
|
||||
HydrogenResult:
|
||||
type: object
|
||||
required: [plateNumber, date, hydrogenConsumptionKg, status]
|
||||
required: [vin, plateNumber, date, hydrogenConsumptionKg, statisticsStartTime, statisticsEndTime, updatedAt, status]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
description: 授权车辆 VIN,供跨接口身份校验
|
||||
plateNumber:
|
||||
type: string
|
||||
date:
|
||||
@@ -620,17 +627,32 @@ components:
|
||||
format: double
|
||||
nullable: true
|
||||
description: 单日用氢量,kg;OK 和 SUSPECT 计算结果均返回数值,无数据时为 null
|
||||
statisticsStartTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: FINAL 证据区间最早开始时间;PRELIMINARY 仅有结束水位故为 null;缺失或异常证据为 null。边界是证据包络,不承诺连续覆盖
|
||||
statisticsEndTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: FINAL 证据区间最晚结束时间,PRELIMINARY 为真实 lastEventTime 水位;缺失或异常证据为 null;不是 API 查询时间
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 同一用氢统计行的数据库更新时间,RFC 3339 带时区;无统计行时为 null
|
||||
calculationPhase:
|
||||
type: string
|
||||
enum: [PRELIMINARY, FINAL]
|
||||
description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL
|
||||
description: PRELIMINARY 仅供标注为初步的日内监控;FINAL 表示批量重算已完成,补传或算法调整仍可能再次重算,不保证固定结算时刻
|
||||
algorithmVersion:
|
||||
type: string
|
||||
description: 氢耗计算算法版本
|
||||
qualityStatus:
|
||||
type: string
|
||||
enum: [OK, SUSPECT, NO_DATA]
|
||||
description: 氢耗计算质量状态
|
||||
description: OK 对应 NORMAL;SUSPECT 对应 DATA_ANOMALY 并保留数值供审计,不得用于正常百公里氢耗;NO_DATA 对应无可用统计
|
||||
qualityReason:
|
||||
type: string
|
||||
description: 质量判定原因;SUSPECT 或 NO_DATA 时供调用方解释和审计
|
||||
@@ -638,7 +660,7 @@ components:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
MileageResult:
|
||||
type: object
|
||||
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status]
|
||||
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, statisticsStartTime, statisticsEndTime, status]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
@@ -657,6 +679,16 @@ components:
|
||||
format: double
|
||||
nullable: true
|
||||
description: 当日所选协议最后有效终端累计总里程,km;GPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null
|
||||
statisticsStartTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 当日统计所选来源的最早 first_event_time,跨日基线可早于当日零点,不是自然日起始;历史结转补零或无数据时为 null
|
||||
statisticsEndTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 当日所选来源最晚 latest_event_time,等于 dataTime;历史结转补零或无数据时为 null。与用氢接口无共同快照保证
|
||||
dataTime:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -846,10 +878,69 @@ components:
|
||||
$ref: '#/components/schemas/StationaryVehicleResult'
|
||||
RealtimeVehicleResult:
|
||||
type: object
|
||||
required: [vin, plateNumber, online, motionStatus, locationAvailable, status]
|
||||
required: [vin, plateNumber, online, motionStatus, locationAvailable, status, remainingHydrogenKg, remainingHydrogenPercent, hydrogenRecordTime, hydrogenDataStatus, remainingHydrogenKgStatus, remainingHydrogenPercentStatus, hydrogenValueSource, hydrogenSourceProtocol, hydrogenStaleAfterSeconds, hydrogenExpectedIntervalSeconds, gpsFixStatus, locationRecordTime, coordinateSystem]
|
||||
properties:
|
||||
vin: { type: string }
|
||||
plateNumber: { type: string }
|
||||
remainingHydrogenKg:
|
||||
type: number
|
||||
nullable: true
|
||||
minimum: 0
|
||||
maximum: 200
|
||||
description: GB32960 广东燃料电池扩展 0x34 全车上报氢质量,kg;真实零保留。无值、不支持或无效为 null;陈旧值保留并标 STALE,不以单瓶压力或当日消耗推算
|
||||
remainingHydrogenPercent:
|
||||
type: number
|
||||
nullable: true
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
description: 当前无可信额定或可用质量容量分母,固定 null,字段状态 UNSUPPORTED;不以 SOC 替代
|
||||
hydrogenRecordTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 氢质量对应原始同帧实际采集时间,RFC 3339 带时区;不是请求时间或缓存更新时间
|
||||
hydrogenDataStatus:
|
||||
type: string
|
||||
enum: [NORMAL, PARTIAL, STALE, MISSING, UNSUPPORTED, INVALID]
|
||||
description: NORMAL 保留供未来全部字段有效;当前不会输出 NORMAL;PARTIAL 部分支持(当前质量有效但百分比不支持);STALE 数据陈旧;MISSING 无记录;UNSUPPORTED 不支持;INVALID 异常。分别检查字段级状态
|
||||
remainingHydrogenKgStatus:
|
||||
type: string
|
||||
enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID]
|
||||
remainingHydrogenPercentStatus:
|
||||
type: string
|
||||
enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID]
|
||||
description: 当前固定 UNSUPPORTED
|
||||
hydrogenValueSource:
|
||||
type: string
|
||||
nullable: true
|
||||
enum: [REPORTED]
|
||||
description: REPORTED 仅表示终端上报质量,无法确认终端内部采用测量还是估算;本接口不作压力推算
|
||||
hydrogenSourceProtocol:
|
||||
type: string
|
||||
nullable: true
|
||||
enum: [GB32960, MQTT, JT808]
|
||||
description: 储氢数据采用的协议,独立于位置的 protocol
|
||||
hydrogenStaleAfterSeconds:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: GB32960 服务时效策略为 300 秒;不是协议采样周期承诺
|
||||
hydrogenExpectedIntervalSeconds:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: 当前无已确认上报周期,固定 null
|
||||
gpsFixStatus:
|
||||
type: string
|
||||
enum: [FIXED, NO_FIX, UNKNOWN]
|
||||
description: 实际位置报文定位位;GB32960 bit0=0、JT808 bit1=1 表示 FIXED;MQTT 或缺少可信定位位为 UNKNOWN。不以在线或数据年龄推断
|
||||
locationRecordTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 位置行对应实际采集时间,独立于主记录 recordTime;历史 FIXED 不因车辆离线改变
|
||||
coordinateSystem:
|
||||
type: string
|
||||
enum: [WGS84, GCJ02, UNKNOWN]
|
||||
description: 仅 GB2025 显式坐标类型 1=WGS84、2=GCJ02,其余 UNKNOWN;GB2016/JT808/MQTT 当前无确证为 UNKNOWN,未知坐标系不得直接当高德坐标使用
|
||||
protocol:
|
||||
type: string
|
||||
enum: [GB32960, MQTT, JT808]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Timestamps are evidence from the persisted result, never the API clock or
|
||||
// separately evolving stream state. A missing boundary stays unknown.
|
||||
func validStatisticsTimestamp(value string) *string {
|
||||
t, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
normalized := t.In(time.FixedZone("CST", 8*60*60)).Format(time.RFC3339Nano)
|
||||
return &normalized
|
||||
}
|
||||
|
||||
func validatedStatisticsInterval(start, end string) (*string, *string) {
|
||||
a, b := validStatisticsTimestamp(start), validStatisticsTimestamp(end)
|
||||
if a != nil && b != nil {
|
||||
at, _ := time.Parse(time.RFC3339Nano, *a)
|
||||
bt, _ := time.Parse(time.RFC3339Nano, *b)
|
||||
if at.After(bt) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
return a, b
|
||||
}
|
||||
|
||||
// FINAL evidence contains an envelope of calculation segments, not a promise
|
||||
// of uninterrupted coverage. PRELIMINARY evidence currently only stores a
|
||||
// lastEventTime watermark, so its start is intentionally null.
|
||||
func hydrogenStatisticsInterval(evidence string) (*string, *string) {
|
||||
var live struct {
|
||||
LastEventTime string `json:"lastEventTime"`
|
||||
}
|
||||
if json.Unmarshal([]byte(evidence), &live) == nil && live.LastEventTime != "" {
|
||||
return nil, validStatisticsTimestamp(live.LastEventTime)
|
||||
}
|
||||
var segments []struct {
|
||||
StartTime string `json:"startTime"`
|
||||
EndTime string `json:"endTime"`
|
||||
}
|
||||
if json.Unmarshal([]byte(evidence), &segments) != nil || len(segments) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var earliest, latest time.Time
|
||||
for _, segment := range segments {
|
||||
a, b := validatedStatisticsInterval(segment.StartTime, segment.EndTime)
|
||||
if a == nil || b == nil {
|
||||
return nil, nil
|
||||
}
|
||||
at, _ := time.Parse(time.RFC3339Nano, *a)
|
||||
bt, _ := time.Parse(time.RFC3339Nano, *b)
|
||||
if earliest.IsZero() || at.Before(earliest) {
|
||||
earliest = at
|
||||
}
|
||||
if latest.IsZero() || bt.After(latest) {
|
||||
latest = bt
|
||||
}
|
||||
}
|
||||
return validatedStatisticsInterval(earliest.Format(time.RFC3339Nano), latest.Format(time.RFC3339Nano))
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestHydrogenStatisticsInterval(t *testing.T) {
|
||||
cases := []struct{ name, evidence, start, end string }{
|
||||
{"live watermark", `{"lastEventTime":"2026-09-08T10:23:45.123Z"}`, "", "2026-09-08T18:23:45.123+08:00"},
|
||||
{"final envelope", `[{"startTime":"2026-09-08T02:00:00+08:00","endTime":"2026-09-08T03:00:00+08:00"},{"startTime":"2026-09-08T01:00:00+08:00","endTime":"2026-09-08T01:30:00+08:00"}]`, "2026-09-08T01:00:00+08:00", "2026-09-08T03:00:00+08:00"},
|
||||
{"missing", `null`, "", ""},
|
||||
{"legacy empty", `[]`, "", ""},
|
||||
{"malformed", `{`, "", ""},
|
||||
{"reversed", `[{"startTime":"2026-09-08T03:00:00+08:00","endTime":"2026-09-08T02:00:00+08:00"}]`, "", ""},
|
||||
{"incomplete", `[{"startTime":"2026-09-08T03:00:00+08:00"}]`, "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
a, b := hydrogenStatisticsInterval(tc.evidence)
|
||||
if (a == nil) != (tc.start == "") || a != nil && *a != tc.start {
|
||||
t.Fatalf("start=%v", a)
|
||||
}
|
||||
if (b == nil) != (tc.end == "") || b != nil && *b != tc.end {
|
||||
t.Fatalf("end=%v", b)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageStatisticsIntervalDoesNotRelabelCarryForward(t *testing.T) {
|
||||
value := DailyMileage{Date: "2026-09-07", StatisticsStartTime: "2026-09-07T00:01:00+08:00", DataTime: "2026-09-07T23:01:00+08:00"}
|
||||
carried := MileageResult{Date: "2026-09-08"}
|
||||
fillMileageResult(&carried, value, 0)
|
||||
if carried.StatisticsStartTime != nil || carried.StatisticsEndTime != nil {
|
||||
t.Fatalf("fabricated carry interval: %+v", carried)
|
||||
}
|
||||
current := MileageResult{Date: value.Date}
|
||||
fillMileageResult(¤t, value, 10)
|
||||
if current.StatisticsStartTime == nil || current.StatisticsEndTime == nil {
|
||||
t.Fatalf("missing evidence: %+v", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyHydrogenReadsEvidenceAndUpdateFromSameRow(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT vin,DATE_FORMAT.*COALESCE\\(evidence_json,'null'\\).*DATE_FORMAT\\(updated_at").WithArgs("2026-09-08", "VIN1").WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "consumption", "samples", "quality", "reason", "phase", "algorithm", "evidence", "updated"}).AddRow("VIN1", "2026-09-08", 0, 2, "OK", "", "PRELIMINARY", "V3_5", `{"lastEventTime":"2026-09-08T10:00:00+08:00"}`, "2026-09-08T10:01:00.123000+08:00"))
|
||||
values, err := NewMySQLRepository(db).DailyHydrogen(context.Background(), []string{"VIN1"}, "2026-09-08")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if values["VIN1"].EvidenceJSON == "" || values["VIN1"].UpdatedAt == "" {
|
||||
t.Fatal(values)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyQueryRejectsSameCountWrongAuthorizationMembership(t *testing.T) {
|
||||
repo := &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"}}}
|
||||
_, err := NewService(repo).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01"})
|
||||
if err != ErrForbidden {
|
||||
t.Fatalf("authorization membership mismatch must fail closed: %v", err)
|
||||
}
|
||||
if len(repo.dailyVINs) != 0 {
|
||||
t.Fatalf("queried data after failed authorization: %v", repo.dailyVINs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyQueryIgnoresUnrequestedVINResults(t *testing.T) {
|
||||
repo := &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}}, hydrogen: map[string]DailyHydrogen{"LTEST32960VIN0002": {ConsumptionKg: 99, QualityStatus: "OK"}}}
|
||||
rows, err := NewService(repo).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].VIN != "LTEST32960VIN0001" || rows[0].HydrogenConsumptionKg != nil {
|
||||
t.Fatalf("unexpected VIN leaked: %+v", rows)
|
||||
}
|
||||
}
|
||||
@@ -79,3 +79,36 @@ func TestOpenAPISpecCoversPublicAndManagementEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveContractDocumentationCoversSerializedFieldsAndQuality(t *testing.T) {
|
||||
// Public additions must be discoverable in both the machine contract and the
|
||||
// human-readable documentation, including their null/quality companions.
|
||||
for _, field := range []string{
|
||||
"remainingHydrogenKg", "remainingHydrogenPercent", "hydrogenRecordTime",
|
||||
"hydrogenDataStatus", "remainingHydrogenKgStatus", "remainingHydrogenPercentStatus",
|
||||
"hydrogenValueSource", "hydrogenSourceProtocol", "hydrogenStaleAfterSeconds",
|
||||
"hydrogenExpectedIntervalSeconds", "gpsFixStatus", "locationRecordTime",
|
||||
"coordinateSystem", "statisticsStartTime", "statisticsEndTime",
|
||||
"updatedAt", "calculationPhase", "qualityStatus", "algorithmVersion",
|
||||
} {
|
||||
if !strings.Contains(string(openAPISpec), field+":") {
|
||||
t.Errorf("OpenAPI missing live contract field %s", field)
|
||||
}
|
||||
if !strings.Contains(string(simpleDocsHTML), field) {
|
||||
t.Errorf("HTML missing live contract field %s", field)
|
||||
}
|
||||
}
|
||||
for name, doc := range map[string]string{"OpenAPI": string(openAPISpec), "HTML": string(simpleDocsHTML)} {
|
||||
for _, boundary := range []string{"PARTIAL", "UNSUPPORTED", "UNKNOWN", "PRELIMINARY", "FINAL", "SUSPECT", "REPORTED", "共同快照", "null"} {
|
||||
if !strings.Contains(doc, boundary) {
|
||||
t.Errorf("%s missing availability/comparability boundary %q", name, boundary)
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(openAPISpec), "MEASURED") || strings.Contains(string(simpleDocsHTML), "MEASURED") {
|
||||
t.Error("reported hydrogen must not promise a measured terminal value")
|
||||
}
|
||||
if strings.Contains(string(simpleDocsHTML), "新接入请使用 sourceProtocol") {
|
||||
t.Error("HTML must not advertise sourceProtocol as realtime field")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ func (p *ProtocolPriority) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
|
||||
type HydrogenResult struct {
|
||||
VIN string `json:"vin"`
|
||||
StatisticsStartTime *string `json:"statisticsStartTime"`
|
||||
StatisticsEndTime *string `json:"statisticsEndTime"`
|
||||
UpdatedAt *string `json:"updatedAt"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Date string `json:"date"`
|
||||
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
|
||||
@@ -57,16 +61,18 @@ type HydrogenResult struct {
|
||||
}
|
||||
|
||||
type MileageResult struct {
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Date string `json:"date"`
|
||||
DailyMileageKm *float64 `json:"dailyMileageKm"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
DataTime *string `json:"dataTime"`
|
||||
UpdatedAt *string `json:"updatedAt"`
|
||||
SourceProtocol *string `json:"sourceProtocol"`
|
||||
DataQuality *string `json:"dataQuality,omitempty"`
|
||||
Status string `json:"status"`
|
||||
StatisticsStartTime *string `json:"statisticsStartTime"`
|
||||
StatisticsEndTime *string `json:"statisticsEndTime"`
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Date string `json:"date"`
|
||||
DailyMileageKm *float64 `json:"dailyMileageKm"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
DataTime *string `json:"dataTime"`
|
||||
UpdatedAt *string `json:"updatedAt"`
|
||||
SourceProtocol *string `json:"sourceProtocol"`
|
||||
DataQuality *string `json:"dataQuality,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type MileageRangeResult struct {
|
||||
@@ -151,21 +157,31 @@ type RealtimeVehicleRequest struct {
|
||||
}
|
||||
|
||||
type RealtimeVehiclePoint struct {
|
||||
VIN string
|
||||
Protocol string
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
SpeedKmh float64
|
||||
SOCPercent *float64
|
||||
TotalMileageKm float64
|
||||
ObservedAt time.Time
|
||||
Online bool
|
||||
ActiveToday bool
|
||||
LiveHydrogen RealtimeHydrogenData
|
||||
LocationObservedAt time.Time
|
||||
LocationEventID string
|
||||
LocationReceivedAt time.Time
|
||||
GPSFixStatus string
|
||||
CoordinateSystem string
|
||||
VIN string
|
||||
Protocol string
|
||||
Longitude float64
|
||||
Latitude float64
|
||||
SpeedKmh float64
|
||||
SOCPercent *float64
|
||||
TotalMileageKm float64
|
||||
ObservedAt time.Time
|
||||
Online bool
|
||||
ActiveToday bool
|
||||
}
|
||||
|
||||
type RealtimeVehicleResult struct {
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
RealtimeHydrogenData
|
||||
GPSFixStatus string `json:"gpsFixStatus"`
|
||||
LocationRecordTime *string `json:"locationRecordTime"`
|
||||
CoordinateSystem string `json:"coordinateSystem"`
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
// Protocol is the single, canonical source protocol for this realtime record.
|
||||
// It is normalized to GB32960, MQTT, or JT808.
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
@@ -285,6 +301,8 @@ type AppCredential struct {
|
||||
}
|
||||
|
||||
type DailyHydrogen struct {
|
||||
EvidenceJSON string
|
||||
UpdatedAt string
|
||||
VIN string
|
||||
Date string
|
||||
ConsumptionKg float64
|
||||
@@ -296,13 +314,14 @@ type DailyHydrogen struct {
|
||||
}
|
||||
|
||||
type DailyMileage struct {
|
||||
VIN string
|
||||
Date string
|
||||
Protocol string
|
||||
MileageKm float64
|
||||
TotalMileageKm float64
|
||||
DataTime string
|
||||
UpdatedAt string
|
||||
StatisticsStartTime string
|
||||
VIN string
|
||||
Date string
|
||||
Protocol string
|
||||
MileageKm float64
|
||||
TotalMileageKm float64
|
||||
DataTime string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type MileageSnapshot struct {
|
||||
|
||||
@@ -196,7 +196,8 @@ func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, n
|
||||
query := `
|
||||
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
|
||||
COALESCE(l.speed_kmh,0),l.soc_percent,COALESCE(l.total_mileage_km,0),l.updated_at,
|
||||
MAX(CASE WHEN l.updated_at>=? THEN 1 ELSE 0 END) OVER (PARTITION BY l.vin) AS active_today
|
||||
MAX(CASE WHEN l.updated_at>=? THEN 1 ELSE 0 END) OVER (PARTITION BY l.vin) AS active_today,
|
||||
l.event_time,l.received_at,l.event_id
|
||||
FROM vehicle_realtime_location l
|
||||
WHERE BINARY l.vin IN (` + placeholders + `)
|
||||
ORDER BY l.vin,
|
||||
@@ -212,9 +213,12 @@ ORDER BY l.vin,
|
||||
for rows.Next() {
|
||||
var point RealtimeVehiclePoint
|
||||
var soc sql.NullFloat64
|
||||
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &point.TotalMileageKm, &point.ObservedAt, &point.ActiveToday); err != nil {
|
||||
var locationAt, receivedAt sql.NullTime
|
||||
var eventID sql.NullString
|
||||
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &point.TotalMileageKm, &point.ObservedAt, &point.ActiveToday, &locationAt, &receivedAt, &eventID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
point.LocationObservedAt, point.LocationReceivedAt, point.LocationEventID = locationAt.Time, receivedAt.Time, eventID.String
|
||||
if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 {
|
||||
value := round3(soc.Float64)
|
||||
point.SOCPercent = &value
|
||||
@@ -229,7 +233,16 @@ ORDER BY l.vin,
|
||||
out[point.VIN] = selected
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.enrichRealtimeLiveData(ctx, vins, out, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||
@@ -313,7 +326,8 @@ func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date
|
||||
}
|
||||
query, args := inQuery(`
|
||||
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status,quality_reason,
|
||||
calculation_phase,algorithm_version
|
||||
calculation_phase,algorithm_version,COALESCE(evidence_json,'null'),
|
||||
DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%s.%f+08:00')
|
||||
FROM vehicle_open_daily_energy
|
||||
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
@@ -324,7 +338,7 @@ WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
||||
out := make(map[string]DailyHydrogen, len(vins))
|
||||
for rows.Next() {
|
||||
var value DailyHydrogen
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.QualityReason, &value.CalculationPhase, &value.AlgorithmVersion); err != nil {
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus, &value.QualityReason, &value.CalculationPhase, &value.AlgorithmVersion, &value.EvidenceJSON, &value.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[value.VIN] = value
|
||||
@@ -407,7 +421,12 @@ SELECT
|
||||
AND selected.is_selected=1
|
||||
AND selected.latest_event_time IS NOT NULL
|
||||
),'%Y-%m-%dT%H:%i:%s+08:00'),''),
|
||||
DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00')
|
||||
DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00'),
|
||||
COALESCE(DATE_FORMAT((SELECT MIN(selected.first_event_time)
|
||||
FROM vehicle_daily_mileage_source selected
|
||||
WHERE selected.vin=m.vin AND selected.stat_date=m.stat_date
|
||||
AND selected.protocol=m.protocol AND selected.is_selected=1
|
||||
),'%Y-%m-%dT%H:%i:%s+08:00'),'')
|
||||
FROM vehicle_daily_mileage m
|
||||
WHERE m.stat_date BETWEEN ? AND ?
|
||||
AND m.vin IN (` + placeholders + `)
|
||||
@@ -445,7 +464,7 @@ ORDER BY m.stat_date,m.vin,
|
||||
out := make(map[string]DailyMileage, len(vins))
|
||||
for rows.Next() {
|
||||
var value DailyMileage
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt, &value.StatisticsStartTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := dailyMileageKey(value.VIN, value.Date)
|
||||
@@ -805,7 +824,8 @@ func inQuery(template, first string, values []string) (string, []any) {
|
||||
for _, value := range values {
|
||||
args = append(args, value)
|
||||
}
|
||||
return strings.Replace(template, "%s", placeholders, 1), args
|
||||
// Match the IN marker, not MySQL DATE_FORMAT seconds (%s).
|
||||
return strings.Replace(template, "IN (%s)", "IN ("+placeholders+")", 1), args
|
||||
}
|
||||
|
||||
func nullableTime(value *time.Time) any {
|
||||
|
||||
@@ -11,10 +11,13 @@ import (
|
||||
)
|
||||
|
||||
func TestInQueryPreservesMySQLDateFormatPercentTokens(t *testing.T) {
|
||||
query, args := inQuery("SELECT DATE_FORMAT(stat_date,'%Y-%m-%d') FROM metrics WHERE stat_date=? AND vin IN (%s)", "2026-07-21", []string{"VIN1", "VIN2"})
|
||||
query, args := inQuery("SELECT DATE_FORMAT(stat_date,'%Y-%m-%d'),DATE_FORMAT(updated_at,'%Y-%m-%dT%H:%i:%s.%f+08:00') FROM metrics WHERE stat_date=? AND vin IN (%s)", "2026-07-21", []string{"VIN1", "VIN2"})
|
||||
if strings.Contains(query, "MISSING") || !strings.Contains(query, "DATE_FORMAT(stat_date,'%Y-%m-%d')") {
|
||||
t.Fatalf("date format was corrupted: %s", query)
|
||||
}
|
||||
if !strings.Contains(query, "%H:%i:%s.%f+08:00") || strings.Contains(query, "IN (%s)") {
|
||||
t.Fatalf("timestamp seconds consumed as VIN placeholder: %s", query)
|
||||
}
|
||||
if !strings.Contains(query, "vin IN (?,?)") {
|
||||
t.Fatalf("VIN placeholders missing: %s", query)
|
||||
}
|
||||
@@ -132,8 +135,8 @@ func TestDailyMileageReturnsDailyAndAuthoritativeSameProtocolEndTotal(t *testing
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*COALESCE\\(m.day_end_total_mileage_km,m.latest_total_mileage_km\\)>=0.*m.daily_mileage_km>=0").
|
||||
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at", "statistics_start_time"}).
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00", "2026-07-21T00:01:00+08:00"))
|
||||
|
||||
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001", "LTEST32960VIN0002"}, "2026-07-21", nil)
|
||||
if err != nil {
|
||||
@@ -159,9 +162,9 @@ func TestDailyMileageExplicitPriorityFiltersDisabledProtocolsAndKeepsZero(t *tes
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("m.protocol IN \\(\\?,\\?\\).*ORDER BY m.stat_date,m.vin,CASE m.protocol WHEN \\? THEN 1 WHEN \\? THEN 2").
|
||||
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "JT808", "GB32960", "JT808", "GB32960").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "JT808", 0.0, 12000.0, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00").
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 12.0, 12012.0, "2026-07-21T23:59:00+08:00", "2026-07-22T05:10:00+08:00"))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at", "statistics_start_time"}).
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "JT808", 0.0, 12000.0, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00", "2026-07-21T00:01:00+08:00").
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 12.0, 12012.0, "2026-07-21T23:59:00+08:00", "2026-07-22T05:10:00+08:00", "2026-07-21T00:01:00+08:00"))
|
||||
|
||||
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-21", []string{"JT808", "GB32960"})
|
||||
if err != nil {
|
||||
@@ -269,9 +272,9 @@ func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T)
|
||||
vin := "LTEST32960VIN0001"
|
||||
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
|
||||
WithArgs(dayStart, vin, now.Add(-10*time.Minute)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "updated_at", "active_today"}).
|
||||
AddRow(vin, "GB32960", 120.1, 30.2, 0, 86.5, 1000, now.Add(-2*time.Minute), true).
|
||||
AddRow(vin, "JT808", 120.2, 30.3, 10, nil, 0, now.Add(-20*time.Second), true))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "soc_percent", "total_mileage_km", "updated_at", "active_today", "event_time", "received_at", "event_id"}).
|
||||
AddRow(vin, "GB32960", 120.1, 30.2, 0, 86.5, 1000, now.Add(-2*time.Minute), true, now.Add(-2*time.Minute), now.Add(-2*time.Minute), "gb-frame").
|
||||
AddRow(vin, "JT808", 120.2, 30.3, 10, nil, 0, now.Add(-20*time.Second), true, now.Add(-20*time.Second), now.Add(-20*time.Second), "jt-frame"))
|
||||
|
||||
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GB32960 live freshness is an API policy, not an assertion about the device's
|
||||
// negotiated reporting interval. No supported protocol currently has a verified
|
||||
// full-vehicle percentage/capacity contract.
|
||||
const realtimeHydrogenStaleSeconds int64 = 300
|
||||
|
||||
type RealtimeHydrogenData struct {
|
||||
RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"`
|
||||
RemainingHydrogenPercent *float64 `json:"remainingHydrogenPercent"`
|
||||
HydrogenRecordTime *string `json:"hydrogenRecordTime"`
|
||||
HydrogenDataStatus string `json:"hydrogenDataStatus"`
|
||||
RemainingHydrogenKgStatus string `json:"remainingHydrogenKgStatus"`
|
||||
RemainingHydrogenPercentStatus string `json:"remainingHydrogenPercentStatus"`
|
||||
HydrogenValueSource *string `json:"hydrogenValueSource"`
|
||||
HydrogenSourceProtocol *string `json:"hydrogenSourceProtocol"`
|
||||
HydrogenStaleAfterSeconds *int64 `json:"hydrogenStaleAfterSeconds"`
|
||||
HydrogenExpectedIntervalSeconds *int64 `json:"hydrogenExpectedIntervalSeconds"`
|
||||
}
|
||||
|
||||
func missingRealtimeHydrogen(protocol string) RealtimeHydrogenData {
|
||||
status := "UNSUPPORTED"
|
||||
if protocol == "" || protocol == "GB32960" {
|
||||
status = "MISSING"
|
||||
}
|
||||
result := RealtimeHydrogenData{HydrogenDataStatus: status, RemainingHydrogenKgStatus: status, RemainingHydrogenPercentStatus: "UNSUPPORTED"}
|
||||
if protocol == "GB32960" {
|
||||
threshold := realtimeHydrogenStaleSeconds
|
||||
result.HydrogenStaleAfterSeconds = &threshold
|
||||
result.HydrogenSourceProtocol = &protocol
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyRealtimeLiveData(item *RealtimeVehicleResult, point RealtimeVehiclePoint, location *time.Location) {
|
||||
item.RealtimeHydrogenData = point.LiveHydrogen
|
||||
if item.HydrogenDataStatus == "" {
|
||||
item.RealtimeHydrogenData = missingRealtimeHydrogen(point.Protocol)
|
||||
}
|
||||
if point.GPSFixStatus != "" {
|
||||
item.GPSFixStatus = point.GPSFixStatus
|
||||
}
|
||||
if point.CoordinateSystem != "" {
|
||||
item.CoordinateSystem = point.CoordinateSystem
|
||||
}
|
||||
if !point.LocationObservedAt.IsZero() {
|
||||
at := point.LocationObservedAt.In(location).Format(time.RFC3339Nano)
|
||||
item.LocationRecordTime = &at
|
||||
}
|
||||
}
|
||||
|
||||
func realtimeHydrogenFromFrame(parsed string, observedAt, now time.Time) RealtimeHydrogenData {
|
||||
result := missingRealtimeHydrogen("GB32960")
|
||||
var fields map[string]any
|
||||
if json.Unmarshal([]byte(parsed), &fields) != nil {
|
||||
return result
|
||||
}
|
||||
for _, key := range hydrogenMassFields {
|
||||
value, exists := fields[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
// A malformed or explicit protocol invalid value must never fall through to
|
||||
// an alias retaining a different reading.
|
||||
mass, valid := numericValue(value)
|
||||
if !observedAt.IsZero() {
|
||||
at := observedAt.In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format(time.RFC3339Nano)
|
||||
result.HydrogenRecordTime = &at
|
||||
}
|
||||
if !valid || math.IsNaN(mass) || math.IsInf(mass, 0) || mass < 0 || mass > 200 || observedAt.IsZero() || observedAt.After(now.Add(time.Minute)) {
|
||||
result.HydrogenDataStatus, result.RemainingHydrogenKgStatus = "INVALID", "INVALID"
|
||||
return result
|
||||
}
|
||||
mass = round3(mass)
|
||||
result.RemainingHydrogenKg = &mass
|
||||
source := "REPORTED"
|
||||
result.HydrogenValueSource = &source
|
||||
result.HydrogenDataStatus, result.RemainingHydrogenKgStatus = "PARTIAL", "NORMAL"
|
||||
if now.Sub(observedAt) > time.Duration(realtimeHydrogenStaleSeconds)*time.Second {
|
||||
result.HydrogenDataStatus, result.RemainingHydrogenKgStatus = "STALE", "STALE"
|
||||
}
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GPS flags are read only from the exact raw frame which produced the chosen
|
||||
// location. A snapshot's JSON is merge-patched and cannot prove field freshness.
|
||||
func realtimeGPSFromFrame(protocol, parsed string) (string, string) {
|
||||
fix, coordinate := "UNKNOWN", "UNKNOWN"
|
||||
var fields map[string]any
|
||||
if json.Unmarshal([]byte(parsed), &fields) != nil {
|
||||
return fix, coordinate
|
||||
}
|
||||
switch protocol {
|
||||
case "GB32960":
|
||||
if flag, ok := numericValue(fields["gb32960.position.position_status"]); ok && flag >= 0 && flag <= 7 && math.Trunc(flag) == flag {
|
||||
fix = "FIXED"
|
||||
if int(flag)&1 != 0 {
|
||||
fix = "NO_FIX"
|
||||
}
|
||||
}
|
||||
if code, exists := fields["gb32960.position.coordinate_system"]; exists {
|
||||
if value, ok := numericValue(code); ok {
|
||||
switch value {
|
||||
case 1:
|
||||
coordinate = "WGS84"
|
||||
case 2:
|
||||
coordinate = "GCJ02"
|
||||
}
|
||||
}
|
||||
}
|
||||
case "JT808":
|
||||
if flag, ok := numericValue(fields["jt808.location.status_flag"]); ok && flag >= 0 && flag <= math.MaxUint32 && math.Trunc(flag) == flag {
|
||||
fix = "NO_FIX"
|
||||
if uint32(flag)&2 != 0 {
|
||||
fix = "FIXED"
|
||||
}
|
||||
}
|
||||
}
|
||||
return fix, coordinate
|
||||
}
|
||||
|
||||
type realtimeFrameReference struct {
|
||||
VIN, Protocol, EventID string
|
||||
ReceivedAt time.Time
|
||||
Hydrogen, Location bool
|
||||
}
|
||||
|
||||
func realtimeReferenceKey(vin, protocol, eventID string) string {
|
||||
return vin + "\x00" + protocol + "\x00" + eventID
|
||||
}
|
||||
|
||||
// An optional history outage must not make previously available realtime fields
|
||||
// disappear. A shared deadline bounds enrichment even for the 2,000 VIN batch.
|
||||
func (r *MySQLRepository) enrichRealtimeLiveData(ctx context.Context, vins []string, points map[string]RealtimeVehiclePoint, now time.Time) error {
|
||||
for vin, point := range points {
|
||||
point.LiveHydrogen = missingRealtimeHydrogen("")
|
||||
points[vin] = point
|
||||
}
|
||||
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
if err := r.loadRealtimeLiveData(bounded, vins, points, now); err != nil {
|
||||
log.Printf("openplatform realtime enrichment unavailable: type=%T reason=%s", err, realtimeEnrichmentErrorReason(err))
|
||||
// Keep successfully proven raw-frame values, and mark every unresolved
|
||||
// field missing/unknown rather than classifying a storage error unsupported.
|
||||
for vin, point := range points {
|
||||
if point.LiveHydrogen.HydrogenRecordTime == nil {
|
||||
point.LiveHydrogen = missingRealtimeHydrogen("")
|
||||
}
|
||||
points[vin] = point
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) loadRealtimeLiveData(ctx context.Context, vins []string, points map[string]RealtimeVehiclePoint, now time.Time) error {
|
||||
for vin, point := range points {
|
||||
point.LiveHydrogen = missingRealtimeHydrogen(point.Protocol)
|
||||
points[vin] = point
|
||||
}
|
||||
// Repositories used without history retain compatible old fields and explicit
|
||||
// missing statuses; merged MySQL JSON is never used as a fallback.
|
||||
if r.tdengine == nil || r.tdDatabase == "" {
|
||||
return fmt.Errorf("realtime history is not configured")
|
||||
}
|
||||
refs := map[string]realtimeFrameReference{}
|
||||
for vin, point := range points {
|
||||
if point.LocationEventID != "" && !point.LocationReceivedAt.IsZero() {
|
||||
ref := realtimeFrameReference{VIN: vin, Protocol: point.Protocol, EventID: point.LocationEventID, ReceivedAt: point.LocationReceivedAt, Location: true}
|
||||
refs[realtimeReferenceKey(vin, ref.Protocol, ref.EventID)] = ref
|
||||
}
|
||||
}
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||
args := make([]any, len(vins))
|
||||
for i, vin := range vins {
|
||||
args[i] = vin
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `SELECT vin,protocol,event_id,received_at FROM vehicle_realtime_snapshot WHERE BINARY vin IN (`+placeholders+`) AND protocol='GB32960'`, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var ref realtimeFrameReference
|
||||
var received sql.NullTime
|
||||
var eventID sql.NullString
|
||||
if err := rows.Scan(&ref.VIN, &ref.Protocol, &eventID, &received); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
point, exists := points[ref.VIN]
|
||||
if !exists {
|
||||
continue
|
||||
} // preserve existing realtime row selection contract
|
||||
point.LiveHydrogen = missingRealtimeHydrogen("GB32960")
|
||||
points[ref.VIN] = point
|
||||
if !received.Valid || !eventID.Valid || eventID.String == "" {
|
||||
continue
|
||||
}
|
||||
ref.EventID, ref.ReceivedAt, ref.Hydrogen = eventID.String, received.Time, true
|
||||
key := realtimeReferenceKey(ref.VIN, ref.Protocol, ref.EventID)
|
||||
if prior, ok := refs[key]; ok {
|
||||
ref.Location = prior.Location
|
||||
}
|
||||
refs[key] = ref
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
ordered := make([]realtimeFrameReference, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
ordered = append(ordered, ref)
|
||||
}
|
||||
|
||||
// At most two references per requested VIN. Batches bound SQL size; primary
|
||||
// timestamp and VIN tag filters prevent unbounded history scans.
|
||||
frames, loadErr := r.loadRealtimeRawFrameBatches(ctx, ordered)
|
||||
for _, frame := range frames {
|
||||
ref, exists := refs[realtimeReferenceKey(frame.VIN, frame.Protocol, frame.EventID)]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
point, exists := points[frame.VIN]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if ref.Hydrogen {
|
||||
var at time.Time
|
||||
if frame.EventMS.Valid && frame.EventMS.Int64 > 0 {
|
||||
at = time.UnixMilli(frame.EventMS.Int64)
|
||||
}
|
||||
point.LiveHydrogen = realtimeHydrogenFromFrame(frame.Parsed.String, at, now)
|
||||
}
|
||||
if ref.Location {
|
||||
point.GPSFixStatus, point.CoordinateSystem = realtimeGPSFromFrame(frame.Protocol, frame.Parsed.String)
|
||||
}
|
||||
points[frame.VIN] = point
|
||||
}
|
||||
if loadErr == nil && ctx.Err() == nil {
|
||||
var missing []realtimeFrameReference
|
||||
for _, ref := range refs {
|
||||
if ref.Hydrogen && points[ref.VIN].LiveHydrogen.HydrogenDataStatus == "MISSING" {
|
||||
missing = append(missing, ref)
|
||||
}
|
||||
}
|
||||
fallback, err := r.loadRealtimeRawFrameBatchesWithQuery(ctx, missing, realtimeHydrogenFallbackQuery, 1)
|
||||
applyRealtimeHydrogenFallback(points, missing, fallback, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return loadErr
|
||||
}
|
||||
|
||||
// Only explicitly missing hydrogen can be filled. In particular, the newest
|
||||
// invalid measurement must never be hidden by an older valid reading.
|
||||
func applyRealtimeHydrogenFallback(points map[string]RealtimeVehiclePoint, refs []realtimeFrameReference, frames []realtimeRawFrame, now time.Time) {
|
||||
allowed := make(map[string]bool, len(refs))
|
||||
for _, ref := range refs {
|
||||
allowed[ref.VIN] = true
|
||||
}
|
||||
// SQL orders by event_time, not arrival time: delayed retransmissions must not
|
||||
// displace newer measurements. Keep that rule explicit when processing rows.
|
||||
sort.SliceStable(frames, func(i, j int) bool { return frames[i].EventMS.Int64 > frames[j].EventMS.Int64 })
|
||||
for _, frame := range frames {
|
||||
if !allowed[frame.VIN] || frame.Protocol != "GB32960" {
|
||||
continue
|
||||
}
|
||||
point, exists := points[frame.VIN]
|
||||
if !exists || point.LiveHydrogen.HydrogenDataStatus != "MISSING" {
|
||||
continue
|
||||
}
|
||||
var at time.Time
|
||||
if frame.EventMS.Valid && frame.EventMS.Int64 > 0 {
|
||||
at = time.UnixMilli(frame.EventMS.Int64)
|
||||
}
|
||||
data := realtimeHydrogenFromFrame(frame.Parsed.String, at, now)
|
||||
if data.HydrogenDataStatus == "MISSING" {
|
||||
continue
|
||||
} // LIKE is a prefilter, never a JSON parser.
|
||||
point.LiveHydrogen = data
|
||||
points[frame.VIN] = point
|
||||
}
|
||||
}
|
||||
|
||||
type realtimeRawFrame struct {
|
||||
VIN, Protocol, EventID string
|
||||
EventMS sql.NullInt64
|
||||
Parsed sql.NullString
|
||||
}
|
||||
|
||||
type realtimeRawBatchResult struct {
|
||||
frames []realtimeRawFrame
|
||||
err error
|
||||
}
|
||||
|
||||
// Four workers cap pressure on history storage while avoiding serial latency
|
||||
// across a large authorized fleet. Only the caller writes the result map.
|
||||
func (r *MySQLRepository) loadRealtimeRawFrameBatches(ctx context.Context, refs []realtimeFrameReference) ([]realtimeRawFrame, error) {
|
||||
return r.loadRealtimeRawFrameBatchesWithQuery(ctx, refs, realtimeRawFrameQuery, 100)
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) loadRealtimeRawFrameBatchesWithQuery(ctx context.Context, refs []realtimeFrameReference, buildQuery func(string, []realtimeFrameReference) (string, error), maxBatchSize int) ([]realtimeRawFrame, error) {
|
||||
batches := realtimeRawReferenceBatches(refs)
|
||||
if maxBatchSize == 1 {
|
||||
var singleVINBatches [][]realtimeFrameReference
|
||||
for _, batch := range batches {
|
||||
for i := range batch {
|
||||
singleVINBatches = append(singleVINBatches, batch[i:i+1])
|
||||
}
|
||||
}
|
||||
batches = singleVINBatches
|
||||
}
|
||||
batchCount := len(batches)
|
||||
if batchCount == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
jobs := make(chan string, batchCount)
|
||||
results := make(chan realtimeRawBatchResult, batchCount)
|
||||
for _, batch := range batches {
|
||||
query, err := buildQuery(r.tdDatabase, batch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
jobs <- query
|
||||
}
|
||||
close(jobs)
|
||||
workers := 4
|
||||
if batchCount < workers {
|
||||
workers = batchCount
|
||||
}
|
||||
for worker := 0; worker < workers; worker++ {
|
||||
go func() {
|
||||
for query := range jobs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
frames, err := r.loadRealtimeRawFrameBatch(ctx, query)
|
||||
results <- realtimeRawBatchResult{frames: frames, err: err}
|
||||
}
|
||||
}()
|
||||
}
|
||||
var frames []realtimeRawFrame
|
||||
var firstErr error
|
||||
for batch := 0; batch < batchCount; batch++ {
|
||||
var result realtimeRawBatchResult
|
||||
select {
|
||||
case result = <-results:
|
||||
case <-ctx.Done():
|
||||
return frames, ctx.Err()
|
||||
}
|
||||
if firstErr == nil && result.err != nil {
|
||||
firstErr = result.err
|
||||
}
|
||||
frames = append(frames, result.frames...)
|
||||
}
|
||||
return frames, firstErr
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) loadRealtimeRawFrameBatch(ctx context.Context, query string) ([]realtimeRawFrame, error) {
|
||||
rows, err := r.tdengine.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var frames []realtimeRawFrame
|
||||
for rows.Next() {
|
||||
var frame realtimeRawFrame
|
||||
if err := rows.Scan(&frame.VIN, &frame.Protocol, &frame.EventID, &frame.EventMS, &frame.Parsed); err != nil {
|
||||
return frames, err
|
||||
}
|
||||
frames = append(frames, frame)
|
||||
}
|
||||
return frames, rows.Err()
|
||||
}
|
||||
|
||||
// Keep historical outliers away from current data: one query never spans more
|
||||
// than five minutes of arrival time, even when a fleet includes months-old rows.
|
||||
// Newest batches run first so the shared deadline favors currently active cars.
|
||||
func realtimeRawReferenceBatches(refs []realtimeFrameReference) [][]realtimeFrameReference {
|
||||
ordered := append([]realtimeFrameReference(nil), refs...)
|
||||
sort.Slice(ordered, func(i, j int) bool { return ordered[i].ReceivedAt.After(ordered[j].ReceivedAt) })
|
||||
var batches [][]realtimeFrameReference
|
||||
for start := 0; start < len(ordered); {
|
||||
end := start + 1
|
||||
for end < len(ordered) && end-start < 100 && ordered[start].ReceivedAt.Sub(ordered[end].ReceivedAt) <= 5*time.Minute {
|
||||
end++
|
||||
}
|
||||
batches = append(batches, ordered[start:end])
|
||||
start = end
|
||||
}
|
||||
return batches
|
||||
}
|
||||
|
||||
func realtimeRawFrameQuery(database string, refs []realtimeFrameReference) (string, error) {
|
||||
if !validTDIdentifier(database) || len(refs) == 0 || len(refs) > 100 {
|
||||
return "", fmt.Errorf("invalid realtime raw-frame query")
|
||||
}
|
||||
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||
clauses, vins, timestamps := make([]string, 0, len(refs)), make([]string, 0, len(refs)), make([]string, 0, len(refs))
|
||||
min, max := refs[0].ReceivedAt.UnixMilli(), refs[0].ReceivedAt.UnixMilli()
|
||||
for _, ref := range refs {
|
||||
at := ref.ReceivedAt.UnixMilli()
|
||||
if at < min {
|
||||
min = at
|
||||
}
|
||||
if at > max {
|
||||
max = at
|
||||
}
|
||||
vins = append(vins, quote(ref.VIN))
|
||||
timestamps = append(timestamps, strconv.FormatInt(at, 10))
|
||||
clauses = append(clauses, "(ts="+strconv.FormatInt(at, 10)+" AND vin="+quote(ref.VIN)+" AND protocol="+quote(ref.Protocol)+" AND event_id="+quote(ref.EventID)+")")
|
||||
}
|
||||
return `SELECT vin,protocol,event_id,CAST(event_time AS BIGINT),parsed_json FROM ` + database + `.raw_frames WHERE ts>=` + strconv.FormatInt(min, 10) + ` AND ts<=` + strconv.FormatInt(max, 10) + ` AND ts IN (` + strings.Join(timestamps, ",") + `) AND vin IN (` + strings.Join(vins, ",") + `) AND parse_status='OK' AND (` + strings.Join(clauses, " OR ") + `) LIMIT ` + strconv.Itoa(len(refs)*2), nil
|
||||
}
|
||||
|
||||
// Search only five minutes before one snapshot arrival. TDengine applies LIMIT
|
||||
// globally even with PARTITION BY, so each fallback query must contain one VIN.
|
||||
// The shared worker pool still bounds concurrency to four and uses one deadline.
|
||||
func realtimeHydrogenFallbackQuery(database string, refs []realtimeFrameReference) (string, error) {
|
||||
if !validTDIdentifier(database) || len(refs) != 1 {
|
||||
return "", fmt.Errorf("invalid realtime hydrogen fallback query")
|
||||
}
|
||||
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||
min, max := refs[0].ReceivedAt.Add(-5*time.Minute).UnixMilli(), refs[0].ReceivedAt.UnixMilli()
|
||||
var clauses, vins, fields []string
|
||||
for _, ref := range refs {
|
||||
start, end := ref.ReceivedAt.Add(-5*time.Minute).UnixMilli(), ref.ReceivedAt.UnixMilli()
|
||||
if start < min {
|
||||
min = start
|
||||
}
|
||||
if end > max {
|
||||
max = end
|
||||
}
|
||||
vins = append(vins, quote(ref.VIN))
|
||||
clauses = append(clauses, "(vin="+quote(ref.VIN)+" AND ts>="+strconv.FormatInt(start, 10)+" AND ts<="+strconv.FormatInt(end, 10)+")")
|
||||
}
|
||||
for _, key := range hydrogenMassFields {
|
||||
fields = append(fields, "parsed_json LIKE "+quote("%\""+key+"\":%"))
|
||||
}
|
||||
return `SELECT vin,protocol,event_id,CAST(event_time AS BIGINT),parsed_json FROM ` + database + `.raw_frames WHERE protocol='GB32960' AND ts>=` + strconv.FormatInt(min, 10) + ` AND ts<=` + strconv.FormatInt(max, 10) + ` AND vin IN (` + strings.Join(vins, ",") + `) AND parse_status='OK' AND (` + strings.Join(clauses, " OR ") + `) AND (` + strings.Join(fields, " OR ") + `) ORDER BY event_time DESC,ts DESC LIMIT 1`, nil
|
||||
}
|
||||
|
||||
var realtimeErrorQuotedText = regexp.MustCompile(`'[^']*'|"[^"]*"`)
|
||||
var realtimeErrorVIN = regexp.MustCompile(`\b[A-Z0-9]{12,32}\b`)
|
||||
|
||||
// Driver errors sometimes append the whole query. Keep the failure reason for
|
||||
// operations without writing fleet identifiers or SQL literals to the log.
|
||||
func realtimeEnrichmentErrorReason(err error) string {
|
||||
reason := err.Error()
|
||||
upper := strings.ToUpper(reason)
|
||||
for _, keyword := range []string{"SELECT ", "INSERT ", "UPDATE ", "DELETE "} {
|
||||
if index := strings.Index(upper, keyword); index >= 0 {
|
||||
reason = reason[:index] + "[SQL omitted]"
|
||||
break
|
||||
}
|
||||
}
|
||||
reason = realtimeErrorQuotedText.ReplaceAllString(reason, "[quoted value omitted]")
|
||||
reason = realtimeErrorVIN.ReplaceAllString(reason, "[VIN omitted]")
|
||||
reason = strings.Join(strings.Fields(reason), " ")
|
||||
if len(reason) > 300 {
|
||||
reason = reason[:300] + "..."
|
||||
}
|
||||
return reason
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestRealtimeHydrogenQualityAndIndependentNulls(t *testing.T) {
|
||||
now := time.Date(2026, 9, 8, 12, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name, parsed, status, kgStatus string
|
||||
age time.Duration
|
||||
kg *float64
|
||||
}{
|
||||
{"measured", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":18.6}`, "PARTIAL", "NORMAL", 0, realLiveFloat(18.6)},
|
||||
{"true zero", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`, "PARTIAL", "NORMAL", 0, realLiveFloat(0)},
|
||||
{"stale", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":18.6}`, "STALE", "STALE", 301 * time.Second, realLiveFloat(18.6)},
|
||||
{"fresh boundary", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":18.6}`, "PARTIAL", "NORMAL", 300 * time.Second, realLiveFloat(18.6)},
|
||||
{"SOC is not hydrogen", `{"gb32960.vehicle.soc_percent":60}`, "MISSING", "MISSING", 0, nil},
|
||||
{"invalid", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":-1}`, "INVALID", "INVALID", 0, nil},
|
||||
{"protocol invalid", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null}`, "INVALID", "INVALID", 0, nil},
|
||||
{"nonfinite", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":"NaN"}`, "INVALID", "INVALID", 0, nil},
|
||||
{"future clock", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":1}`, "INVALID", "INVALID", -2 * time.Minute, nil},
|
||||
{"invalid alias must not fallback", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null,"gd_fc_vehicle_hydrogen_mass_kg":10}`, "INVALID", "INVALID", 0, nil},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := realtimeHydrogenFromFrame(tc.parsed, now.Add(-tc.age), now)
|
||||
if got.HydrogenDataStatus != tc.status || got.RemainingHydrogenKgStatus != tc.kgStatus || got.RemainingHydrogenPercent != nil || got.RemainingHydrogenPercentStatus != "UNSUPPORTED" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if (tc.kg == nil) != (got.RemainingHydrogenKg == nil) || tc.kg != nil && math.Abs(*got.RemainingHydrogenKg-*tc.kg) > 0.0001 {
|
||||
t.Fatalf("kg=%v", got.RemainingHydrogenKg)
|
||||
}
|
||||
data, err := json.Marshal(got)
|
||||
if err != nil || !strings.Contains(string(data), `"remainingHydrogenPercent":null`) {
|
||||
t.Fatalf("json=%s err=%v", data, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if got := missingRealtimeHydrogen("JT808"); got.HydrogenDataStatus != "UNSUPPORTED" || got.RemainingHydrogenKg != nil {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
func realLiveFloat(v float64) *float64 { return &v }
|
||||
|
||||
func TestRealtimeGPSFlagsIndependentOfFreshness(t *testing.T) {
|
||||
for _, tc := range []struct{ protocol, parsed, fix, coordinate string }{
|
||||
{"GB32960", `{"gb32960.position.position_status":0}`, "FIXED", "UNKNOWN"},
|
||||
{"GB32960", `{"gb32960.position.position_status":1}`, "NO_FIX", "UNKNOWN"},
|
||||
{"GB32960", `{"gb32960.position.position_status":254}`, "UNKNOWN", "UNKNOWN"},
|
||||
{"GB32960", `{"gb32960.position.position_status":0,"gb32960.position.coordinate_system":2}`, "FIXED", "GCJ02"},
|
||||
{"GB32960", `{"gb32960.position.position_status":0,"gb32960.position.coordinate_system":3}`, "FIXED", "UNKNOWN"},
|
||||
{"JT808", `{"jt808.location.status_flag":2}`, "FIXED", "UNKNOWN"},
|
||||
{"JT808", `{"jt808.location.status_flag":0}`, "NO_FIX", "UNKNOWN"},
|
||||
{"YUTONG_MQTT", `{"latitude":23,"longitude":113}`, "UNKNOWN", "UNKNOWN"},
|
||||
} {
|
||||
fix, coordinate := realtimeGPSFromFrame(tc.protocol, tc.parsed)
|
||||
if fix != tc.fix || coordinate != tc.coordinate {
|
||||
t.Fatalf("%+v got %s %s", tc, fix, coordinate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeRawFrameEnrichmentMatchesLocationAndHydrogenSeparately(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
td, tdmock, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
now := time.Date(2026, 9, 8, 12, 0, 0, 0, time.UTC)
|
||||
vin := "LTEST32960VIN0001"
|
||||
points := map[string]RealtimeVehiclePoint{vin: {VIN: vin, Protocol: "JT808", LocationEventID: "loc", LocationReceivedAt: now, LocationObservedAt: now.Add(-time.Hour)}}
|
||||
mock.ExpectQuery("SELECT vin,protocol,event_id,received_at FROM vehicle_realtime_snapshot").WithArgs(vin).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "received_at"}).AddRow(vin, "GB32960", "hydrogen", now))
|
||||
tdmock.ExpectQuery("SELECT vin,protocol,event_id,CAST\\(event_time AS BIGINT\\),parsed_json FROM vehicle_ts.raw_frames WHERE ts>=.*AND vin IN .*event_id='.*LIMIT 4").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}).
|
||||
AddRow(vin, "GB32960", "hydrogen", now.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`).
|
||||
AddRow(vin, "JT808", "loc", now.Add(-time.Hour).UnixMilli(), `{"jt808.location.status_flag":2}`).
|
||||
AddRow("OTHER", "GB32960", "hydrogen", now.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":123}`))
|
||||
err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := points[vin]
|
||||
if got.LiveHydrogen.RemainingHydrogenKg == nil || *got.LiveHydrogen.RemainingHydrogenKg != 0 || got.GPSFixStatus != "FIXED" || got.LiveHydrogen.HydrogenDataStatus != "PARTIAL" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tdmock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeRawFrameLagIsMissingNotError(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
td, tdmock, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
now := time.Now()
|
||||
vin := "LTEST32960VIN0001"
|
||||
points := map[string]RealtimeVehiclePoint{vin: {Protocol: "GB32960"}}
|
||||
mock.ExpectQuery("SELECT vin,protocol,event_id,received_at").WithArgs(vin).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "received_at"}).AddRow(vin, "GB32960", "new", now))
|
||||
tdmock.ExpectQuery("SELECT vin,protocol,event_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}))
|
||||
tdmock.ExpectQuery("SELECT vin,protocol,event_id.*parsed_json LIKE").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}))
|
||||
if err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if points[vin].LiveHydrogen.HydrogenDataStatus != "MISSING" {
|
||||
t.Fatal(points[vin])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tdmock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeRawFrameQueryBoundsAndIdentity(t *testing.T) {
|
||||
at := time.UnixMilli(10000)
|
||||
query, err := realtimeRawFrameQuery("vehicle_ts", []realtimeFrameReference{{VIN: "VIN'1", Protocol: "GB32960", EventID: "id'1", ReceivedAt: at}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"ts>=10000 AND ts<=10000", "ts IN (10000)", "vin='VIN''1'", "protocol='GB32960'", "event_id='id''1'", "LIMIT 2"} {
|
||||
if !strings.Contains(query, want) {
|
||||
t.Fatalf("missing %s in %s", want, query)
|
||||
}
|
||||
}
|
||||
if _, err := realtimeRawFrameQuery("bad;sql", []realtimeFrameReference{{}}); err == nil {
|
||||
t.Fatal("accepted database injection")
|
||||
}
|
||||
if _, err := realtimeRawFrameQuery("vehicle_ts", make([]realtimeFrameReference, 101)); err == nil {
|
||||
t.Fatal("accepted unbounded batch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeHistoryFailurePreservesOldFields(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
td, tdmock, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
now := time.Now()
|
||||
vin := "LTEST32960VIN0001"
|
||||
points := map[string]RealtimeVehiclePoint{vin: {Protocol: "JT808", Longitude: 113, Latitude: 23, Online: true}}
|
||||
mock.ExpectQuery("SELECT vin,protocol,event_id,received_at").WithArgs(vin).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "received_at"}).AddRow(vin, "GB32960", "new", now))
|
||||
tdmock.ExpectQuery("SELECT vin,protocol,event_id").WillReturnError(context.DeadlineExceeded)
|
||||
if err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := points[vin]
|
||||
if got.LiveHydrogen.HydrogenDataStatus != "MISSING" || !got.Online || got.Longitude != 113 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tdmock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeServiceNoFixAndHistoricalFixed(t *testing.T) {
|
||||
now := time.Now()
|
||||
vin := "LTEST32960VIN0001"
|
||||
for _, fix := range []string{"FIXED", "NO_FIX", "UNKNOWN"} {
|
||||
t.Run(fix, func(t *testing.T) {
|
||||
repository := &fakeRepository{app: AppCredential{ID: 9}, vehicles: map[string]AuthorizedVehicle{"测试车牌": {VIN: vin, Plate: "测试车牌"}}, realtime: map[string]RealtimeVehiclePoint{vin: {VIN: vin, Protocol: "GB32960", Longitude: 113, Latitude: 23, ObservedAt: now.Add(-96 * time.Hour), LocationObservedAt: now.Add(-96 * time.Hour), GPSFixStatus: fix}}}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return now }
|
||||
result, err := service.QueryRealtimeVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "test", RealtimeVehicleRequest{PlateNumbers: []string{"测试车牌"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := result[0]
|
||||
if got.Online || got.MotionStatus != "offline" || got.GPSFixStatus != fix || got.LocationRecordTime == nil {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if fix == "NO_FIX" {
|
||||
if got.LocationAvailable || got.Longitude != nil || got.Latitude != nil {
|
||||
t.Fatalf("invalid position exposed: %+v", got)
|
||||
}
|
||||
} else if !got.LocationAvailable {
|
||||
t.Fatalf("historical position discarded: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeBatchesReadConcurrentlyWithinSharedDeadline(t *testing.T) {
|
||||
td, mock, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
mock.MatchExpectationsInOrder(false)
|
||||
refs := make([]realtimeFrameReference, 400)
|
||||
for i := range refs {
|
||||
refs[i] = realtimeFrameReference{VIN: "VIN", Protocol: "GB32960", EventID: "event", ReceivedAt: time.UnixMilli(int64(i))}
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
mock.ExpectQuery("SELECT vin,protocol,event_id").WillDelayFor(100 * time.Millisecond).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}))
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 350*time.Millisecond)
|
||||
defer cancel()
|
||||
repository := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||
if _, err := repository.loadRealtimeRawFrameBatches(ctx, refs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeBatchesSeparateHistoricalOutliersAndPreferNewest(t *testing.T) {
|
||||
now := time.Now()
|
||||
refs := []realtimeFrameReference{{EventID: "old", ReceivedAt: now.Add(-90 * 24 * time.Hour)}, {EventID: "recent", ReceivedAt: now.Add(-time.Minute)}, {EventID: "newest", ReceivedAt: now}, {EventID: "older", ReceivedAt: now.Add(-6 * time.Minute)}}
|
||||
batches := realtimeRawReferenceBatches(refs)
|
||||
if len(batches) != 3 || len(batches[0]) != 2 || batches[0][0].EventID != "newest" || batches[1][0].EventID != "older" || batches[2][0].EventID != "old" {
|
||||
t.Fatalf("%+v", batches)
|
||||
}
|
||||
if refs[0].EventID != "old" {
|
||||
t.Fatal("mutated input ordering")
|
||||
}
|
||||
refs = make([]realtimeFrameReference, 201)
|
||||
for i := range refs {
|
||||
refs[i].ReceivedAt = now
|
||||
}
|
||||
batches = realtimeRawReferenceBatches(refs)
|
||||
if len(batches) != 3 || len(batches[0]) != 100 || len(batches[1]) != 100 || len(batches[2]) != 1 {
|
||||
t.Fatalf("unexpected count bounds %+v", batches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeMissingHydrogenFallsBackToActualFieldSample(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
td, tdmock, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
now := time.Now()
|
||||
vin := "LTEST32960VIN0001"
|
||||
points := map[string]RealtimeVehiclePoint{vin: {Protocol: "GB32960"}}
|
||||
mock.ExpectQuery("SELECT vin,protocol,event_id,received_at").WithArgs(vin).WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "received_at"}).AddRow(vin, "GB32960", "new-location-only", now))
|
||||
columns := []string{"vin", "protocol", "event_id", "event_time", "parsed_json"}
|
||||
tdmock.ExpectQuery("SELECT vin,protocol,event_id.*ts IN").WillReturnRows(sqlmock.NewRows(columns).AddRow(vin, "GB32960", "new-location-only", now.UnixMilli(), `{"gb32960.vehicle.speed_kmh":20}`))
|
||||
sampleAt := now.Add(-90 * time.Second)
|
||||
tdmock.ExpectQuery("SELECT vin,protocol,event_id.*parsed_json LIKE.*ORDER BY event_time DESC,ts DESC LIMIT 1").WillReturnRows(sqlmock.NewRows(columns).AddRow(vin, "GB32960", "older-hydrogen", sampleAt.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`))
|
||||
if err := NewMySQLRepository(db).WithTDengine(td, "vehicle_ts").enrichRealtimeLiveData(context.Background(), []string{vin}, points, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := points[vin].LiveHydrogen
|
||||
if got.HydrogenDataStatus != "PARTIAL" || got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != 0 || got.HydrogenRecordTime == nil || *got.HydrogenValueSource != "REPORTED" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
gotAt, err := time.Parse(time.RFC3339Nano, *got.HydrogenRecordTime)
|
||||
if err != nil || gotAt.UnixMilli() != sampleAt.UnixMilli() {
|
||||
t.Fatalf("time=%s err=%v", *got.HydrogenRecordTime, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tdmock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeHydrogenFallbackKeepsNewestInvalidAndSourceIdentity(t *testing.T) {
|
||||
now := time.Now()
|
||||
vin := "LTEST32960VIN0001"
|
||||
point := RealtimeVehiclePoint{LiveHydrogen: missingRealtimeHydrogen("GB32960")}
|
||||
points := map[string]RealtimeVehiclePoint{vin: point}
|
||||
refs := []realtimeFrameReference{{VIN: vin, Protocol: "GB32960", Hydrogen: true, ReceivedAt: now}}
|
||||
raw := func(vin, protocol, parsed string, at time.Time) realtimeRawFrame {
|
||||
return realtimeRawFrame{VIN: vin, Protocol: protocol, Parsed: sql.NullString{String: parsed, Valid: true}, EventMS: sql.NullInt64{Int64: at.UnixMilli(), Valid: true}}
|
||||
}
|
||||
frames := []realtimeRawFrame{
|
||||
raw(vin, "GB32960", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":4}`, now.Add(-2*time.Minute)),
|
||||
raw(vin, "GB32960", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null}`, now.Add(-time.Minute)),
|
||||
raw("OTHER", "GB32960", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":100}`, now),
|
||||
raw(vin, "JT808", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":100}`, now),
|
||||
}
|
||||
applyRealtimeHydrogenFallback(points, refs, frames, now)
|
||||
if got := points[vin].LiveHydrogen; got.HydrogenDataStatus != "INVALID" || got.RemainingHydrogenKg != nil {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
applyRealtimeHydrogenFallback(points, refs, []realtimeRawFrame{raw(vin, "GB32960", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":9}`, now)}, now)
|
||||
if points[vin].LiveHydrogen.HydrogenDataStatus != "INVALID" {
|
||||
t.Fatal("overwrote existing invalid measurement")
|
||||
}
|
||||
points[vin] = point
|
||||
applyRealtimeHydrogenFallback(points, refs, []realtimeRawFrame{raw(vin, "GB32960", `{"note":"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg"}`, now)}, now)
|
||||
if points[vin].LiveHydrogen.HydrogenDataStatus != "MISSING" {
|
||||
t.Fatal("accepted text instead of actual field")
|
||||
}
|
||||
points[vin] = point
|
||||
applyRealtimeHydrogenFallback(points, refs, []realtimeRawFrame{raw(vin, "GB32960", `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":4}`, now.Add(-24*time.Hour))}, now)
|
||||
if points[vin].LiveHydrogen.HydrogenDataStatus != "STALE" {
|
||||
t.Fatal("historical sample incorrectly fresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeHydrogenFallbackQueryLimitsEachVINWindow(t *testing.T) {
|
||||
at := time.UnixMilli(1000000)
|
||||
query, err := realtimeHydrogenFallbackQuery("vehicle_ts", []realtimeFrameReference{{VIN: "VIN'1", ReceivedAt: at}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"protocol='GB32960'", "ts>=700000 AND ts<=1000000", "vin='VIN''1' AND ts>=700000 AND ts<=1000000", `parsed_json LIKE '%"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":%'`, "ORDER BY event_time DESC,ts DESC LIMIT 1"} {
|
||||
if !strings.Contains(query, want) {
|
||||
t.Fatalf("missing %s in %s", want, query)
|
||||
}
|
||||
}
|
||||
if strings.Contains(query, "hydrogen_mass_kg IS NOT NULL") {
|
||||
t.Fatal("must include explicit invalid readings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeHydrogenFallbackQueriesEachVINSeparately(t *testing.T) {
|
||||
td, mock, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
mock.MatchExpectationsInOrder(false)
|
||||
now := time.Now()
|
||||
refs := []realtimeFrameReference{{VIN: "V1", ReceivedAt: now}, {VIN: "V2", ReceivedAt: now}, {VIN: "V3", ReceivedAt: now}}
|
||||
for _, ref := range refs {
|
||||
mock.ExpectQuery("SELECT vin,protocol,event_id.*vin IN \\('" + ref.VIN + "'\\).*ORDER BY event_time DESC,ts DESC LIMIT 1").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "event_time", "parsed_json"}).AddRow(ref.VIN, "GB32960", "sample", now.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":4}`))
|
||||
}
|
||||
repository := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||
frames, err := repository.loadRealtimeRawFrameBatchesWithQuery(context.Background(), refs, realtimeHydrogenFallbackQuery, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unique := map[string]bool{}
|
||||
for _, frame := range frames {
|
||||
unique[frame.VIN] = true
|
||||
}
|
||||
if len(frames) != 3 || len(unique) != 3 {
|
||||
t.Fatalf("frames=%+v", frames)
|
||||
}
|
||||
if _, err := realtimeHydrogenFallbackQuery("vehicle_ts", refs); err == nil {
|
||||
t.Fatal("accepted multi-VIN LIMIT 1 fallback")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealtimeEnrichmentErrorReasonOmitsSQLAndVehicleIdentifiers(t *testing.T) {
|
||||
for _, message := range []string{"driver failed near 'LTEST32960VIN0001': SELECT parsed_json FROM raw_frames WHERE vin='LTEST32960VIN0001'", "invalid LTEST32960VIN0001 \"private value\"", "SELECT vin FROM raw_frames"} {
|
||||
got := realtimeEnrichmentErrorReason(fmt.Errorf("%s", message))
|
||||
if strings.Contains(got, "LTEST32960VIN0001") || strings.Contains(got, "private value") || strings.Contains(got, "SELECT") {
|
||||
t.Fatalf("leaked reason %s", got)
|
||||
}
|
||||
}
|
||||
if got := realtimeEnrichmentErrorReason(context.DeadlineExceeded); got != "context deadline exceeded" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
@@ -284,13 +284,14 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
||||
results := make([]RealtimeVehicleResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData}
|
||||
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData, RealtimeHydrogenData: missingRealtimeHydrogen(""), GPSFixStatus: "UNKNOWN", CoordinateSystem: "UNKNOWN"}
|
||||
if point, ok := points[vehicle.VIN]; ok {
|
||||
difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds())
|
||||
if difference < 0 {
|
||||
difference = 0
|
||||
}
|
||||
item.Protocol = externalMileageProtocol(point.Protocol)
|
||||
applyRealtimeLiveData(&item, point, s.location)
|
||||
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
||||
item.TimeDifferenceSeconds = &difference
|
||||
item.Online = point.Online
|
||||
@@ -304,7 +305,7 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
||||
speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
|
||||
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
|
||||
item.SOCPercent = point.SOCPercent
|
||||
if validCoordinate(point.Longitude, point.Latitude) {
|
||||
if item.GPSFixStatus != "NO_FIX" && validCoordinate(point.Longitude, point.Latitude) {
|
||||
longitude, latitude := point.Longitude, point.Latitude
|
||||
item.Longitude, item.Latitude = &longitude, &latitude
|
||||
item.LocationAvailable = true
|
||||
@@ -387,11 +388,13 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
|
||||
results := make([]HydrogenResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := HydrogenResult{PlateNumber: plate, Date: date, Status: StatusNoData}
|
||||
item := HydrogenResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
|
||||
// Sampling sufficiency is decided by the producer and persisted in
|
||||
// quality_status. Imported refuelling-ledger rows can be authoritative
|
||||
// with one transaction, while pressure-derived rows require two samples.
|
||||
if value, ok := values[vehicle.VIN]; ok {
|
||||
item.StatisticsStartTime, item.StatisticsEndTime = hydrogenStatisticsInterval(value.EvidenceJSON)
|
||||
item.UpdatedAt = validStatisticsTimestamp(value.UpdatedAt)
|
||||
item.CalculationPhase = value.CalculationPhase
|
||||
item.AlgorithmVersion = value.AlgorithmVersion
|
||||
item.QualityStatus = value.QualityStatus
|
||||
@@ -711,6 +714,12 @@ func (s *Service) authorize(ctx context.Context, rawKey string, plates []string,
|
||||
if len(plates) > 0 && len(vehicles) != len(plates) {
|
||||
return app, nil, ErrForbidden
|
||||
}
|
||||
for _, plate := range plates {
|
||||
vehicle, ok := vehicles[plate]
|
||||
if !ok || strings.TrimSpace(vehicle.VIN) == "" || (vehicle.Plate != "" && vehicle.Plate != plate) {
|
||||
return app, nil, ErrForbidden
|
||||
}
|
||||
}
|
||||
return app, vehicles, nil
|
||||
}
|
||||
|
||||
@@ -991,6 +1000,9 @@ func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map
|
||||
}
|
||||
|
||||
func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage float64) {
|
||||
if value.Date == item.Date {
|
||||
item.StatisticsStartTime, item.StatisticsEndTime = validatedStatisticsInterval(value.StatisticsStartTime, value.DataTime)
|
||||
}
|
||||
item.DailyMileageKm = &dailyMileage
|
||||
totalMileage := value.TotalMileageKm
|
||||
item.TotalMileageKm = &totalMileage
|
||||
|
||||
@@ -261,7 +261,7 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
|
||||
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
||||
},
|
||||
hydrogen: map[string]DailyHydrogen{
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK", CalculationPhase: "PRELIMINARY", AlgorithmVersion: trustedHydrogenAlgorithmVersion},
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK", CalculationPhase: "PRELIMINARY", AlgorithmVersion: trustedHydrogenAlgorithmVersion, EvidenceJSON: `{"lastEventTime":"2026-07-01T11:59:00+08:00"}`, UpdatedAt: "2026-07-01T12:00:00+08:00"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
"LTEST32960VIN0001": {
|
||||
@@ -282,6 +282,9 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
|
||||
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
|
||||
t.Fatalf("hydrogen = %#v", hydrogen)
|
||||
}
|
||||
if hydrogen[0].VIN != "LTEST32960VIN0001" || hydrogen[1].VIN != "LTEST32960VIN0002" || hydrogen[0].StatisticsStartTime != nil || hydrogen[0].StatisticsEndTime == nil || *hydrogen[0].StatisticsEndTime != "2026-07-01T11:59:00+08:00" || hydrogen[0].UpdatedAt == nil || hydrogen[1].UpdatedAt != nil {
|
||||
t.Fatalf("hydrogen identity/timestamps = %#v", hydrogen)
|
||||
}
|
||||
if hydrogen[0].CalculationPhase != "PRELIMINARY" || hydrogen[0].AlgorithmVersion != trustedHydrogenAlgorithmVersion {
|
||||
t.Fatalf("hydrogen calculation metadata = %#v", hydrogen[0])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user