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 openapi: 3.0.3
info: info:
title: 车辆数据开放平台 API title: 车辆数据开放平台 API
version: 1.7.0 version: 1.8.0
license: license:
name: Proprietary name: Proprietary
description: | description: |
@@ -210,7 +210,11 @@ paths:
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。 任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。
socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。 socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。
activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。 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 operationId: queryRealtimeVehicles
security: security:
- AppKeyAuth: [] - AppKeyAuth: []
@@ -608,8 +612,11 @@ components:
description: true仅合作站,false仅外部站,省略则返回全部 description: true仅合作站,false仅外部站,省略则返回全部
HydrogenResult: HydrogenResult:
type: object type: object
required: [plateNumber, date, hydrogenConsumptionKg, status] required: [vin, plateNumber, date, hydrogenConsumptionKg, statisticsStartTime, statisticsEndTime, updatedAt, status]
properties: properties:
vin:
type: string
description: 授权车辆 VIN,供跨接口身份校验
plateNumber: plateNumber:
type: string type: string
date: date:
@@ -620,17 +627,32 @@ components:
format: double format: double
nullable: true nullable: true
description: 单日用氢量,kg;OK 和 SUSPECT 计算结果均返回数值,无数据时为 null 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: calculationPhase:
type: string type: string
enum: [PRELIMINARY, FINAL] enum: [PRELIMINARY, FINAL]
description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL description: PRELIMINARY 仅供标注为初步的日内监控;FINAL 表示批量重算已完成,补传或算法调整仍可能再次重算,不保证固定结算时刻
algorithmVersion: algorithmVersion:
type: string type: string
description: 氢耗计算算法版本 description: 氢耗计算算法版本
qualityStatus: qualityStatus:
type: string type: string
enum: [OK, SUSPECT, NO_DATA] enum: [OK, SUSPECT, NO_DATA]
description: 氢耗计算质量状态 description: OK 对应 NORMALSUSPECT 对应 DATA_ANOMALY 并保留数值供审计,不得用于正常百公里氢耗;NO_DATA 对应无可用统计
qualityReason: qualityReason:
type: string type: string
description: 质量判定原因;SUSPECT 或 NO_DATA 时供调用方解释和审计 description: 质量判定原因;SUSPECT 或 NO_DATA 时供调用方解释和审计
@@ -638,7 +660,7 @@ components:
$ref: '#/components/schemas/DataStatus' $ref: '#/components/schemas/DataStatus'
MileageResult: MileageResult:
type: object 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: properties:
vin: vin:
type: string type: string
@@ -657,6 +679,16 @@ components:
format: double format: double
nullable: true nullable: true
description: 当日所选协议最后有效终端累计总里程,km;GPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null 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: dataTime:
type: string type: string
format: date-time format: date-time
@@ -846,10 +878,69 @@ components:
$ref: '#/components/schemas/StationaryVehicleResult' $ref: '#/components/schemas/StationaryVehicleResult'
RealtimeVehicleResult: RealtimeVehicleResult:
type: object 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: properties:
vin: { type: string } vin: { type: string }
plateNumber: { 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: protocol:
type: string type: string
enum: [GB32960, MQTT, JT808] 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 { type HydrogenResult struct {
VIN string `json:"vin"`
StatisticsStartTime *string `json:"statisticsStartTime"`
StatisticsEndTime *string `json:"statisticsEndTime"`
UpdatedAt *string `json:"updatedAt"`
PlateNumber string `json:"plateNumber"` PlateNumber string `json:"plateNumber"`
Date string `json:"date"` Date string `json:"date"`
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"` HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
@@ -57,16 +61,18 @@ type HydrogenResult struct {
} }
type MileageResult struct { type MileageResult struct {
VIN string `json:"vin"` StatisticsStartTime *string `json:"statisticsStartTime"`
PlateNumber string `json:"plateNumber"` StatisticsEndTime *string `json:"statisticsEndTime"`
Date string `json:"date"` VIN string `json:"vin"`
DailyMileageKm *float64 `json:"dailyMileageKm"` PlateNumber string `json:"plateNumber"`
TotalMileageKm *float64 `json:"totalMileageKm"` Date string `json:"date"`
DataTime *string `json:"dataTime"` DailyMileageKm *float64 `json:"dailyMileageKm"`
UpdatedAt *string `json:"updatedAt"` TotalMileageKm *float64 `json:"totalMileageKm"`
SourceProtocol *string `json:"sourceProtocol"` DataTime *string `json:"dataTime"`
DataQuality *string `json:"dataQuality,omitempty"` UpdatedAt *string `json:"updatedAt"`
Status string `json:"status"` SourceProtocol *string `json:"sourceProtocol"`
DataQuality *string `json:"dataQuality,omitempty"`
Status string `json:"status"`
} }
type MileageRangeResult struct { type MileageRangeResult struct {
@@ -151,21 +157,31 @@ type RealtimeVehicleRequest struct {
} }
type RealtimeVehiclePoint struct { type RealtimeVehiclePoint struct {
VIN string LiveHydrogen RealtimeHydrogenData
Protocol string LocationObservedAt time.Time
Longitude float64 LocationEventID string
Latitude float64 LocationReceivedAt time.Time
SpeedKmh float64 GPSFixStatus string
SOCPercent *float64 CoordinateSystem string
TotalMileageKm float64 VIN string
ObservedAt time.Time Protocol string
Online bool Longitude float64
ActiveToday bool Latitude float64
SpeedKmh float64
SOCPercent *float64
TotalMileageKm float64
ObservedAt time.Time
Online bool
ActiveToday bool
} }
type RealtimeVehicleResult struct { type RealtimeVehicleResult struct {
VIN string `json:"vin"` RealtimeHydrogenData
PlateNumber string `json:"plateNumber"` 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. // Protocol is the single, canonical source protocol for this realtime record.
// It is normalized to GB32960, MQTT, or JT808. // It is normalized to GB32960, MQTT, or JT808.
Protocol string `json:"protocol,omitempty"` Protocol string `json:"protocol,omitempty"`
@@ -285,6 +301,8 @@ type AppCredential struct {
} }
type DailyHydrogen struct { type DailyHydrogen struct {
EvidenceJSON string
UpdatedAt string
VIN string VIN string
Date string Date string
ConsumptionKg float64 ConsumptionKg float64
@@ -296,13 +314,14 @@ type DailyHydrogen struct {
} }
type DailyMileage struct { type DailyMileage struct {
VIN string StatisticsStartTime string
Date string VIN string
Protocol string Date string
MileageKm float64 Protocol string
TotalMileageKm float64 MileageKm float64
DataTime string TotalMileageKm float64
UpdatedAt string DataTime string
UpdatedAt string
} }
type MileageSnapshot struct { type MileageSnapshot struct {
@@ -196,7 +196,8 @@ func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, n
query := ` query := `
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0), 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, 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 FROM vehicle_realtime_location l
WHERE BINARY l.vin IN (` + placeholders + `) WHERE BINARY l.vin IN (` + placeholders + `)
ORDER BY l.vin, ORDER BY l.vin,
@@ -212,9 +213,12 @@ ORDER BY l.vin,
for rows.Next() { for rows.Next() {
var point RealtimeVehiclePoint var point RealtimeVehiclePoint
var soc sql.NullFloat64 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 return nil, err
} }
point.LocationObservedAt, point.LocationReceivedAt, point.LocationEventID = locationAt.Time, receivedAt.Time, eventID.String
if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 { if soc.Valid && soc.Float64 >= 0 && soc.Float64 <= 100 {
value := round3(soc.Float64) value := round3(soc.Float64)
point.SOCPercent = &value point.SOCPercent = &value
@@ -229,7 +233,16 @@ ORDER BY l.vin,
out[point.VIN] = selected 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) { 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(` query, args := inQuery(`
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status,quality_reason, 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 FROM vehicle_open_daily_energy
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins) WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
rows, err := r.db.QueryContext(ctx, query, args...) 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)) out := make(map[string]DailyHydrogen, len(vins))
for rows.Next() { for rows.Next() {
var value DailyHydrogen 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 return nil, err
} }
out[value.VIN] = value out[value.VIN] = value
@@ -407,7 +421,12 @@ SELECT
AND selected.is_selected=1 AND selected.is_selected=1
AND selected.latest_event_time IS NOT NULL AND selected.latest_event_time IS NOT NULL
),'%Y-%m-%dT%H:%i:%s+08:00'),''), ),'%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 FROM vehicle_daily_mileage m
WHERE m.stat_date BETWEEN ? AND ? WHERE m.stat_date BETWEEN ? AND ?
AND m.vin IN (` + placeholders + `) AND m.vin IN (` + placeholders + `)
@@ -445,7 +464,7 @@ ORDER BY m.stat_date,m.vin,
out := make(map[string]DailyMileage, len(vins)) out := make(map[string]DailyMileage, len(vins))
for rows.Next() { for rows.Next() {
var value DailyMileage 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 return nil, err
} }
key := dailyMileageKey(value.VIN, value.Date) key := dailyMileageKey(value.VIN, value.Date)
@@ -805,7 +824,8 @@ func inQuery(template, first string, values []string) (string, []any) {
for _, value := range values { for _, value := range values {
args = append(args, value) 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 { func nullableTime(value *time.Time) any {
@@ -11,10 +11,13 @@ import (
) )
func TestInQueryPreservesMySQLDateFormatPercentTokens(t *testing.T) { 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')") { if strings.Contains(query, "MISSING") || !strings.Contains(query, "DATE_FORMAT(stat_date,'%Y-%m-%d')") {
t.Fatalf("date format was corrupted: %s", query) 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 (?,?)") { if !strings.Contains(query, "vin IN (?,?)") {
t.Fatalf("VIN placeholders missing: %s", query) t.Fatalf("VIN placeholders missing: %s", query)
} }
@@ -132,8 +135,8 @@ func TestDailyMileageReturnsDailyAndAuthoritativeSameProtocolEndTotal(t *testing
defer db.Close() 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"). 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"). 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"}). 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")) 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) values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001", "LTEST32960VIN0002"}, "2026-07-21", nil)
if err != nil { if err != nil {
@@ -159,9 +162,9 @@ func TestDailyMileageExplicitPriorityFiltersDisabledProtocolsAndKeepsZero(t *tes
defer db.Close() defer db.Close()
mock.ExpectQuery("m.protocol IN \\(\\?,\\?\\).*ORDER BY m.stat_date,m.vin,CASE m.protocol WHEN \\? THEN 1 WHEN \\? THEN 2"). 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"). 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"}). 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"). 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")) 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"}) values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-21", []string{"JT808", "GB32960"})
if err != nil { if err != nil {
@@ -269,9 +272,9 @@ func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T)
vin := "LTEST32960VIN0001" vin := "LTEST32960VIN0001"
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location"). mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
WithArgs(dayStart, vin, now.Add(-10*time.Minute)). 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"}). 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). 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)) 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) points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
if err != nil { 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)) results := make([]RealtimeVehicleResult, 0, len(plates))
for _, plate := range plates { for _, plate := range plates {
vehicle := vehicles[plate] 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 { if point, ok := points[vehicle.VIN]; ok {
difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds()) difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds())
if difference < 0 { if difference < 0 {
difference = 0 difference = 0
} }
item.Protocol = externalMileageProtocol(point.Protocol) 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.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
item.TimeDifferenceSeconds = &difference item.TimeDifferenceSeconds = &difference
item.Online = point.Online 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) speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
item.SOCPercent = point.SOCPercent 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 longitude, latitude := point.Longitude, point.Latitude
item.Longitude, item.Latitude = &longitude, &latitude item.Longitude, item.Latitude = &longitude, &latitude
item.LocationAvailable = true item.LocationAvailable = true
@@ -387,11 +388,13 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
results := make([]HydrogenResult, 0, len(plates)) results := make([]HydrogenResult, 0, len(plates))
for _, plate := range plates { for _, plate := range plates {
vehicle := vehicles[plate] 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 // Sampling sufficiency is decided by the producer and persisted in
// quality_status. Imported refuelling-ledger rows can be authoritative // quality_status. Imported refuelling-ledger rows can be authoritative
// with one transaction, while pressure-derived rows require two samples. // with one transaction, while pressure-derived rows require two samples.
if value, ok := values[vehicle.VIN]; ok { if value, ok := values[vehicle.VIN]; ok {
item.StatisticsStartTime, item.StatisticsEndTime = hydrogenStatisticsInterval(value.EvidenceJSON)
item.UpdatedAt = validStatisticsTimestamp(value.UpdatedAt)
item.CalculationPhase = value.CalculationPhase item.CalculationPhase = value.CalculationPhase
item.AlgorithmVersion = value.AlgorithmVersion item.AlgorithmVersion = value.AlgorithmVersion
item.QualityStatus = value.QualityStatus 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) { if len(plates) > 0 && len(vehicles) != len(plates) {
return app, nil, ErrForbidden 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 return app, vehicles, nil
} }
@@ -991,6 +1000,9 @@ func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map
} }
func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage float64) { 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 item.DailyMileageKm = &dailyMileage
totalMileage := value.TotalMileageKm totalMileage := value.TotalMileageKm
item.TotalMileageKm = &totalMileage item.TotalMileageKm = &totalMileage
@@ -261,7 +261,7 @@ func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"}, "粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
}, },
hydrogen: map[string]DailyHydrogen{ 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{ mileage: map[string]DailyMileage{
"LTEST32960VIN0001": { "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 { 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) 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 { if hydrogen[0].CalculationPhase != "PRELIMINARY" || hydrogen[0].AlgorithmVersion != trustedHydrogenAlgorithmVersion {
t.Fatalf("hydrogen calculation metadata = %#v", hydrogen[0]) 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/nullkg | 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/GCJ02GB2016/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 表示 FIXEDJT808 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 台样本中 7477 台有 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 页面适配仍属于上述明确限制,不宣称这些数据/客户端工作已经完成。