diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html index b451d30d..dc3224a5 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html @@ -69,7 +69,7 @@ "cooperateOnly": true } -

车辆单日用氢量

POST/api/v1/vehicles/hydrogen-consumption/query

查询车辆单日用氢量,单位 kg。

#

请求参数

字段必填说明
date日期,格式 yyyy-MM-dd
plateNumbers车牌数组;省略或 [] 时查询全部授权车辆

成功返回 · 200

{
"code": "SUCCESS",
"data": [{
"plateNumber": "浙F06618F",
"hydrogenConsumptionKg": 12.315,
"status": "NORMAL"
}]
}
  • 400:日期或车牌格式不正确
  • 401:appKey 无效、停用或过期
  • 403:指定车辆未授权
+

车辆单日用氢量

POST/api/v1/vehicles/hydrogen-consumption/query

查询车辆单日用氢量,单位 kg。NORMAL + OK + PRELIMINARY 仅供初步监控;正式报表要求 FINAL 并核对证据与版本。两个日统计接口没有共同快照,区间缺失或不同不能直接计算百公里氢耗。

#

请求参数

字段必填说明
date日期,格式 yyyy-MM-dd
plateNumbers车牌数组;省略或 [] 时查询全部授权车辆

成功返回 · 200

{
"code": "SUCCESS",
"data": [{
"plateNumber": "浙F06618F",
"hydrogenConsumptionKg": 12.315,
"status": "NORMAL"
}]
}
  • 400:日期或车牌格式不正确
  • 401:appKey 无效、停用或过期
  • 403:指定车辆未授权

车辆单日里程

POST/api/v1/vehicles/mileage/query

返回当日行驶里程、当日累计总里程和实际选用的数据协议,单位 km。

#

请求参数

字段必填说明
date日期,格式 yyyy-MM-dd
plateNumbers省略时查询全部授权车辆
protocolPriority协议选源顺序,例如 ["GB32960","MQTT","JT808"]
缺数规则:若当日没有有效里程,日里程为 0;累计总里程沿用上一个有效统计周期,计算时间也显示该周期时间。

成功返回 · 200

{
"code": "SUCCESS",
"data": [{
"dailyMileageKm": 182.437,
"totalMileageKm": 12345.679,
"sourceProtocol": "GB32960",
"status": "NORMAL"
}]
}
  • 400:日期、车牌或协议参数错误
  • 403:授权期未覆盖查询日
  • 无统计:单车以 NO_DATA 返回
@@ -77,13 +77,13 @@

指定时刻总里程

POST/api/v1/vehicles/total-mileage/query

按 VIN 返回不晚于指定时刻的最近一条总里程及实际采集协议。

#

请求参数

字段必填说明
vin17 位已授权 VIN
time北京时间 yyyy-MM-dd HH:mm:ss
protocolGB32960、MQTT 或 JT808;省略时按默认顺序选取

成功返回 · 200

{
"code": "SUCCESS",
"data": {
"totalMileageKm": 12345.678,
"protocol": "GB32960",
"recordTime": "2026-08-06 10:29:45",
"timeDifferenceSeconds": 15
}
}
  • 400:VIN、时间或协议不合法
  • 403:VIN 未授权
  • 无记录:返回 NO_DATA
-

车辆实时位置与状态

POST/api/v1/vehicles/realtime/query

返回最新位置、在线状态、速度、总里程、实际协议和记录时间。

#

请求参数

字段必填说明
plateNumbers省略或 [] 时返回全部当前有效授权车辆

成功返回 · 200

{
"data": [{
"vin": "LA9GG68L2PBAF4790",
"plateNumber": "浙F06618F",
"sourceProtocol": "GB32960",
"longitude": 120.75,
"latitude": 30.74,
"speedKmh": 0,
"totalMileageKm": 12345.678,
"recordTime": "2026-08-17 17:40:12",
"timeDifferenceSeconds": 8,
"online": true,
"activeToday": true,
"motionStatus": "idle",
"locationAvailable": true,
"status": "NORMAL"
}]
}
  • 400:车牌格式错误
  • 403:指定车牌未授权
  • 无实时记录:返回 NO_DATA
+

车辆实时位置与状态

POST/api/v1/vehicles/realtime/query

返回位置、在线状态、速度、总里程、储氢及独立 GPS 状态。当前储氢百分比无可信分母,返回 null/UNSUPPORTED;质量按字段状态解释。

#

请求参数

字段必填说明
plateNumbers省略或 [] 时返回全部当前有效授权车辆

成功返回 · 200

{
"data": [{
"vin": "LA9GG68L2PBAF4790",
"plateNumber": "浙F06618F",
"protocol": "GB32960",
"longitude": 120.75,
"latitude": 30.74,
"speedKmh": 0,
"totalMileageKm": 12345.678,
"recordTime": "2026-08-17 17:40:12",
"timeDifferenceSeconds": 8,
"online": true,
"activeToday": true,
"motionStatus": "idle",
"locationAvailable": true,
"status": "NORMAL"
}]
}
  • 400:车牌格式错误
  • 403:指定车牌未授权
  • 无实时记录:返回 NO_DATA

加氢车辆停留核验

POST/api/v1/vehicles/stationary/query

按站点坐标、坐标系、时间段和半径,核验速度接近 0 的车辆停留记录,并按匹配度降序返回。

#

请求参数

字段必填说明
startTime / endTime北京时间;查询区间最长 24 小时
longitude / latitude加氢站经纬度;由 coordinateSystem 说明坐标系
coordinateSystemWGS84(默认)或 GCJ02(高德);GCJ02 自动转换后核验
radiusMeters核验半径,单位 m;1–100,默认 5
plateNumbers省略时核验全部有效授权车辆
匹配规则:速度 ≤ 3 km/h,至少 2 个样本且停留 ≥ 60 秒;相邻定位点间隔超过 10 分钟时拆分停留区间。

成功返回 · 200

{
"data": [{
"plateNumber": "浙F06618F",
"stayStartTime": "2026-08-06 10:16:02",
"stayEndTime": "2026-08-06 10:42:18",
"stayDurationSeconds": 1576,
"matchScore": 93.842
}]
}
  • 400:时间、坐标、坐标系或半径不合法
  • 403:授权期未覆盖整个时间段
  • 无匹配:data 为空数组

加氢站地图点位

POST/api/v1/hydrogen-stations/query

只读查询有有效坐标的加氢站,可按行政区划和合作属性筛选。

#

请求参数

字段必填说明
province / city行政区划筛选
cooperateOnlytrue 仅合作站;false 仅外部站

成功返回 · 200

{
"data": [{
"id": "1",
"name": "示例加氢站",
"longitude": 120.752312,
"latitude": 30.746281,
"cooperative": true
}]
}
  • 400:行政区划参数过长
  • 401:appKey 无效
  • 无符合站点:data 为空数组
-

返回字段与枚举说明

所有成功响应外层固定包含 code、message、data 和 traceId;以下为 data 内的字段。无数据时按对应接口返回 null、NO_DATA 或空数组。

