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])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# OneOS 氢能车辆实况接口交付契约
|
||||
|
||||
契约修订日期:2026-09-08。对应 `/api/v1/vehicles/realtime/query`、`/mileage/query`、`/hydrogen-consumption/query` 的增量交付。实际发布版本、上线时间及脱敏联调证据由发布验收记录提供,本文不将开发完成等同于线上验收完成。
|
||||
|
||||
## 兼容性与授权
|
||||
|
||||
认证继续使用 `Authorization: Bearer <appKey>`;成功响应继续包含 `code / message / data / traceId`。原请求参数不变;新字段增量添加。`plateNumbers` 省略或空数组表示全部有效授权车辆,指定名单时按授权校验后查询,消费者必须核对 VIN、车牌和日期,不能只按车牌串接不同车辆。报警、违章、企业名称、Seeker 本地车辆编号均不在本次新增范围。
|
||||
|
||||
完整机器契约见服务 `/open-api/openapi.yaml`(路径以服务文档入口为准),内嵌 HTML 同步说明新增字段。
|
||||
|
||||
## 实时储氢与定位
|
||||
|
||||
| 字段 | 类型/单位 | 本次口径 |
|
||||
| --- | --- | --- |
|
||||
| remainingHydrogenKg | number/null,kg | GB32960 广东燃料电池扩展 0x34 终端全车氢质量直接上报;保留真实零,不汇总单瓶、不由压力推算 |
|
||||
| remainingHydrogenPercent | number/null,% | 当前缺少可信质量容量分母,固定 null,字段级 UNSUPPORTED;不使用 SOC 或当日消耗替代 |
|
||||
| hydrogenRecordTime | RFC 3339/null | 精确匹配 raw_frames 原始同帧采集时间,非合并快照更新时间 |
|
||||
| hydrogenDataStatus | enum | NORMAL/PARTIAL/STALE/MISSING/UNSUPPORTED/INVALID;当前 kg 有效但百分比不支持为 PARTIAL |
|
||||
| remainingHydrogenKgStatus / remainingHydrogenPercentStatus | enum | 分别为 NORMAL/STALE/MISSING/UNSUPPORTED/INVALID,客户端独立判断 |
|
||||
| hydrogenValueSource / hydrogenSourceProtocol | string/null | REPORTED 仅表示终端上报,无法确认终端内部采用测量还是估算;储氢协议独立于位置 protocol |
|
||||
| hydrogenStaleAfterSeconds | integer/null,秒 | GB32960 服务陈旧阈值 300;并非协议规定更新频率 |
|
||||
| hydrogenExpectedIntervalSeconds | integer/null,秒 | 各协议未确认上报周期,当前为 null |
|
||||
| gpsFixStatus | FIXED/NO_FIX/UNKNOWN | 来源为实际位置报文定位位,不根据在线、运动或记录年龄推断 |
|
||||
| locationRecordTime | RFC 3339/null | 位置实际采集时间,可与主记录 recordTime 不同 |
|
||||
| coordinateSystem | WGS84/GCJ02/UNKNOWN | 仅 GB2025 显式坐标类型 1/2 对应 WGS84/GCJ02;GB2016/JT808/MQTT 暂无确证为 UNKNOWN |
|
||||
|
||||
平台接受 0–200 kg,超出范围、非有限值或采集时间超过请求时刻 1 分钟标为 INVALID 并返回 null。超过 300 秒的有效质量保留数值并标 STALE,客户端需提示陈旧;PARTIAL 仍需逐字段判断。聚合 NORMAL 保留供未来两字段均有效,目前不会输出。原始帧未找到返回 MISSING;补充历史查询共享 3 秒预算,查询异常或超时降级为 MISSING/UNKNOWN 并保留旧实时字段,不以合并快照伪造采集时间。因此 MISSING 既可能暂无记录,也可能本次未取得可信证据,不能据此断言设备不支持。
|
||||
|
||||
有界回补仅针对具有 GB 最新快照原始帧引用、且精确帧氢量为 MISSING 的车辆:查询该快照 `received_at` 向前 5 分钟内含氢量键的原始帧,按 `event_time DESC, ts DESC` 取最新候选。窗口以快照接收时间为基准,不是 API 当前时间;离线车辆可返回真实的 STALE。显式 null 或异常候选不被过滤成旧正常值;已有 INVALID/PARTIAL/STALE 不回补,不能掩盖最新异常。回补所得氢量及 hydrogenRecordTime 始终来自同一原始帧,仍共享 3 秒预算;超时或无记录维持 MISSING。该策略不会扫描全部历史,也不承诺找到窗口外最近一条氢量。
|
||||
|
||||
储氢覆盖以收到扩展字段的 GB32960 车辆为限,不代表所有 GB32960 车型支持;MQTT/JT808 当前不支持储氢。平台不提供凭容量未知的百分比换算,因而本次不保证 Seeker 所有车辆两列均有值。多瓶完整性由终端全车上报负责,接口没有逐瓶完整性检测能力。
|
||||
|
||||
定位使用位置行的 event_id 对应原始报文:GB 定位状态 bit0=0 表示 FIXED;JT808 bit1=1 表示 FIXED;缺少可信定位位为 UNKNOWN。NO_FIX 时位置不可用、经纬度 null。历史 FIXED 可与 offline 同时存在;显示历史位置需提示位置时间,UNKNOWN 坐标系不得擅自作为 GCJ02 上图。现有 `protocol` 是实时唯一协议字段,枚举 GB32960/MQTT/JT808;`sourceProtocol` 属于里程接口。`online=false` 时 motionStatus=offline;在线且所选速度>3 km/h 为 driving,否则 idle。
|
||||
|
||||
## 日统计身份与时间
|
||||
|
||||
两个接口均以 Asia/Shanghai 自然日请求,新增时间为 RFC 3339 带时区,不改变原 dataTime / updatedAt 的含义。
|
||||
|
||||
| 接口 | 字段 | 来源及空值 |
|
||||
| --- | --- | --- |
|
||||
| 用氢 | vin | 授权车辆 VIN,与实时和里程核对 |
|
||||
| 用氢 | statisticsStartTime / statisticsEndTime | FINAL 为 evidence 区间 min(startTime) / max(endTime);这是证据包络,不保证连续覆盖。PRELIMINARY 只有 lastEventTime 水位,start=null;证据缺失或异常为 null |
|
||||
| 用氢 | updatedAt | 同一 daily_energy 行的 updated_at,无统计行为 null |
|
||||
| 里程 | statisticsStartTime / statisticsEndTime | 同一所选来源 MIN(first_event_time) / MAX(latest_event_time),start 可为前一日跨日基线采样,不能夹到 00:00 伪造自然日起始;end 等于 dataTime;历史结转的当日 0 里程、无记录或异常缺少有效边界时为 null |
|
||||
|
||||
两个接口独立读取投影,**没有共同快照或严格同步统计保证**。同日、NORMAL、相近 updatedAt 均不足以证明可以相除。客户端至少需边界非空且一致,并核查证据覆盖/来源口径;边界仅为包络,相等也不是完整连续同区间的证明。无法证明可比时百公里氢耗为空,保留各自指标展示。里程沿用历史累计值时日里程补 0,是既有兼容规则,不证明当天真实测得零里程。
|
||||
|
||||
## 日用氢量算法与质量
|
||||
|
||||
当前算法标识为 `PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5`;客户端应展示实际 `algorithmVersion`,不能据名称自行推定公式或锁定未来版本。
|
||||
|
||||
数据基础是 GB32960 氢气压力(MPa)、温度(摄氏度)与车型配置的储氢系统水容积,经 NIST 实气压缩因子换算质量(kg):
|
||||
|
||||
```text
|
||||
Tk = 温度 + 273.15
|
||||
m = P × 1000 × 0.00201588 × V / (8.314472 × Tk × Z)
|
||||
```
|
||||
|
||||
V 为储氢容积升数,Z 为代码实现的 NIST 压缩因子。算法筛选有效运行分界点,识别日内加氢后分段计算首末质量下降;不直接拿两次 API 响应相减。加氢识别包含持续压力回升与净质量增加补充规则;纯电异常压降、工作状态、运行边界与仪表里程异常参与质量判定。缺少足够有效边界为 `NO_DATA`。此输入不包含逐瓶完整性证明,不能将部分瓶数据包装成已测量全车总量。
|
||||
|
||||
压力边界无法产生有效正耗氢且满足混动里程等条件时,可用燃料电池电压电流积分折算氢量兜底;此时质量降为 `SUSPECT` 并在 `qualityReason` 说明。`hydrogenConsumptionKg` 返回物理用氢结果,不是 SOC、剩余氢量或 SOC 平衡校正量。压力法本身也是模型估算,不应对外宣称为质量流量计直接测量。
|
||||
|
||||
| qualityStatus | status | 数值与使用要求 |
|
||||
| --- | --- | --- |
|
||||
| OK | NORMAL | 可用于日内监控;还需校验计算阶段和可比区间 |
|
||||
| SUSPECT | DATA_ANOMALY | 保留数值供审计,禁止作为正常百公里氢耗输入 |
|
||||
| NO_DATA | NO_DATA | 用氢量为 null;没有统计行时质量/阶段等旧可选字段可能省略 |
|
||||
|
||||
`calculationPhase` 为 `PRELIMINARY` 或 `FINAL`。流式初步结果可变化,`NORMAL + PRELIMINARY + OK` 仅适用于明确标注“初步”的日内监控;不能当成最终日报。`FINAL` 表示已执行批量重算,仍可因补传、参数或算法修订再次重算,并非不可变账单。实际完成时间读取 `updatedAt`;接口不承诺每日固定时刻已经完成 FINAL。正式报表应同时要求 FINAL、OK、NORMAL、有效时间区间,并留存版本和查询快照。
|
||||
|
||||
## Seeker 必须同步适配
|
||||
|
||||
1. Go 上游模型增加储氢、GPS、质量及时间字段,映射 `remainingHydrogenKg → leftHydrogen`、`remainingHydrogenPercent → originalLeftHydrogen`;前端分别判断两个字段,保留真实 0,null 显示“—”并提示原因。
|
||||
2. `socPercent` 仍为动力电池 SOC,不得替代储氢百分比。`timeDifferenceSeconds / 3600` 是记录年龄(小时),不是实际离线持续时间;移除“72 小时即 GPS 正常”的判断。
|
||||
3. 按 VIN、规范化车牌及 date 合并三个接口;本地 truckNum、筛选、排序保持原口径。新字段不会自动接入不在本仓库的 Seeker 代码。
|
||||
4. 百公里氢耗由 Seeker 计算 `hydrogenConsumptionKg / dailyMileageKm × 100`,单位 kg/100km。必须保证分子分母有效、里程大于 0、两行 NORMAL、氢量质量 OK,且统计区间可比较;否则返回 null。真实用氢量 0 与正里程可产生 0。历史字段 `dayHydrogenPercent` 并非百分比。
|
||||
5. 联调需覆盖真实零、部分字段支持、陈旧、无记录、未支持协议、异常、离线历史位置、无定位、零里程及统计重算。页面验收须在 Seeker 完成上述接入后单独进行。
|
||||
@@ -0,0 +1,49 @@
|
||||
# 车辆实况接口补充:发布与验收记录
|
||||
|
||||
需求依据:2026-09-08《OneOS 氢能车辆实况接口补充需求》。交付范围是本仓库 OneOS 开放平台,Seeker 客户端代码不在本仓库,未将接口上线等同于客户端页面已完成适配。
|
||||
|
||||
## 交付范围
|
||||
|
||||
- 实时查询:储氢 kg、百分比接口字段、字段级状态、真实采集时间、来源和陈旧阈值;GPS 定位位、位置采集时间和可确认的坐标系。
|
||||
- 日用氢量:VIN、来自同一统计记录的可空证据起止时间、更新时间。
|
||||
- 日里程:所选来源真实证据起止时间,历史累计结转不伪造当日区间。
|
||||
- 认证、原请求体、响应外壳和旧数值口径兼容;加强请求车牌与授权映射成员校验。
|
||||
- 内嵌 OpenAPI 1.8.0、HTML 文档以及 `oneos-vehicle-live-api-contract.md`。
|
||||
|
||||
## 验收方法
|
||||
|
||||
1. 执行 API 模块 `go test ./...`、开放平台 `go test -race ./internal/openplatform`、`go vet ./internal/openplatform`,以及发布脚本自测与 diff-check。
|
||||
2. 在生产机器仅监听 127.0.0.1:20311 的候选进程读取真实数据;以已有授权车辆为样本,创建临时测试凭证,调用三个正式 HTTP 接口,测试完删除临时凭证与授权,保留接口审计记录。凭证没有写入交付文件。
|
||||
3. 每轮批量 100 台,检查结果数量、车牌白名单、VIN 一致性、必备新增字段、真实零、百分比空值、NO_FIX 坐标为空、401 未认证、403 未授权名单。覆盖当日及前一日统计。
|
||||
4. 对实时快照与原始帧进行脱敏核对,确认合并快照会保留旧字段、最新帧可能没有储氢单元;回补须使用真正含储氢字段的原始帧及原采集时间。
|
||||
5. 原始回查以每 VIN 五分钟接收时间窗、四并发、共享三秒预算限制成本;测试缺数据、显式异常、未来时间、陈旧、历史离线定位、故障降级、错 VIN、跨日结转及证据重算变化。
|
||||
|
||||
候选联调中发现并修复了两个仅靠 SQL mock 无法充分覆盖的问题:DATE_FORMAT 秒标记与 IN 参数模板冲突;TDengine 的排序分区查询 LIMIT 实际限制全局行数,回补最终改为每 VIN 单独查询。上线前进一步通过真实 SQL 核对。
|
||||
|
||||
## 交付限制
|
||||
|
||||
- 百分比目前 `null / UNSUPPORTED`:没有可信全车容量分母,不能使用电池 SOC、单瓶值或猜测容量替代。
|
||||
- `REPORTED` 仅确认终端上报质量;无法确认终端内部采用测量还是估算。全车口径依赖已识别的 GB32960 广东扩展,不代表全部车型和协议均支持。
|
||||
- 坐标系没有明确原始编码时为 UNKNOWN,不能宣称统一 GCJ02;真实定位状态与在线、新旧独立。
|
||||
- 两个日统计接口没有共同快照保证;PRELIMINARY 氢耗开始时间通常未知,不能仅凭同日就作为严格同步百公里氢耗依据。
|
||||
- 回补有明确时间窗;原始记录不可用、没有相关字段或查询超时仍可能 MISSING,不伪造数值或采集时间。
|
||||
- Seeker 须自行增加字段映射、状态展示和 VIN/统计区间核验,接口添加不会自动改变其页面。
|
||||
|
||||
## 发布证据
|
||||
|
||||
发布结果、二进制 SHA-256、正式接口烟测和脱敏样本保存在工作区 `outputs/live-api-release-20260908/`。最终发布与验收结果如下:
|
||||
|
||||
|
||||
- 正式版本:`open-platform-live-fields-202609082146`,北京时间 2026-09-08 21:46:08 切换,21:46:09 健康检查就绪。
|
||||
- 服务:`lingniu-vehicle-open-platform`,端口 20310。无数据库迁移;沿用原门户静态资源,更新开放平台 API 及其内嵌接口文档。
|
||||
- 正式二进制 SHA-256:`724491000e1010dc20960e0abb7db9aceac0dd99dc9867cd993f339da00ac97c`,与验收候选一致。
|
||||
- 原版本 `open-platform-suspect-20260904162007` 和本次首版 `open-platform-live-fields-202609082130` 均保留。安装器保留失败时恢复旧版本的逻辑;本次正式切换成功,未执行生产回滚演练。
|
||||
- 最终全量 Go 测试、race、vet、文档测试、YAML 校验、发布脚本测试和 diff-check 均通过。
|
||||
- 最终候选四轮:实时接口 244–275 ms,100 台样本中 74–77 台有 kg,0 台 MISSING;其余为不支持协议。包括当日及前一日统计。各轮重新选择活跃授权样本,不是固定车辆群组,不应把不同轮覆盖率当作同一群组趋势。
|
||||
- 正式接口三轮(每轮 100 台):实时 446/335/295 ms;里程 15/15/18 ms;用氢 6/6/6 ms。三接口均 SUCCESS,结果数量及车牌/VIN 核验通过,401/403 负向用例通过。
|
||||
- 正式样本 kg 非空 59/69/71 台,包含 3/4/4 个真实零;MISSING 33/22/19 台;UNSUPPORTED 8/9/10 台。未把缺失样本认定为已有有效储氢量,也不承诺全量车辆覆盖。无 enrichment 错误日志,仍应按真实来源和回查窗口判断可用性。
|
||||
- 正式 GPS 样本识别 NO_FIX 4/5/5 台,经纬度及 locationAvailable 验证一致;所有样本坐标系为 UNKNOWN,未伪报统一坐标系。
|
||||
- `http://115.29.187.205:20310/healthz`、`/open-api/openapi.yaml`、`/open-api/docs/` 公网检查 200 且版本/字段匹配;门户 catalog 正常,systemd active,无异常退出。
|
||||
- 本轮所有临时测试应用与授权均删除,测试用凭证未输出。没有修改既有合作方密钥和授权。
|
||||
|
||||
结论:开放平台本次增量接口及其兼容、权限、真实来源、空值和发布行为验收通过;储氢百分比数据接入、未知坐标系的供应方确认、统一统计快照和 Seeker 页面适配仍属于上述明确限制,不宣称这些数据/客户端工作已经完成。
|
||||
Reference in New Issue
Block a user