feat: 补齐开放平台车辆实况与统计时间字段

This commit is contained in:
lingniu
2026-09-08 21:47:58 +08:00
parent 9fa3d81777
commit a6e31effc2
14 changed files with 1376 additions and 62 deletions
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 对应 NORMALSUSPECT 对应 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 表示 FIXEDMQTT 或缺少可信定位位为 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,其余 UNKNOWNGB2016/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))
}
@@ -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(&current, 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])
}