#

通用响应与状态

字段/枚举说明
code = SUCCESS请求已成功处理。业务数据是否存在由每条数据的 status 判断。
message成功时固定为 success。
traceId本次请求唯一追踪标识;异常排查时请完整提供。
status = NORMAL存在可用数据,相关数值、协议和时间字段有效。
status = NO_DATA在授权范围内未找到可用数据;可选数值、位置、时间字段为 null 或省略。
status = DATA_ANOMALY检测到数据异常,当前不返回可能错误的里程;dataQuality 给出原因。
dataQuality = TOTAL_MILEAGE_ROLLBACK终端累计里程明显回退,已阻止其作为正常累计里程返回。

车辆单日用氢量

字段说明
plateNumber / date车牌与查询自然日(Asia/Shanghai)。
hydrogenConsumptionKg当日用氢量,单位 kg;无有效数据时为 null。
statusNORMAL 表示已计算;NO_DATA 表示无可用统计。

车辆单日里程与区间日里程

字段说明
vin / plateNumber / date车辆唯一 VIN、车牌和统计自然日。
dailyMileageKm当日行驶里程,单位 km。缺少当日记录且可前向补齐时为 0。
totalMileageKm所选协议的日末累计总里程,单位 km;不会用 GPS 日里程估算值替代。
sourceProtocol实际采用的协议:GB32960、MQTT 或 JT808。
dataTime实际采用的最后一条车辆源数据时间。
updatedAt该统计周期的计算时间;前向补齐时保持上一有效统计周期时间。
snapshotId / nextCursor仅区间接口返回;snapshotId 固定本次分页车辆范围,nextCursor 为下一页游标,最后一页为 null。

指定时刻总里程

字段说明
vin / queryTime查询车辆 VIN 与请求的北京时间。
totalMileageKm不晚于请求时间的最近一条累计总里程,单位 km。
protocol / protocolInputprotocol 为实际命中协议;protocolInput 为请求显式传入的协议,未传则省略。
recordTime / timeDifferenceSeconds实际命中记录时间,以及请求时间与记录时间的差值,单位秒。
mileageMeaning该协议累计里程的业务口径;JT808 为定位终端/GPS侧累计值。

车辆实时位置与状态

字段/枚举说明
sourceProtocol本条位置、速度、累计里程和记录时间实际采用的协议:GB32960、MQTT 或 JT808。
longitude / latitude最新有效经纬度;无有效位置时为 null,locationAvailable 为 false。
speedKmh / totalMileageKm所选来源的瞬时速度(km/h)和累计总里程(km)。
recordTime / timeDifferenceSeconds所选来源的记录时间,以及与当前查询时间的差值,单位秒。
onlinetrue:任一协议最近 60 秒内上报;false:没有任一协议满足该实时阈值。
activeTodaytrue:任一协议在当前自然日曾上报;不等同于实时 online。
motionStatus = driving在线且所选来源速度大于 3 km/h。
motionStatus = idle在线且所选来源速度不大于 3 km/h。
motionStatus = offline当前不在线,或没有实时记录。
protocol旧版兼容字段;新接入请使用 sourceProtocol。

加氢车辆停留核验

字段说明
stayStartTime / stayEndTime满足核验条件的连续停留开始、结束时间。
stayDurationSeconds / stayDurationMinutes停留时长,分别以秒、分钟表示。
matchScore0–100;位置越接近、速度越低、停留越久、样本越充分,分数越高。
averageDistanceMeters / maxDistanceMeters定位点相对输入站点坐标的平均/最大距离,单位 m。
averageSpeedKmh / maxSpeedKmh / matchedSamples核验区间内速度统计与匹配定位样本数。
sourceProtocols本停留片段采用过的协议数组,枚举值为 GB32960、MQTT、JT808。

加氢站地图点位

字段说明
id / name / shortName站点唯一标识、标准名称和简称;id 为字符串以避免 JavaScript 精度丢失。
address / province / city / district站点地址及行政区划。
longitude / latitude站点坐标。
cooperativetrue:合作站(内部站或已导入合作名录);false:外部站。
+

返回字段与枚举说明

所有成功响应外层固定包含 code、message、data 和 traceId;以下为 data 内的字段。无数据时按对应接口返回 null、NO_DATA 或空数组。

#

通用响应与状态

字段/枚举说明
code = SUCCESS请求已成功处理。业务数据是否存在由每条数据的 status 判断。
message成功时固定为 success。
traceId本次请求唯一追踪标识;异常排查时请完整提供。
status = NORMAL存在可用数据,相关数值、协议和时间字段有效。
status = NO_DATA在授权范围内未找到可用数据;可选数值、位置、时间字段为 null 或省略。
status = DATA_ANOMALY检测到数据异常,里程异常不返回可能错误的里程;用氢 SUSPECT 保留审计数值。原因分别见 dataQuality / qualityReason。
dataQuality = TOTAL_MILEAGE_ROLLBACK终端累计里程明显回退,已阻止其作为正常累计里程返回。

车辆单日用氢量

字段说明
vin / plateNumber / date授权车辆 VIN、车牌与查询自然日(Asia/Shanghai)。
hydrogenConsumptionKg当日用氢量,单位 kg;无有效数据时为 null。
status / qualityStatusNORMAL / OK 为可用;DATA_ANOMALY / SUSPECT 保留数值供审计,不参与正常百公里氢耗;NO_DATA 不可用。
calculationPhase / algorithmVersionPRELIMINARY 为初步监控结果,FINAL 为批量重算结果;历史结果仍可能再重算,保留实际版本。
statisticsStartTime / statisticsEndTime / updatedAt证据包络起止和统计行更新时间;PRELIMINARY 开始时间为 null,结束为数据水位。与里程无共同快照保证。

车辆单日里程与区间日里程

字段说明
vin / plateNumber / date车辆唯一 VIN、车牌和统计自然日。
dailyMileageKm当日行驶里程,单位 km。缺少当日记录且可前向补齐时为 0。
totalMileageKm所选协议的日末累计总里程,单位 km;不会用 GPS 日里程估算值替代。
sourceProtocol实际采用的协议:GB32960、MQTT 或 JT808。
dataTime实际采用的最后一条车辆源数据时间。
updatedAt该统计周期的计算时间;前向补齐时保持上一有效统计周期时间。
snapshotId / nextCursor仅区间接口返回;snapshotId 固定本次分页车辆范围,nextCursor 为下一页游标,最后一页为 null。

指定时刻总里程

字段说明
vin / queryTime查询车辆 VIN 与请求的北京时间。
totalMileageKm不晚于请求时间的最近一条累计总里程,单位 km。
protocol / protocolInputprotocol 为实际命中协议;protocolInput 为请求显式传入的协议,未传则省略。
recordTime / timeDifferenceSeconds实际命中记录时间,以及请求时间与记录时间的差值,单位秒。
mileageMeaning该协议累计里程的业务口径;JT808 为定位终端/GPS侧累计值。

车辆实时位置与状态

字段/枚举说明
protocol本条位置、速度、累计里程和记录时间实际采用的协议:GB32960、MQTT 或 JT808。
remainingHydrogenKg / remainingHydrogenPercent全车储氢 kg / %,真实零保留。当前仅 GB32960 广东扩展质量支持;百分比为 null/UNSUPPORTED。
hydrogenRecordTime / hydrogenDataStatus同帧采集时间和聚合状态 NORMAL / PARTIAL / STALE / MISSING / UNSUPPORTED / INVALID;必须同时检查两个字段级状态。
gpsFixStatus / locationRecordTime / coordinateSystem真实定位位、位置采集时间和 WGS84 / GCJ02 / UNKNOWN。离线不清除历史 FIXED,陈旧不证明无定位。
longitude / latitude无有效位置或 NO_FIX 时为 null,locationAvailable 为 false;UNKNOWN 坐标系需由调用方确认后上图。
speedKmh / totalMileageKm所选来源的瞬时速度(km/h)和累计总里程(km)。
recordTime / timeDifferenceSeconds所选来源的记录时间,以及与当前查询时间的差值,单位秒。
onlinetrue:任一协议最近 60 秒内上报;false:没有任一协议满足该实时阈值。
activeTodaytrue:任一协议在当前自然日曾上报;不等同于实时 online。
motionStatus = driving在线且所选来源速度大于 3 km/h。
motionStatus = idle在线且所选来源速度不大于 3 km/h。
motionStatus = offline当前不在线,或没有实时记录。

加氢车辆停留核验

字段说明
stayStartTime / stayEndTime满足核验条件的连续停留开始、结束时间。
stayDurationSeconds / stayDurationMinutes停留时长,分别以秒、分钟表示。
matchScore0–100;位置越接近、速度越低、停留越久、样本越充分,分数越高。
averageDistanceMeters / maxDistanceMeters定位点相对输入站点坐标的平均/最大距离,单位 m。
averageSpeedKmh / maxSpeedKmh / matchedSamples核验区间内速度统计与匹配定位样本数。
sourceProtocols本停留片段采用过的协议数组,枚举值为 GB32960、MQTT、JT808。

加氢站地图点位

字段说明
id / name / shortName站点唯一标识、标准名称和简称;id 为字符串以避免 JavaScript 精度丢失。
address / province / city / district站点地址及行政区划。
longitude / latitude站点坐标。
cooperativetrue:合作站(内部站或已导入合作名录);false:外部站。

总里程协议口径

未指定 protocol 时,按 GB32960 > MQTT > JT808 选择第一个有数据的协议;不会跨协议拼接里程。

#
规范值总里程含义
GB32960车辆仪表盘累计总里程,对应 GB/T 32960 整车数据累计里程。
MQTT车辆仪表盘或车端控制器累计总里程,由 MQTT 平台上报。
JT808定位终端累计里程,由 GPS/终端侧计算,不等同于车辆仪表盘里程。
@@ -100,10 +100,16 @@ const fields = { "daily-hydrogen": [ ["data[]", "array", "按请求车牌顺序返回的车辆数据数组。"], + ["data[].vin", "string", "授权车辆 VIN,必须与实时、里程记录共同核对。"], ["data[].plateNumber", "string", "车辆车牌号。"], ["data[].date", "date", "查询自然日,格式 yyyy-MM-dd,时区 Asia/Shanghai。"], ["data[].hydrogenConsumptionKg", "number | null", "当日用氢量,单位 kg;无有效统计时为 null。"], - ["data[].status", "enum", "NORMAL 已计算出有效用氢量;NO_DATA 无可用统计。"] + ["data[].statisticsStartTime / statisticsEndTime", "datetime | null", "FINAL 取证据区间最早开始/最晚结束,是证据包络,不保证连续覆盖;PRELIMINARY 仅结束水位,开始为 null;异常或缺失证据为 null。"], + ["data[].updatedAt", "datetime | null", "同一用氢统计行的数据库更新时间,RFC 3339 带时区。"], + ["data[].calculationPhase", "enum (optional)", "PRELIMINARY:初步监控值;FINAL:已批量重算,仍可因补传或算法变更再次重算。"], + ["data[].algorithmVersion", "string (optional)", "实际计算算法版本;压力/温度/容积模型估算,特殊能量积分兜底由质量原因说明。"], + ["data[].qualityStatus / qualityReason", "string (optional)", "OK:可用;SUSPECT:异常/估算兜底,保留数值供审计;NO_DATA:不可用。qualityReason 解释原因。"], + ["data[].status", "enum", "NORMAL 对应 OK;DATA_ANOMALY 对应 SUSPECT,禁止参与正常百公里氢耗;NO_DATA 为无可用统计。"] ], "daily-mileage": [ ["data[]", "array", "按请求车牌顺序返回的车辆数据数组。"], @@ -111,12 +117,13 @@ ["data[].dailyMileageKm", "number | null", "当日行驶里程,单位 km;若当日无记录但存在此前有效累计里程,则返回 0。"], ["data[].totalMileageKm", "number | null", "日末累计总里程,单位 km;仅采用终端累计里程,不以 GPS 日里程估算替代。"], ["data[].sourceProtocol", "enum | null", "GB32960 GB/T 32960 车辆仪表累计里程;MQTT 车端/MQTT 平台累计里程;JT808 定位终端/GPS侧累计里程。"], + ["data[].statisticsStartTime / statisticsEndTime", "datetime | null", "当日统计所选来源首末采集时间,跨日基线开始可早于当日零点,不是自然日起始;历史结转补零时均为 null。与用氢无共同快照,不可仅凭同日直接相除。"], ["data[].dataTime", "datetime | null", "本条统计实际采用的最后一条车辆源数据时间。"], ["data[].updatedAt", "datetime | null", "本行统计计算时间;前向补齐时为上一个有效统计周期的计算时间。"], ["data[].status", "enum", "NORMAL 有效;NO_DATA 无数据;DATA_ANOMALY 发现异常,里程不返回。"], ["data[].dataQuality", "enum | null", "仅 DATA_ANOMALY 时返回。TOTAL_MILEAGE_ROLLBACK 表示终端累计里程明显回退,已拦截。"] ], "mileage-range": [ - ["data[]", "array", "当前页的车辆×日期数据,按请求区间与授权车辆快照排序。单条字段与“车辆单日里程”完全一致。"], + ["data[]", "array", "当前页的车辆×日期数据,按请求区间与授权车辆快照排序。字段说明见下表;statisticsStartTime / statisticsEndTime 仅单日接口新增。"], ["data[].vin / plateNumber / date", "string / date", "车辆 VIN、车牌和该行统计自然日。"], ["data[].dailyMileageKm", "number | null", "当日里程,单位 km。"], ["data[].totalMileageKm", "number | null", "该日累计总里程,单位 km。"], ["data[].sourceProtocol", "enum | null", "GB32960 / MQTT / JT808;含义见单日里程接口。"], ["data[].dataTime / updatedAt", "datetime | null", "采用源数据时间 / 统计计算时间。"], ["data[].status / dataQuality", "enum | null", "NORMAL、NO_DATA、DATA_ANOMALY;异常原因目前为 TOTAL_MILEAGE_ROLLBACK。"], @@ -131,6 +138,14 @@ ], "realtime": [ ["data[]", "array", "按请求车牌顺序返回;省略车牌时返回当前授权范围内全部车辆。"], ["data[].vin / plateNumber", "string", "车辆唯一 VIN / 车牌。"], + ["data[].remainingHydrogenKg / remainingHydrogenPercent", "number | null", "全车储氢质量 kg / 比例 %。当前仅 GB32960 广东扩展 0x34 直接上报质量可用,百分比缺少可信容量分母始终 null/UNSUPPORTED。真实 0 保留,不能用 SOC 替代。"], + ["data[].hydrogenRecordTime", "datetime | null", "氢量原始同帧的实际采集时间,RFC 3339 带时区;不是查询/缓存时间。仅 GB 最新快照有引用且精确帧氢量 MISSING 时,回查快照 received_at 向前 5 分钟的候选,按采集时间优先取最新;INVALID 不回退,离线回补保留真实 STALE。"], + ["data[].hydrogenDataStatus", "enum", "NORMAL 全部有效;PARTIAL 部分字段支持;STALE 陈旧;MISSING 缺记录或本次未取得可信证据(补充查询失败/3 秒超时会降级);UNSUPPORTED 不支持;INVALID 异常。当前质量有效、百分比不支持时为 PARTIAL。"], + ["data[].remainingHydrogenKgStatus / remainingHydrogenPercentStatus", "enum", "逐字段 NORMAL / STALE / MISSING / UNSUPPORTED / INVALID;独立判断两个数值,聚合状态不代表两者都有效。"], + ["data[].hydrogenValueSource / hydrogenSourceProtocol", "string | null", "REPORTED 仅表示终端上报质量,无法确认终端内部采用测量还是估算;储氢数据协议独立于位置 protocol。"], + ["data[].hydrogenStaleAfterSeconds / hydrogenExpectedIntervalSeconds", "integer | null", "GB32960 服务陈旧阈值 300 秒;协议期望上报周期未约定为 null,阈值不是采样周期承诺。"], + ["data[].gpsFixStatus", "enum", "FIXED / NO_FIX / UNKNOWN:实际位置报文定位位,独立于在线和数据年龄;MQTT 无可信定位位为 UNKNOWN。"], + ["data[].locationRecordTime / coordinateSystem", "datetime | null / enum", "位置实际采集时间。坐标系 WGS84 / GCJ02 / UNKNOWN;仅 GB2025 显式类型 1/2 确定 WGS84/GCJ02,GB2016/JT808/MQTT 无确证为 UNKNOWN。不能默认 GCJ02。"], ["data[].protocol", "enum | null", "本条位置、速度、累计里程和记录时间实际采用的唯一协议字段。GB32960 车辆协议;MQTT MQTT 车端来源;JT808 定位终端来源。"], ["data[].longitude / latitude", "number | null", "所选来源的最新有效经度/纬度;无位置时为 null。"], ["data[].speedKmh", "number | null", "所选来源瞬时速度,单位 km/h。"], ["data[].socPercent", "number", "所选来源的动力电池荷电状态,单位 %;仅在采集值为 0–100 时返回,无值或无效值时字段省略。"], ["data[].totalMileageKm", "number | null", "所选来源累计总里程,单位 km。"], ["data[].recordTime", "datetime | null", "所选来源实际记录时间,北京时间。"], ["data[].timeDifferenceSeconds", "integer | null", "当前查询时间减 recordTime,单位秒。"], diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml index dbae4ac5..ded8c886 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: 车辆数据开放平台 API - version: 1.7.0 + version: 1.8.0 license: name: Proprietary description: | @@ -210,7 +210,11 @@ paths: 任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度、SOC 和记录时间仍按上述来源优先级选择。 socPercent 仅在所选来源采集到有效 SOC(0–100,单位 %)时返回;无值或无效值时该字段省略。 activeToday 表示任一采集协议在当前自然日(Asia/Shanghai)内曾上报,用于日上线车辆统计,不改变 online 的实时口径。 - 在线且所选来源速度大于3km/h为行驶中,否则为静止中。 + 离线时 motionStatus=offline;在线且所选来源速度大于3km/h为 driving,否则为 idle。 + 储氢使用可追溯原始帧;最新帧缺少氢量时可有界回补,当前百分比无可信容量分母为 null/UNSUPPORTED;不得用 SOC 替代。 + 仅 GB 最新快照有引用且精确帧氢量 MISSING 时,回查该快照 received_at 向前 5 分钟的含氢量字段原始帧,按采集时间优先、接收时间次序取最新;不回退覆盖 INVALID,离线回补保留真实 STALE。 + GPS 定位状态独立于在线与陈旧;坐标系可能 UNKNOWN,不能假定统一 GCJ02。 + 补充历史查询共享 3 秒预算,失败或超时降级为 MISSING/UNKNOWN 并保留旧实时字段;MISSING 不等于设备不支持。 operationId: queryRealtimeVehicles security: - AppKeyAuth: [] @@ -608,8 +612,11 @@ components: description: true仅合作站,false仅外部站,省略则返回全部 HydrogenResult: type: object - required: [plateNumber, date, hydrogenConsumptionKg, status] + required: [vin, plateNumber, date, hydrogenConsumptionKg, statisticsStartTime, statisticsEndTime, updatedAt, status] properties: + vin: + type: string + description: 授权车辆 VIN,供跨接口身份校验 plateNumber: type: string date: @@ -620,17 +627,32 @@ components: format: double nullable: true description: 单日用氢量,kg;OK 和 SUSPECT 计算结果均返回数值,无数据时为 null + statisticsStartTime: + type: string + format: date-time + nullable: true + description: FINAL 证据区间最早开始时间;PRELIMINARY 仅有结束水位故为 null;缺失或异常证据为 null。边界是证据包络,不承诺连续覆盖 + statisticsEndTime: + type: string + format: date-time + nullable: true + description: FINAL 证据区间最晚结束时间,PRELIMINARY 为真实 lastEventTime 水位;缺失或异常证据为 null;不是 API 查询时间 + updatedAt: + type: string + format: date-time + nullable: true + description: 同一用氢统计行的数据库更新时间,RFC 3339 带时区;无统计行时为 null calculationPhase: type: string enum: [PRELIMINARY, FINAL] - description: 当天流式结果为 PRELIMINARY,日终重算结果为 FINAL + description: PRELIMINARY 仅供标注为初步的日内监控;FINAL 表示批量重算已完成,补传或算法调整仍可能再次重算,不保证固定结算时刻 algorithmVersion: type: string description: 氢耗计算算法版本 qualityStatus: type: string enum: [OK, SUSPECT, NO_DATA] - description: 氢耗计算质量状态 + description: OK 对应 NORMAL;SUSPECT 对应 DATA_ANOMALY 并保留数值供审计,不得用于正常百公里氢耗;NO_DATA 对应无可用统计 qualityReason: type: string description: 质量判定原因;SUSPECT 或 NO_DATA 时供调用方解释和审计 @@ -638,7 +660,7 @@ components: $ref: '#/components/schemas/DataStatus' MileageResult: type: object - required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status] + required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, statisticsStartTime, statisticsEndTime, status] properties: vin: type: string @@ -657,6 +679,16 @@ components: format: double nullable: true description: 当日所选协议最后有效终端累计总里程,km;GPS 日里程估算不会作为累计总里程。status=NORMAL 时必定有值,NO_DATA 时为 null + statisticsStartTime: + type: string + format: date-time + nullable: true + description: 当日统计所选来源的最早 first_event_time,跨日基线可早于当日零点,不是自然日起始;历史结转补零或无数据时为 null + statisticsEndTime: + type: string + format: date-time + nullable: true + description: 当日所选来源最晚 latest_event_time,等于 dataTime;历史结转补零或无数据时为 null。与用氢接口无共同快照保证 dataTime: type: string format: date-time @@ -846,10 +878,69 @@ components: $ref: '#/components/schemas/StationaryVehicleResult' RealtimeVehicleResult: type: object - required: [vin, plateNumber, online, motionStatus, locationAvailable, status] + required: [vin, plateNumber, online, motionStatus, locationAvailable, status, remainingHydrogenKg, remainingHydrogenPercent, hydrogenRecordTime, hydrogenDataStatus, remainingHydrogenKgStatus, remainingHydrogenPercentStatus, hydrogenValueSource, hydrogenSourceProtocol, hydrogenStaleAfterSeconds, hydrogenExpectedIntervalSeconds, gpsFixStatus, locationRecordTime, coordinateSystem] properties: vin: { type: string } plateNumber: { type: string } + remainingHydrogenKg: + type: number + nullable: true + minimum: 0 + maximum: 200 + description: GB32960 广东燃料电池扩展 0x34 全车上报氢质量,kg;真实零保留。无值、不支持或无效为 null;陈旧值保留并标 STALE,不以单瓶压力或当日消耗推算 + remainingHydrogenPercent: + type: number + nullable: true + minimum: 0 + maximum: 100 + description: 当前无可信额定或可用质量容量分母,固定 null,字段状态 UNSUPPORTED;不以 SOC 替代 + hydrogenRecordTime: + type: string + format: date-time + nullable: true + description: 氢质量对应原始同帧实际采集时间,RFC 3339 带时区;不是请求时间或缓存更新时间 + hydrogenDataStatus: + type: string + enum: [NORMAL, PARTIAL, STALE, MISSING, UNSUPPORTED, INVALID] + description: NORMAL 保留供未来全部字段有效;当前不会输出 NORMAL;PARTIAL 部分支持(当前质量有效但百分比不支持);STALE 数据陈旧;MISSING 无记录;UNSUPPORTED 不支持;INVALID 异常。分别检查字段级状态 + remainingHydrogenKgStatus: + type: string + enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID] + remainingHydrogenPercentStatus: + type: string + enum: [NORMAL, STALE, MISSING, UNSUPPORTED, INVALID] + description: 当前固定 UNSUPPORTED + hydrogenValueSource: + type: string + nullable: true + enum: [REPORTED] + description: REPORTED 仅表示终端上报质量,无法确认终端内部采用测量还是估算;本接口不作压力推算 + hydrogenSourceProtocol: + type: string + nullable: true + enum: [GB32960, MQTT, JT808] + description: 储氢数据采用的协议,独立于位置的 protocol + hydrogenStaleAfterSeconds: + type: integer + nullable: true + description: GB32960 服务时效策略为 300 秒;不是协议采样周期承诺 + hydrogenExpectedIntervalSeconds: + type: integer + nullable: true + description: 当前无已确认上报周期,固定 null + gpsFixStatus: + type: string + enum: [FIXED, NO_FIX, UNKNOWN] + description: 实际位置报文定位位;GB32960 bit0=0、JT808 bit1=1 表示 FIXED;MQTT 或缺少可信定位位为 UNKNOWN。不以在线或数据年龄推断 + locationRecordTime: + type: string + format: date-time + nullable: true + description: 位置行对应实际采集时间,独立于主记录 recordTime;历史 FIXED 不因车辆离线改变 + coordinateSystem: + type: string + enum: [WGS84, GCJ02, UNKNOWN] + description: 仅 GB2025 显式坐标类型 1=WGS84、2=GCJ02,其余 UNKNOWN;GB2016/JT808/MQTT 当前无确证为 UNKNOWN,未知坐标系不得直接当高德坐标使用 protocol: type: string enum: [GB32960, MQTT, JT808] diff --git a/vehicle-data-platform/apps/api/internal/openplatform/daily_statistics_interval.go b/vehicle-data-platform/apps/api/internal/openplatform/daily_statistics_interval.go new file mode 100644 index 00000000..87431411 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/daily_statistics_interval.go @@ -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)) +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/daily_statistics_interval_test.go b/vehicle-data-platform/apps/api/internal/openplatform/daily_statistics_interval_test.go new file mode 100644 index 00000000..967047a5 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/daily_statistics_interval_test.go @@ -0,0 +1,86 @@ +package openplatform + +import ( + "context" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestHydrogenStatisticsInterval(t *testing.T) { + cases := []struct{ name, evidence, start, end string }{ + {"live watermark", `{"lastEventTime":"2026-09-08T10:23:45.123Z"}`, "", "2026-09-08T18:23:45.123+08:00"}, + {"final envelope", `[{"startTime":"2026-09-08T02:00:00+08:00","endTime":"2026-09-08T03:00:00+08:00"},{"startTime":"2026-09-08T01:00:00+08:00","endTime":"2026-09-08T01:30:00+08:00"}]`, "2026-09-08T01:00:00+08:00", "2026-09-08T03:00:00+08:00"}, + {"missing", `null`, "", ""}, + {"legacy empty", `[]`, "", ""}, + {"malformed", `{`, "", ""}, + {"reversed", `[{"startTime":"2026-09-08T03:00:00+08:00","endTime":"2026-09-08T02:00:00+08:00"}]`, "", ""}, + {"incomplete", `[{"startTime":"2026-09-08T03:00:00+08:00"}]`, "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + a, b := hydrogenStatisticsInterval(tc.evidence) + if (a == nil) != (tc.start == "") || a != nil && *a != tc.start { + t.Fatalf("start=%v", a) + } + if (b == nil) != (tc.end == "") || b != nil && *b != tc.end { + t.Fatalf("end=%v", b) + } + }) + } +} + +func TestMileageStatisticsIntervalDoesNotRelabelCarryForward(t *testing.T) { + value := DailyMileage{Date: "2026-09-07", StatisticsStartTime: "2026-09-07T00:01:00+08:00", DataTime: "2026-09-07T23:01:00+08:00"} + carried := MileageResult{Date: "2026-09-08"} + fillMileageResult(&carried, value, 0) + if carried.StatisticsStartTime != nil || carried.StatisticsEndTime != nil { + t.Fatalf("fabricated carry interval: %+v", carried) + } + current := MileageResult{Date: value.Date} + fillMileageResult(¤t, value, 10) + if current.StatisticsStartTime == nil || current.StatisticsEndTime == nil { + t.Fatalf("missing evidence: %+v", current) + } +} + +func TestDailyHydrogenReadsEvidenceAndUpdateFromSameRow(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatal(err) + } + defer db.Close() + mock.ExpectQuery("SELECT vin,DATE_FORMAT.*COALESCE\\(evidence_json,'null'\\).*DATE_FORMAT\\(updated_at").WithArgs("2026-09-08", "VIN1").WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "consumption", "samples", "quality", "reason", "phase", "algorithm", "evidence", "updated"}).AddRow("VIN1", "2026-09-08", 0, 2, "OK", "", "PRELIMINARY", "V3_5", `{"lastEventTime":"2026-09-08T10:00:00+08:00"}`, "2026-09-08T10:01:00.123000+08:00")) + values, err := NewMySQLRepository(db).DailyHydrogen(context.Background(), []string{"VIN1"}, "2026-09-08") + if err != nil { + t.Fatal(err) + } + if values["VIN1"].EvidenceJSON == "" || values["VIN1"].UpdatedAt == "" { + t.Fatal(values) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +func TestDailyQueryRejectsSameCountWrongAuthorizationMembership(t *testing.T) { + repo := &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"}}} + _, err := NewService(repo).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01"}) + if err != ErrForbidden { + t.Fatalf("authorization membership mismatch must fail closed: %v", err) + } + if len(repo.dailyVINs) != 0 { + t.Fatalf("queried data after failed authorization: %v", repo.dailyVINs) + } +} + +func TestDailyQueryIgnoresUnrequestedVINResults(t *testing.T) { + repo := &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}}, hydrogen: map[string]DailyHydrogen{"LTEST32960VIN0002": {ConsumptionKg: 99, QualityStatus: "OK"}}} + rows, err := NewService(repo).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01"}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].VIN != "LTEST32960VIN0001" || rows[0].HydrogenConsumptionKg != nil { + t.Fatalf("unexpected VIN leaked: %+v", rows) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go b/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go index 52b16c1d..578daabd 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/docs_test.go @@ -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") + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/model.go b/vehicle-data-platform/apps/api/internal/openplatform/model.go index 871de8fc..23724bcc 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/model.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/model.go @@ -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 { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go index b8c975ac..1564d325 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go @@ -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 { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go index 81a14b5a..731fa2bb 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go @@ -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 { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/realtime_live.go b/vehicle-data-platform/apps/api/internal/openplatform/realtime_live.go new file mode 100644 index 00000000..f80c9bce --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/realtime_live.go @@ -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 +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/realtime_live_test.go b/vehicle-data-platform/apps/api/internal/openplatform/realtime_live_test.go new file mode 100644 index 00000000..78903aa0 --- /dev/null +++ b/vehicle-data-platform/apps/api/internal/openplatform/realtime_live_test.go @@ -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) + } +} diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service.go b/vehicle-data-platform/apps/api/internal/openplatform/service.go index 904f0562..0f69047c 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service.go @@ -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 diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go index a77ddeb3..0ac42b6d 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go @@ -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]) } diff --git a/vehicle-data-platform/docs/oneos-vehicle-live-api-contract.md b/vehicle-data-platform/docs/oneos-vehicle-live-api-contract.md new file mode 100644 index 00000000..1d96be75 --- /dev/null +++ b/vehicle-data-platform/docs/oneos-vehicle-live-api-contract.md @@ -0,0 +1,77 @@ +# OneOS 氢能车辆实况接口交付契约 + +契约修订日期:2026-09-08。对应 `/api/v1/vehicles/realtime/query`、`/mileage/query`、`/hydrogen-consumption/query` 的增量交付。实际发布版本、上线时间及脱敏联调证据由发布验收记录提供,本文不将开发完成等同于线上验收完成。 + +## 兼容性与授权 + +认证继续使用 `Authorization: Bearer `;成功响应继续包含 `code / message / data / traceId`。原请求参数不变;新字段增量添加。`plateNumbers` 省略或空数组表示全部有效授权车辆,指定名单时按授权校验后查询,消费者必须核对 VIN、车牌和日期,不能只按车牌串接不同车辆。报警、违章、企业名称、Seeker 本地车辆编号均不在本次新增范围。 + +完整机器契约见服务 `/open-api/openapi.yaml`(路径以服务文档入口为准),内嵌 HTML 同步说明新增字段。 + +## 实时储氢与定位 + +| 字段 | 类型/单位 | 本次口径 | +| --- | --- | --- | +| remainingHydrogenKg | number/null,kg | GB32960 广东燃料电池扩展 0x34 终端全车氢质量直接上报;保留真实零,不汇总单瓶、不由压力推算 | +| remainingHydrogenPercent | number/null,% | 当前缺少可信质量容量分母,固定 null,字段级 UNSUPPORTED;不使用 SOC 或当日消耗替代 | +| hydrogenRecordTime | RFC 3339/null | 精确匹配 raw_frames 原始同帧采集时间,非合并快照更新时间 | +| hydrogenDataStatus | enum | NORMAL/PARTIAL/STALE/MISSING/UNSUPPORTED/INVALID;当前 kg 有效但百分比不支持为 PARTIAL | +| remainingHydrogenKgStatus / remainingHydrogenPercentStatus | enum | 分别为 NORMAL/STALE/MISSING/UNSUPPORTED/INVALID,客户端独立判断 | +| hydrogenValueSource / hydrogenSourceProtocol | string/null | REPORTED 仅表示终端上报,无法确认终端内部采用测量还是估算;储氢协议独立于位置 protocol | +| hydrogenStaleAfterSeconds | integer/null,秒 | GB32960 服务陈旧阈值 300;并非协议规定更新频率 | +| hydrogenExpectedIntervalSeconds | integer/null,秒 | 各协议未确认上报周期,当前为 null | +| gpsFixStatus | FIXED/NO_FIX/UNKNOWN | 来源为实际位置报文定位位,不根据在线、运动或记录年龄推断 | +| locationRecordTime | RFC 3339/null | 位置实际采集时间,可与主记录 recordTime 不同 | +| coordinateSystem | WGS84/GCJ02/UNKNOWN | 仅 GB2025 显式坐标类型 1/2 对应 WGS84/GCJ02;GB2016/JT808/MQTT 暂无确证为 UNKNOWN | + +平台接受 0–200 kg,超出范围、非有限值或采集时间超过请求时刻 1 分钟标为 INVALID 并返回 null。超过 300 秒的有效质量保留数值并标 STALE,客户端需提示陈旧;PARTIAL 仍需逐字段判断。聚合 NORMAL 保留供未来两字段均有效,目前不会输出。原始帧未找到返回 MISSING;补充历史查询共享 3 秒预算,查询异常或超时降级为 MISSING/UNKNOWN 并保留旧实时字段,不以合并快照伪造采集时间。因此 MISSING 既可能暂无记录,也可能本次未取得可信证据,不能据此断言设备不支持。 + +有界回补仅针对具有 GB 最新快照原始帧引用、且精确帧氢量为 MISSING 的车辆:查询该快照 `received_at` 向前 5 分钟内含氢量键的原始帧,按 `event_time DESC, ts DESC` 取最新候选。窗口以快照接收时间为基准,不是 API 当前时间;离线车辆可返回真实的 STALE。显式 null 或异常候选不被过滤成旧正常值;已有 INVALID/PARTIAL/STALE 不回补,不能掩盖最新异常。回补所得氢量及 hydrogenRecordTime 始终来自同一原始帧,仍共享 3 秒预算;超时或无记录维持 MISSING。该策略不会扫描全部历史,也不承诺找到窗口外最近一条氢量。 + +储氢覆盖以收到扩展字段的 GB32960 车辆为限,不代表所有 GB32960 车型支持;MQTT/JT808 当前不支持储氢。平台不提供凭容量未知的百分比换算,因而本次不保证 Seeker 所有车辆两列均有值。多瓶完整性由终端全车上报负责,接口没有逐瓶完整性检测能力。 + +定位使用位置行的 event_id 对应原始报文:GB 定位状态 bit0=0 表示 FIXED;JT808 bit1=1 表示 FIXED;缺少可信定位位为 UNKNOWN。NO_FIX 时位置不可用、经纬度 null。历史 FIXED 可与 offline 同时存在;显示历史位置需提示位置时间,UNKNOWN 坐标系不得擅自作为 GCJ02 上图。现有 `protocol` 是实时唯一协议字段,枚举 GB32960/MQTT/JT808;`sourceProtocol` 属于里程接口。`online=false` 时 motionStatus=offline;在线且所选速度>3 km/h 为 driving,否则 idle。 + +## 日统计身份与时间 + +两个接口均以 Asia/Shanghai 自然日请求,新增时间为 RFC 3339 带时区,不改变原 dataTime / updatedAt 的含义。 + +| 接口 | 字段 | 来源及空值 | +| --- | --- | --- | +| 用氢 | vin | 授权车辆 VIN,与实时和里程核对 | +| 用氢 | statisticsStartTime / statisticsEndTime | FINAL 为 evidence 区间 min(startTime) / max(endTime);这是证据包络,不保证连续覆盖。PRELIMINARY 只有 lastEventTime 水位,start=null;证据缺失或异常为 null | +| 用氢 | updatedAt | 同一 daily_energy 行的 updated_at,无统计行为 null | +| 里程 | statisticsStartTime / statisticsEndTime | 同一所选来源 MIN(first_event_time) / MAX(latest_event_time),start 可为前一日跨日基线采样,不能夹到 00:00 伪造自然日起始;end 等于 dataTime;历史结转的当日 0 里程、无记录或异常缺少有效边界时为 null | + +两个接口独立读取投影,**没有共同快照或严格同步统计保证**。同日、NORMAL、相近 updatedAt 均不足以证明可以相除。客户端至少需边界非空且一致,并核查证据覆盖/来源口径;边界仅为包络,相等也不是完整连续同区间的证明。无法证明可比时百公里氢耗为空,保留各自指标展示。里程沿用历史累计值时日里程补 0,是既有兼容规则,不证明当天真实测得零里程。 + +## 日用氢量算法与质量 + +当前算法标识为 `PRESSURE_NIST_VALID_BOUNDARY_CHARGE_CYCLE_V3_5`;客户端应展示实际 `algorithmVersion`,不能据名称自行推定公式或锁定未来版本。 + +数据基础是 GB32960 氢气压力(MPa)、温度(摄氏度)与车型配置的储氢系统水容积,经 NIST 实气压缩因子换算质量(kg): + +```text +Tk = 温度 + 273.15 +m = P × 1000 × 0.00201588 × V / (8.314472 × Tk × Z) +``` + +V 为储氢容积升数,Z 为代码实现的 NIST 压缩因子。算法筛选有效运行分界点,识别日内加氢后分段计算首末质量下降;不直接拿两次 API 响应相减。加氢识别包含持续压力回升与净质量增加补充规则;纯电异常压降、工作状态、运行边界与仪表里程异常参与质量判定。缺少足够有效边界为 `NO_DATA`。此输入不包含逐瓶完整性证明,不能将部分瓶数据包装成已测量全车总量。 + +压力边界无法产生有效正耗氢且满足混动里程等条件时,可用燃料电池电压电流积分折算氢量兜底;此时质量降为 `SUSPECT` 并在 `qualityReason` 说明。`hydrogenConsumptionKg` 返回物理用氢结果,不是 SOC、剩余氢量或 SOC 平衡校正量。压力法本身也是模型估算,不应对外宣称为质量流量计直接测量。 + +| qualityStatus | status | 数值与使用要求 | +| --- | --- | --- | +| OK | NORMAL | 可用于日内监控;还需校验计算阶段和可比区间 | +| SUSPECT | DATA_ANOMALY | 保留数值供审计,禁止作为正常百公里氢耗输入 | +| NO_DATA | NO_DATA | 用氢量为 null;没有统计行时质量/阶段等旧可选字段可能省略 | + +`calculationPhase` 为 `PRELIMINARY` 或 `FINAL`。流式初步结果可变化,`NORMAL + PRELIMINARY + OK` 仅适用于明确标注“初步”的日内监控;不能当成最终日报。`FINAL` 表示已执行批量重算,仍可因补传、参数或算法修订再次重算,并非不可变账单。实际完成时间读取 `updatedAt`;接口不承诺每日固定时刻已经完成 FINAL。正式报表应同时要求 FINAL、OK、NORMAL、有效时间区间,并留存版本和查询快照。 + +## Seeker 必须同步适配 + +1. Go 上游模型增加储氢、GPS、质量及时间字段,映射 `remainingHydrogenKg → leftHydrogen`、`remainingHydrogenPercent → originalLeftHydrogen`;前端分别判断两个字段,保留真实 0,null 显示“—”并提示原因。 +2. `socPercent` 仍为动力电池 SOC,不得替代储氢百分比。`timeDifferenceSeconds / 3600` 是记录年龄(小时),不是实际离线持续时间;移除“72 小时即 GPS 正常”的判断。 +3. 按 VIN、规范化车牌及 date 合并三个接口;本地 truckNum、筛选、排序保持原口径。新字段不会自动接入不在本仓库的 Seeker 代码。 +4. 百公里氢耗由 Seeker 计算 `hydrogenConsumptionKg / dailyMileageKm × 100`,单位 kg/100km。必须保证分子分母有效、里程大于 0、两行 NORMAL、氢量质量 OK,且统计区间可比较;否则返回 null。真实用氢量 0 与正里程可产生 0。历史字段 `dayHydrogenPercent` 并非百分比。 +5. 联调需覆盖真实零、部分字段支持、陈旧、无记录、未支持协议、异常、离线历史位置、无定位、零里程及统计重算。页面验收须在 Seeker 完成上述接入后单独进行。 diff --git a/vehicle-data-platform/docs/oneos-vehicle-live-api-release-20260908.md b/vehicle-data-platform/docs/oneos-vehicle-live-api-release-20260908.md new file mode 100644 index 00000000..17cc8c65 --- /dev/null +++ b/vehicle-data-platform/docs/oneos-vehicle-live-api-release-20260908.md @@ -0,0 +1,49 @@ +# 车辆实况接口补充:发布与验收记录 + +需求依据:2026-09-08《OneOS 氢能车辆实况接口补充需求》。交付范围是本仓库 OneOS 开放平台,Seeker 客户端代码不在本仓库,未将接口上线等同于客户端页面已完成适配。 + +## 交付范围 + +- 实时查询:储氢 kg、百分比接口字段、字段级状态、真实采集时间、来源和陈旧阈值;GPS 定位位、位置采集时间和可确认的坐标系。 +- 日用氢量:VIN、来自同一统计记录的可空证据起止时间、更新时间。 +- 日里程:所选来源真实证据起止时间,历史累计结转不伪造当日区间。 +- 认证、原请求体、响应外壳和旧数值口径兼容;加强请求车牌与授权映射成员校验。 +- 内嵌 OpenAPI 1.8.0、HTML 文档以及 `oneos-vehicle-live-api-contract.md`。 + +## 验收方法 + +1. 执行 API 模块 `go test ./...`、开放平台 `go test -race ./internal/openplatform`、`go vet ./internal/openplatform`,以及发布脚本自测与 diff-check。 +2. 在生产机器仅监听 127.0.0.1:20311 的候选进程读取真实数据;以已有授权车辆为样本,创建临时测试凭证,调用三个正式 HTTP 接口,测试完删除临时凭证与授权,保留接口审计记录。凭证没有写入交付文件。 +3. 每轮批量 100 台,检查结果数量、车牌白名单、VIN 一致性、必备新增字段、真实零、百分比空值、NO_FIX 坐标为空、401 未认证、403 未授权名单。覆盖当日及前一日统计。 +4. 对实时快照与原始帧进行脱敏核对,确认合并快照会保留旧字段、最新帧可能没有储氢单元;回补须使用真正含储氢字段的原始帧及原采集时间。 +5. 原始回查以每 VIN 五分钟接收时间窗、四并发、共享三秒预算限制成本;测试缺数据、显式异常、未来时间、陈旧、历史离线定位、故障降级、错 VIN、跨日结转及证据重算变化。 + +候选联调中发现并修复了两个仅靠 SQL mock 无法充分覆盖的问题:DATE_FORMAT 秒标记与 IN 参数模板冲突;TDengine 的排序分区查询 LIMIT 实际限制全局行数,回补最终改为每 VIN 单独查询。上线前进一步通过真实 SQL 核对。 + +## 交付限制 + +- 百分比目前 `null / UNSUPPORTED`:没有可信全车容量分母,不能使用电池 SOC、单瓶值或猜测容量替代。 +- `REPORTED` 仅确认终端上报质量;无法确认终端内部采用测量还是估算。全车口径依赖已识别的 GB32960 广东扩展,不代表全部车型和协议均支持。 +- 坐标系没有明确原始编码时为 UNKNOWN,不能宣称统一 GCJ02;真实定位状态与在线、新旧独立。 +- 两个日统计接口没有共同快照保证;PRELIMINARY 氢耗开始时间通常未知,不能仅凭同日就作为严格同步百公里氢耗依据。 +- 回补有明确时间窗;原始记录不可用、没有相关字段或查询超时仍可能 MISSING,不伪造数值或采集时间。 +- Seeker 须自行增加字段映射、状态展示和 VIN/统计区间核验,接口添加不会自动改变其页面。 + +## 发布证据 + +发布结果、二进制 SHA-256、正式接口烟测和脱敏样本保存在工作区 `outputs/live-api-release-20260908/`。最终发布与验收结果如下: + + +- 正式版本:`open-platform-live-fields-202609082146`,北京时间 2026-09-08 21:46:08 切换,21:46:09 健康检查就绪。 +- 服务:`lingniu-vehicle-open-platform`,端口 20310。无数据库迁移;沿用原门户静态资源,更新开放平台 API 及其内嵌接口文档。 +- 正式二进制 SHA-256:`724491000e1010dc20960e0abb7db9aceac0dd99dc9867cd993f339da00ac97c`,与验收候选一致。 +- 原版本 `open-platform-suspect-20260904162007` 和本次首版 `open-platform-live-fields-202609082130` 均保留。安装器保留失败时恢复旧版本的逻辑;本次正式切换成功,未执行生产回滚演练。 +- 最终全量 Go 测试、race、vet、文档测试、YAML 校验、发布脚本测试和 diff-check 均通过。 +- 最终候选四轮:实时接口 244–275 ms,100 台样本中 74–77 台有 kg,0 台 MISSING;其余为不支持协议。包括当日及前一日统计。各轮重新选择活跃授权样本,不是固定车辆群组,不应把不同轮覆盖率当作同一群组趋势。 +- 正式接口三轮(每轮 100 台):实时 446/335/295 ms;里程 15/15/18 ms;用氢 6/6/6 ms。三接口均 SUCCESS,结果数量及车牌/VIN 核验通过,401/403 负向用例通过。 +- 正式样本 kg 非空 59/69/71 台,包含 3/4/4 个真实零;MISSING 33/22/19 台;UNSUPPORTED 8/9/10 台。未把缺失样本认定为已有有效储氢量,也不承诺全量车辆覆盖。无 enrichment 错误日志,仍应按真实来源和回查窗口判断可用性。 +- 正式 GPS 样本识别 NO_FIX 4/5/5 台,经纬度及 locationAvailable 验证一致;所有样本坐标系为 UNKNOWN,未伪报统一坐标系。 +- `http://115.29.187.205:20310/healthz`、`/open-api/openapi.yaml`、`/open-api/docs/` 公网检查 200 且版本/字段匹配;门户 catalog 正常,systemd active,无异常退出。 +- 本轮所有临时测试应用与授权均删除,测试用凭证未输出。没有修改既有合作方密钥和授权。 + +结论:开放平台本次增量接口及其兼容、权限、真实来源、空值和发布行为验收通过;储氢百分比数据接入、未知坐标系的供应方确认、统一统计快照和 Seeker 页面适配仍属于上述明确限制,不宣称这些数据/客户端工作已经完成。