feat: 上线历史剩余氢质量批量查询接口
This commit is contained in:
@@ -24,7 +24,7 @@
|
||||
<a class="brand" href="#overview"><span class="brand-name">羚牛 · 数据开放平台</span><span class="brand-subtitle">车辆数据 API Reference</span></a>
|
||||
<nav>
|
||||
<div class="nav-group"><span class="nav-title">开始使用</span><a class="nav-link docs" href="#overview">平台简介</a><a class="nav-link docs" href="#authentication">认证与授权</a><a class="nav-link docs" href="#request-examples">请求体样例</a></div>
|
||||
<div class="nav-group"><span class="nav-title">车辆数据</span><a class="nav-link primary" href="#daily-hydrogen">车辆单日用氢量</a><a class="nav-link primary" href="#daily-mileage">车辆单日里程</a><a class="nav-link primary" href="#mileage-range">车辆区间日里程</a><a class="nav-link primary" href="#total-mileage">指定时刻总里程</a><a class="nav-link primary" href="#realtime">实时位置与状态</a></div>
|
||||
<div class="nav-group"><span class="nav-title">车辆数据</span><a class="nav-link primary" href="#daily-hydrogen">车辆单日用氢量</a><a class="nav-link primary" href="#historical-hydrogen">历史剩余氢质量</a><a class="nav-link primary" href="#daily-mileage">车辆单日里程</a><a class="nav-link primary" href="#mileage-range">车辆区间日里程</a><a class="nav-link primary" href="#total-mileage">指定时刻总里程</a><a class="nav-link primary" href="#realtime">实时位置与状态</a></div>
|
||||
<div class="nav-group"><span class="nav-title">加氢服务</span><a class="nav-link station" href="#stationary">加氢车辆停留核验</a><a class="nav-link station" href="#stations">加氢站地图点位</a></div>
|
||||
<div class="nav-group"><span class="nav-title">通用说明</span><a class="nav-link docs" href="#response-fields">返回字段与枚举</a><a class="nav-link docs" href="#protocols">总里程协议口径</a><a class="nav-link docs" href="#errors">异常与状态码</a></div>
|
||||
</nav>
|
||||
@@ -69,6 +69,16 @@
|
||||
"cooperateOnly": true
|
||||
}</pre></article></div></section>
|
||||
|
||||
<section class="section" id="historical-hydrogen"><div class="section-head"><div><h2>历史时刻剩余氢质量 · 1.10.0</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/hydrogen-remaining/history/query</div><p>仅查询2026-08-01起的真实历史 event_time,不使用未来采样、实时值或日用氢量填补。最近氢相关帧异常或字段不全时显式返回,不跳过它找旧正常值。</p></div><a class="anchor" href="#historical-hydrogen">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>queries</td><td>是</td><td>1–200项</td></tr><tr><td>queries[].requestId</td><td>是</td><td>本批唯一非空,最多128字节,原样回显</td></tr><tr><td>queries[].vin / time</td><td>是</td><td>VIN规范化大写17位且不含I/O/Q;北京时间yyyy-MM-dd HH:mm:ss,不可未来或早于2026-08-01</td></tr><tr><td>queries[].protocol</td><td>否</td><td>GB32960 / YUTONG_MQTT / JT808;省略固定GB32960,后两者当前UNSUPPORTED,不跨协议回退</td></tr><tr><td>maxTimeDifferenceSeconds</td><td>否</td><td>整数0–300,默认300;0要求精确命中</td></tr></table><div class="note">应用与逐车授权需同时覆盖查询时刻和采样时刻(结束时间不含端点);FORBIDDEN不泄露采样元数据。当前appKey无效/过期为401,非法结构400。</div></div><div><h3 class="subhead">请求示例(非真实读数)</h3><pre class="code">{
|
||||
"queries": [{
|
||||
"requestId": "job-start",
|
||||
"vin": "LA9GG68L2PBAF4773",
|
||||
"time": "2026-08-03 16:02:43",
|
||||
"protocol": "GB32960"
|
||||
}],
|
||||
"maxTimeDifferenceSeconds": 300
|
||||
}</pre><p>合法批量HTTP200/SUCCESS,逐项状态判断:NORMAL、NO_DATA、MISSING、STALE、UNSUPPORTED、INVALID、FORBIDDEN、ERROR。非NORMAL质量null,不丢项,不补零。</p><p>每app每实例30批/60秒,最多2个并发批;429读取Retry-After秒后重试。整批9秒预算、4个工作协程,超时项ERROR而不是NO_DATA。200项是结构上限,不保证200个不同点均能按时完成;建议从20个不同点开始,超时缩小批量并退避重试。</p></div></div><div class="note">历史容量需要历史适用版本;当前表不能回套历史。当前只有REPORTED质量可用,缺历史容量版本时不估算,返回MISSING。同批按规范化VIN+time+protocol去重,同点共享单次查询/授权结果和来源版本,所有requestId独立回显;不同点仍是非原子快照,保留各项版本和原始证据。质量保留原精度,Seeker自行计算两端净减少量并在负差值时归零。</div></section>
|
||||
|
||||
<section class="section" id="daily-hydrogen"><div class="section-head"><div><h2>车辆单日用氢量</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/hydrogen-consumption/query</div><p>查询车辆单日用氢量,单位 kg。NORMAL + OK + PRELIMINARY 仅供初步监控;正式报表要求 FINAL 并核对证据与版本。两个日统计接口没有共同快照,区间缺失或不同不能直接计算百公里氢耗。</p></div><a class="anchor" href="#daily-hydrogen">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>date</td><td class="required">是</td><td>日期,格式 yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional">否</td><td>车牌数组;省略或 [] 时查询全部授权车辆</td></tr></table></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"plateNumber"</span>: <span class="string">"浙F06618F"</span>,<br> <span class="key">"hydrogenConsumptionKg"</span>: <span class="number">12.315</span>,<br> <span class="key">"status"</span>: <span class="string">"NORMAL"</span><br> }]<br>}</pre><ul class="errors"><li>400:日期或车牌格式不正确</li><li>401:appKey 无效、停用或过期</li><li>403:指定车辆未授权</li></ul></div></div></section>
|
||||
|
||||
<section class="section" id="daily-mileage"><div class="section-head"><div><h2>车辆单日里程</h2><div class="endpoint"><span class="method">POST</span>/api/v1/vehicles/mileage/query</div><p>返回当日行驶里程、当日累计总里程和实际选用的数据协议,单位 km。</p></div><a class="anchor" href="#daily-mileage">#</a></div><div class="grid"><div><h3 class="subhead">请求参数</h3><table class="param-table"><tr><th>字段</th><th>必填</th><th>说明</th></tr><tr><td>date</td><td class="required">是</td><td>日期,格式 yyyy-MM-dd</td></tr><tr><td>plateNumbers</td><td class="optional">否</td><td>省略时查询全部授权车辆</td></tr><tr><td>protocolPriority</td><td class="optional">否</td><td>协议选源顺序,例如 ["GB32960","MQTT","JT808"]</td></tr></table><div class="note"><strong>缺数规则:</strong>若当日没有有效里程,日里程为 0;累计总里程沿用上一个有效统计周期,计算时间也显示该周期时间。</div></div><div><h3 class="subhead">成功返回 · 200</h3><pre class="code">{<br> <span class="key">"code"</span>: <span class="string">"SUCCESS"</span>,<br> <span class="key">"data"</span>: [{<br> <span class="key">"dailyMileageKm"</span>: <span class="number">182.437</span>,<br> <span class="key">"totalMileageKm"</span>: <span class="number">12345.679</span>,<br> <span class="key">"sourceProtocol"</span>: <span class="string">"GB32960"</span>,<br> <span class="key">"status"</span>: <span class="string">"NORMAL"</span><br> }]<br>}</pre><ul class="errors"><li>400:日期、车牌或协议参数错误</li><li>403:授权期未覆盖查询日</li><li>无统计:单车以 NO_DATA 返回</li></ul></div></div></section>
|
||||
@@ -98,6 +108,19 @@
|
||||
["traceId", "string", "本次请求的唯一追踪标识;出现问题时请完整提供给羚牛技术支持。"]
|
||||
];
|
||||
const fields = {
|
||||
"historical-hydrogen": [
|
||||
["data[].requestId / vin / queryTime", "string", "逐项对应请求;不得只依赖数组位置配对作业起终点。"],
|
||||
["data[].plateNumber", "string | null", "当前缺少历史车牌证据,为null,不把当前车牌冒充历史车牌。"],
|
||||
["data[].remainingHydrogenKg / remainingHydrogenKgStatus", "number | null / enum", "仅NORMAL给原始精度kg;其他状态null。有效质量超过容差STALE;最新INVALID/MISSING保留原状态。无历史氢相关帧NO_DATA;分块重组失败或扫描预算耗尽ERROR,不得回退。"],
|
||||
["data[].hydrogenRecordTime / timeDifferenceSeconds", "datetime | null / number | null", "真实event_time(RFC3339带时区)与查询采样偏差秒数,绝不使用查询之后的帧。"],
|
||||
["data[].hydrogenValueSource / hydrogenSourceProtocol", "string | null", "当前REPORTED表示终端上报(不等同直接测量),GB32960为实际氢协议。ESTIMATED保留但缺历史容积版本时不输出估算值。"],
|
||||
["data[].sourceRecordId", "string | null", "raw-sha256采样身份指纹,便于识别同一采样命中;内容版本另看sourceDataVersion,不是批次快照。"],
|
||||
["data[].sourceDataVersion", "string | null", "sha256原始JSON与解析状态内容指纹,识别同采样修订;不同起终点内容不同是正常的,不要求两端此字段相等。"],
|
||||
["data[].hydrogenCalculationVersion / hydrogenCapacityVersion", "string | null", "REPORTED_HYDROGEN_KG_V1解码转换版本;历史容量版本缺证据为null,不能用当前值回套。"],
|
||||
["data[].updatedAt", "datetime | null", "原始记录接收时间,不代替采集时间;即使时间不变,内容修订仍会改变sourceDataVersion。"],
|
||||
["data[].reasonCode / message", "string | null", "可程序识别原因与中文说明;ERROR应重试,不能当NO_DATA;FORBIDDEN不返回采样证据。"],
|
||||
["data[].hydrogenEstimatePressureMPa / hydrogenEstimateTemperatureC / hydrogenTankCapacityL / hydrogenPressureTemperatureSource", "number | null / string | null", "有同帧温压可返回诊断证据;MAX_SENSOR_AGGREGATE不保证同瓶。没有历史容量证据仍MISSING,容积null。"]
|
||||
],
|
||||
"daily-hydrogen": [
|
||||
["data[]", "array", "按请求车牌顺序返回的车辆数据数组。"],
|
||||
["data[].vin", "string", "授权车辆 VIN,必须与实时、里程记录共同核对。"],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆数据开放平台 API
|
||||
version: 1.9.0
|
||||
version: 1.10.0
|
||||
license:
|
||||
name: Proprietary
|
||||
description: |
|
||||
@@ -16,6 +16,55 @@ tags:
|
||||
- name: 开放平台管理
|
||||
description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口
|
||||
paths:
|
||||
/api/v1/vehicles/hydrogen-remaining/history/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 批量查询历史时刻剩余氢质量
|
||||
description: |
|
||||
按真实 event_time 查询不晚于指定时刻的最近氢相关帧,不跳过最新异常记录,不使用实时值或日用氢量填补。
|
||||
历史支持从 2026-08-01 起;实际车辆/时间覆盖以保留的原始帧为准,日期可请求不代表每个时点均有有效氢量。
|
||||
历史容量必须具有适用于历史时点的证据;不能用当前配置回套历史。当前缺少历史容量版本,只输出有证据的REPORTED质量,温压有效但无容量版本时 MISSING。
|
||||
同批按规范化VIN+time+protocol去重,同一点共享一次查询/授权结果与来源版本,各requestId仍独立回显;不同点为非原子快照,不以同批证明两端可比。
|
||||
200项仅为结构上限,不保证200个不同点能在9秒完成。建议先用20个不同点,ERROR超时时缩小批量并退避重试。
|
||||
有效采样超容差为STALE,最新帧INVALID/MISSING优先保留原原因;分块原始帧重组失败或候选预算耗尽为ERROR,不伪装NO_DATA。
|
||||
每项均返回 requestId;FORBIDDEN、ERROR 等失败不丢项,非 NORMAL 质量为 null。
|
||||
appKey须当前有效,并逐项校验请求时刻与采样时刻授权(结束时间不含端点);FORBIDDEN不返回采样元数据。
|
||||
每app每服务实例30批/60秒,最多2个并发批;429包含Retry-After。整批9秒预算、最多4个工作协程,超时项ERROR,可重试。
|
||||
operationId: queryHistoricalHydrogenRemaining
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HistoricalHydrogenQuery'
|
||||
example:
|
||||
queries:
|
||||
- requestId: job-start
|
||||
vin: LA9GG68L2PBAF4773
|
||||
time: '2026-08-03 16:02:43'
|
||||
protocol: GB32960
|
||||
maxTimeDifferenceSeconds: 300
|
||||
responses:
|
||||
'200':
|
||||
description: 批量处理完成;每项状态独立判断,包括 FORBIDDEN 或 ERROR
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HistoricalHydrogenQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'429':
|
||||
description: 超出历史查询限流;读取 Retry-After 秒数后重试
|
||||
headers:
|
||||
Retry-After:
|
||||
schema: { type: integer, minimum: 1 }
|
||||
description: 建议等待秒数
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v1/vehicles/hydrogen-consumption/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
@@ -529,7 +578,7 @@ components:
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
pattern: '^[A-HJ-NPR-Z0-9]{17}$'
|
||||
pattern: '^[A-HJ-NPR-Za-hj-npr-z0-9]{17}$'
|
||||
description: 已授权车辆 VIN
|
||||
time:
|
||||
type: string
|
||||
@@ -598,6 +647,67 @@ components:
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: 可选;省略或传空数组时查询当前有效授权的全部车辆
|
||||
HistoricalHydrogenQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [queries]
|
||||
properties:
|
||||
queries:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 200
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [requestId, vin, time]
|
||||
properties:
|
||||
requestId: { type: string, minLength: 1, maxLength: 128, description: 本批唯一非空关联标识,最多128字节,原样返回 }
|
||||
vin: { type: string, minLength: 17, maxLength: 17, pattern: '^[A-HJ-NPR-Za-hj-npr-z0-9]{17}$', description: 查询VIN按大写规范化,禁止I/O/Q }
|
||||
time: { type: string, description: '北京时间 yyyy-MM-dd HH:mm:ss;不得早于2026-08-01或晚于请求时刻' }
|
||||
protocol: { type: string, enum: [GB32960, YUTONG_MQTT, JT808], default: GB32960, description: 省略固定GB32960;显式协议不切换,YUTONG_MQTT/JT808当前UNSUPPORTED }
|
||||
maxTimeDifferenceSeconds:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 300
|
||||
default: 300
|
||||
description: 允许采样早于查询的最大秒数;0要求精确命中,不静默放宽
|
||||
HistoricalHydrogenQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items: { $ref: '#/components/schemas/HistoricalHydrogenResult' }
|
||||
HistoricalHydrogenResult:
|
||||
type: object
|
||||
required: [requestId, vin, plateNumber, queryTime, remainingHydrogenKg, hydrogenRecordTime, timeDifferenceSeconds, remainingHydrogenKgStatus, hydrogenValueSource, hydrogenSourceProtocol, sourceRecordId, sourceDataVersion, hydrogenEstimatePressureMPa, hydrogenEstimateTemperatureC, hydrogenTankCapacityL, hydrogenPressureTemperatureSource, hydrogenCalculationVersion, hydrogenCapacityVersion, updatedAt, reasonCode, message]
|
||||
properties:
|
||||
requestId: { type: string, description: 本批对应请求标识 }
|
||||
vin: { type: string }
|
||||
plateNumber: { type: string, nullable: true, description: 无可证明历史车牌时为 null,不冒用当前车牌 }
|
||||
queryTime: { type: string, description: 请求北京时间 }
|
||||
remainingHydrogenKg: { type: number, nullable: true, minimum: 0, description: '全车剩余氢质量kg,保留源/模型计算精度;仅NORMAL可用,其他状态null。不得用缺值补0' }
|
||||
hydrogenRecordTime: { type: string, format: date-time, nullable: true, description: 真实原始帧 event_time,RFC3339带时区 }
|
||||
timeDifferenceSeconds: { type: number, nullable: true, minimum: 0, description: queryTime减真实采集时间的秒数 }
|
||||
remainingHydrogenKgStatus:
|
||||
type: string
|
||||
enum: [NORMAL, NO_DATA, MISSING, STALE, UNSUPPORTED, INVALID, FORBIDDEN, ERROR]
|
||||
description: 仅NORMAL参与业务计算;ERROR明确为处理失败,可按原因重试;FORBIDDEN不泄露采样
|
||||
hydrogenValueSource: { type: string, nullable: true, enum: [REPORTED, ESTIMATED], description: 终端上报不等同于直接测量;估算需历史温压/容积证据 }
|
||||
hydrogenSourceProtocol: { type: string, nullable: true, enum: [GB32960, YUTONG_MQTT, JT808] }
|
||||
sourceRecordId: { type: string, nullable: true, description: raw-sha256采样身份指纹;两端相同需识别采样分辨率不足,内容修订另看sourceDataVersion }
|
||||
sourceDataVersion: { type: string, nullable: true, description: 'sha256原始JSON和解析状态内容指纹;同采样被修订时变化,不是两端必须相等的口径版本' }
|
||||
hydrogenEstimatePressureMPa: { type: number, nullable: true, description: 有效同帧最大氢压MPa证据,缺历史容量仍可诊断返回 }
|
||||
hydrogenEstimateTemperatureC: { type: number, nullable: true, description: 有效同帧最大氢温摄氏度证据 }
|
||||
hydrogenTankCapacityL: { type: number, nullable: true, description: 当前缺历史容量版本始终null,不能回套当前容积 }
|
||||
hydrogenPressureTemperatureSource: { type: string, nullable: true, enum: [MAX_SENSOR_AGGREGATE], description: 最大温压聚合不保证同瓶 }
|
||||
hydrogenCalculationVersion: { type: string, nullable: true, description: 当前REPORTED_HYDROGEN_KG_V1,质量不额外舍入;ESTIMATED预留但当前缺历史容积版本不输出估算值 }
|
||||
hydrogenCapacityVersion: { type: string, nullable: true, description: 历史时点适用容积版本;估算必需,无证据不估算 }
|
||||
updatedAt: { type: string, format: date-time, nullable: true, description: 原始记录received_at,RFC3339带时区;不代替采集时间,同时间戳修订通过sourceDataVersion识别 }
|
||||
reasonCode: { type: string, nullable: true, description: 可程序识别的状态原因 }
|
||||
message: { type: string, nullable: true, description: 中文状态说明 }
|
||||
HydrogenStationQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
@@ -116,3 +116,19 @@ func TestLiveContractDocumentationCoversSerializedFieldsAndQuality(t *testing.T)
|
||||
t.Error("HTML must not advertise sourceProtocol as realtime field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenDocumentationDefinesEvidenceAndFailureBoundaries(t *testing.T) {
|
||||
for name, doc := range map[string]string{"OpenAPI": string(openAPISpec), "HTML": string(simpleDocsHTML)} {
|
||||
for _, want := range []string{
|
||||
"/api/v1/vehicles/hydrogen-remaining/history/query",
|
||||
"requestId", "maxTimeDifferenceSeconds", "sourceRecordId",
|
||||
"hydrogenCapacityVersion", "sourceDataVersion", "reasonCode", "2026-08-01", "FORBIDDEN", "ERROR",
|
||||
"STALE", "429", "Retry-After", "event_time", "历史容量", "非原子",
|
||||
"20个不同点", "去重",
|
||||
} {
|
||||
if !strings.Contains(doc, want) {
|
||||
t.Errorf("%s missing history contract evidence/failure boundary %q", name, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ func NewExternalHandler(service *Service, portal *PortalService) *Handler {
|
||||
|
||||
func (h *Handler) registerExternalDataRoutes() {
|
||||
h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen)
|
||||
h.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, h.historicalHydrogen)
|
||||
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
||||
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
|
||||
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
|
||||
@@ -109,6 +110,7 @@ func (h *Handler) registerPortalRoutes() {
|
||||
func NewDataHandler(service *Service) *Handler {
|
||||
handler := &Handler{service: service, mux: http.NewServeMux()}
|
||||
handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen)
|
||||
handler.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, handler.historicalHydrogen)
|
||||
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
||||
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
|
||||
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
|
||||
@@ -123,7 +125,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func IsPublicPath(path string) bool {
|
||||
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
||||
return path == HistoricalHydrogenQueryPath || path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
||||
}
|
||||
|
||||
func (h *Handler) stationaryVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -754,6 +756,7 @@ func requestRemoteAddress(r *http.Request) string {
|
||||
|
||||
func dataProducts() []DataProduct {
|
||||
return []DataProduct{
|
||||
{Code: "historical_hydrogen_remaining", Name: "历史剩余氢质量", Description: "按VIN与历史北京时间批量查询全车剩余氢质量、采样时间及来源证据。", Version: "v1", Status: "available", Method: http.MethodPost, Path: HistoricalHydrogenQueryPath, Unit: "kg"},
|
||||
{
|
||||
Code: "daily_hydrogen", Name: "单日用氢量",
|
||||
Description: "按车牌和自然日查询授权车辆的氢气消耗量。",
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/vehicleprotocol"
|
||||
)
|
||||
|
||||
const HistoricalHydrogenQueryPath = "/api/v1/vehicles/hydrogen-remaining/history/query"
|
||||
const historicalHydrogenBudget = 9 * time.Second
|
||||
|
||||
var historicalVINPattern = regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`)
|
||||
|
||||
type HistoricalHydrogenQuery struct {
|
||||
RequestID string `json:"requestId"`
|
||||
VIN string `json:"vin"`
|
||||
Time string `json:"time"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
type HistoricalHydrogenRequest struct {
|
||||
Queries []HistoricalHydrogenQuery `json:"queries"`
|
||||
MaxTimeDifferenceSeconds *int `json:"maxTimeDifferenceSeconds,omitempty"`
|
||||
}
|
||||
|
||||
// Optional numeric/protocol fields may be omitted, but explicit JSON null is
|
||||
// not a valid integer or protocol identifier. Preserve strict unknown-field
|
||||
// rejection even though this request has a custom decoder.
|
||||
func (r *HistoricalHydrogenRequest) UnmarshalJSON(data []byte) error {
|
||||
type plain HistoricalHydrogenRequest
|
||||
var decoded plain
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&decoded); err != nil {
|
||||
return err
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if value, exists := raw["maxTimeDifferenceSeconds"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
return fmt.Errorf("maxTimeDifferenceSeconds must be an integer")
|
||||
}
|
||||
var queries []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw["queries"], &queries); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, query := range queries {
|
||||
if value, exists := query["protocol"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
return fmt.Errorf("protocol must be a string")
|
||||
}
|
||||
}
|
||||
*r = HistoricalHydrogenRequest(decoded)
|
||||
return nil
|
||||
}
|
||||
|
||||
type HistoricalHydrogenResult struct {
|
||||
RequestID string `json:"requestId"`
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber *string `json:"plateNumber"`
|
||||
QueryTime string `json:"queryTime"`
|
||||
RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"`
|
||||
HydrogenRecordTime *string `json:"hydrogenRecordTime"`
|
||||
TimeDifferenceSeconds *float64 `json:"timeDifferenceSeconds"`
|
||||
RemainingHydrogenKgStatus string `json:"remainingHydrogenKgStatus"`
|
||||
HydrogenValueSource *string `json:"hydrogenValueSource"`
|
||||
HydrogenSourceProtocol *string `json:"hydrogenSourceProtocol"`
|
||||
SourceRecordID *string `json:"sourceRecordId"`
|
||||
SourceDataVersion *string `json:"sourceDataVersion"`
|
||||
HydrogenCalculationVersion *string `json:"hydrogenCalculationVersion"`
|
||||
HydrogenCapacityVersion *string `json:"hydrogenCapacityVersion"`
|
||||
UpdatedAt *string `json:"updatedAt"`
|
||||
ReasonCode *string `json:"reasonCode"`
|
||||
Message *string `json:"message"`
|
||||
HydrogenEstimatePressureMPa *float64 `json:"hydrogenEstimatePressureMPa"`
|
||||
HydrogenEstimateTemperatureC *float64 `json:"hydrogenEstimateTemperatureC"`
|
||||
HydrogenTankCapacityL *float64 `json:"hydrogenTankCapacityL"`
|
||||
HydrogenPressureTemperatureSource *string `json:"hydrogenPressureTemperatureSource"`
|
||||
}
|
||||
|
||||
type HistoricalHydrogenRepository interface {
|
||||
HistoricalHydrogen(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error)
|
||||
HistoricalHydrogenAuthorized(context.Context, uint64, string, time.Time, time.Time) (bool, error)
|
||||
}
|
||||
|
||||
type parsedHistoricalHydrogenQuery struct {
|
||||
HistoricalHydrogenQuery
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (s *Service) validateHistoricalHydrogen(request HistoricalHydrogenRequest, now time.Time) ([]parsedHistoricalHydrogenQuery, int, error) {
|
||||
if len(request.Queries) < 1 || len(request.Queries) > 200 {
|
||||
return nil, 0, fmt.Errorf("%w: queries must contain 1-200 items", ErrInvalidRequest)
|
||||
}
|
||||
tolerance := 300
|
||||
if request.MaxTimeDifferenceSeconds != nil {
|
||||
tolerance = *request.MaxTimeDifferenceSeconds
|
||||
}
|
||||
if tolerance < 0 || tolerance > 300 {
|
||||
return nil, 0, fmt.Errorf("%w: tolerance must be 0-300", ErrInvalidRequest)
|
||||
}
|
||||
seen := make(map[string]bool, len(request.Queries))
|
||||
parsed := make([]parsedHistoricalHydrogenQuery, 0, len(request.Queries))
|
||||
lower := time.Date(2026, 8, 1, 0, 0, 0, 0, s.location)
|
||||
for _, query := range request.Queries {
|
||||
if strings.TrimSpace(query.RequestID) == "" || len(query.RequestID) > 128 || seen[query.RequestID] {
|
||||
return nil, 0, fmt.Errorf("%w: requestId must be nonempty and unique", ErrInvalidRequest)
|
||||
}
|
||||
seen[query.RequestID] = true
|
||||
query.VIN = strings.ToUpper(query.VIN)
|
||||
if !historicalVINPattern.MatchString(query.VIN) {
|
||||
return nil, 0, fmt.Errorf("%w: invalid vin", ErrInvalidRequest)
|
||||
}
|
||||
at, err := time.ParseInLocation("2006-01-02 15:04:05", query.Time, s.location)
|
||||
if err != nil || at.Format("2006-01-02 15:04:05") != query.Time {
|
||||
return nil, 0, fmt.Errorf("%w: invalid datetime", ErrInvalidRequest)
|
||||
}
|
||||
if at.After(now) || at.Before(lower) {
|
||||
return nil, 0, fmt.Errorf("%w: time outside supported history", ErrInvalidRequest)
|
||||
}
|
||||
if query.Protocol == "" {
|
||||
query.Protocol = vehicleprotocol.GB32960
|
||||
} else if protocol, ok := vehicleprotocol.Canonical(query.Protocol); !ok || protocol != query.Protocol {
|
||||
return nil, 0, fmt.Errorf("%w: invalid protocol", ErrInvalidRequest)
|
||||
}
|
||||
parsed = append(parsed, parsedHistoricalHydrogenQuery{HistoricalHydrogenQuery: query, at: at})
|
||||
}
|
||||
return parsed, tolerance, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryHistoricalHydrogen(ctx context.Context, appKey, traceID string, request HistoricalHydrogenRequest) ([]HistoricalHydrogenResult, error) {
|
||||
now := s.now().In(s.location)
|
||||
queries, tolerance, err := s.validateHistoricalHydrogen(request, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !appKeyPattern.MatchString(appKey) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
bounded, cancel := context.WithTimeout(ctx, historicalHydrogenBudget)
|
||||
defer cancel()
|
||||
app, err := s.repository.Authenticate(bounded, sha256.Sum256([]byte(strings.ToLower(appKey))), now, now, now)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnauthorized) {
|
||||
return nil, ErrUnauthorized
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
release, err := s.historicalLimiter.acquire(app.ID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer release()
|
||||
results := make([]HistoricalHydrogenResult, len(queries))
|
||||
for i, query := range queries {
|
||||
results[i] = historicalHydrogenFailure(query, "ERROR", "UPSTREAM_TIMEOUT", "历史查询超时,请重试")
|
||||
}
|
||||
repository, ok := s.repository.(HistoricalHydrogenRepository)
|
||||
if !ok {
|
||||
for i, query := range queries {
|
||||
results[i] = historicalHydrogenFailure(query, "ERROR", "HISTORY_UNAVAILABLE", "历史数据服务暂不可用")
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
// Deduplicate identical sampling points within this batch. Every requestId
|
||||
// still receives its own result, while duplicate endpoints share one source
|
||||
// revision and one pair of authorization checks.
|
||||
var unique []parsedHistoricalHydrogenQuery
|
||||
var destinations [][]int
|
||||
indexes := make(map[string]int, len(queries))
|
||||
for i, query := range queries {
|
||||
key := query.VIN + "\x00" + query.Time + "\x00" + query.Protocol
|
||||
index, exists := indexes[key]
|
||||
if !exists {
|
||||
index = len(unique)
|
||||
indexes[key] = index
|
||||
unique = append(unique, query)
|
||||
destinations = append(destinations, nil)
|
||||
}
|
||||
destinations[index] = append(destinations[index], i)
|
||||
}
|
||||
type completed struct {
|
||||
index int
|
||||
result HistoricalHydrogenResult
|
||||
}
|
||||
jobs := make(chan int, len(unique))
|
||||
out := make(chan completed, len(unique))
|
||||
for i := range unique {
|
||||
jobs <- i
|
||||
}
|
||||
close(jobs)
|
||||
workers := 4
|
||||
if len(unique) < workers {
|
||||
workers = len(unique)
|
||||
}
|
||||
for worker := 0; worker < workers; worker++ {
|
||||
go func() {
|
||||
for i := range jobs {
|
||||
if bounded.Err() != nil {
|
||||
return
|
||||
}
|
||||
out <- completed{i, s.queryHistoricalHydrogenItem(bounded, repository, app.ID, unique[i], tolerance, now)}
|
||||
}
|
||||
}()
|
||||
}
|
||||
for count := 0; count < len(unique); count++ {
|
||||
select {
|
||||
case result := <-out:
|
||||
for _, destination := range destinations[result.index] {
|
||||
item := result.result
|
||||
item.RequestID = queries[destination].RequestID
|
||||
results[destination] = item
|
||||
}
|
||||
case <-bounded.Done():
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
// Use the remaining shared budget; avoid extending a timed-out batch for audit.
|
||||
if bounded.Err() == nil {
|
||||
_ = s.repository.Audit(bounded, app.ID, "historical_hydrogen_query", "success", traceID, len(results), "")
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func historicalHydrogenFailure(query parsedHistoricalHydrogenQuery, status, reason, message string) HistoricalHydrogenResult {
|
||||
return HistoricalHydrogenResult{RequestID: query.RequestID, VIN: query.VIN, QueryTime: query.Time, RemainingHydrogenKgStatus: status, ReasonCode: historicalString(reason), Message: historicalString(message)}
|
||||
}
|
||||
func historicalString(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
func historicalTime(value time.Time, location *time.Location) *string {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
text := value.In(location).Format(time.RFC3339Nano)
|
||||
return &text
|
||||
}
|
||||
|
||||
func (s *Service) queryHistoricalHydrogenItem(ctx context.Context, repository HistoricalHydrogenRepository, appID uint64, query parsedHistoricalHydrogenQuery, tolerance int, now time.Time) HistoricalHydrogenResult {
|
||||
failure := func(err error) HistoricalHydrogenResult {
|
||||
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
|
||||
return historicalHydrogenFailure(query, "ERROR", "UPSTREAM_TIMEOUT", "历史查询超时,请重试")
|
||||
}
|
||||
return historicalHydrogenFailure(query, "ERROR", "UPSTREAM_FAILURE", "历史查询失败,请重试")
|
||||
}
|
||||
allowed, err := repository.HistoricalHydrogenAuthorized(ctx, appID, query.VIN, query.at, now)
|
||||
if err != nil {
|
||||
return failure(err)
|
||||
}
|
||||
if !allowed {
|
||||
return historicalHydrogenFailure(query, "FORBIDDEN", "HISTORY_FORBIDDEN", "车辆或历史时刻未授权")
|
||||
}
|
||||
if query.Protocol != vehicleprotocol.GB32960 {
|
||||
return historicalHydrogenFailure(query, "UNSUPPORTED", "PROTOCOL_UNSUPPORTED", "该协议暂不支持历史储氢质量")
|
||||
}
|
||||
point, err := repository.HistoricalHydrogen(ctx, query.VIN, query.at, query.Protocol)
|
||||
if err != nil {
|
||||
return failure(err)
|
||||
}
|
||||
if point == nil {
|
||||
return historicalHydrogenFailure(query, "NO_DATA", "NO_HISTORICAL_SAMPLE", "查询时刻前没有历史储氢采样")
|
||||
}
|
||||
if point.VIN != query.VIN || point.Protocol != query.Protocol {
|
||||
return historicalHydrogenFailure(query, "ERROR", "SOURCE_IDENTITY_MISMATCH", "历史采样身份不一致")
|
||||
}
|
||||
if point.ObservedAt.IsZero() || point.ObservedAt.After(query.at) {
|
||||
return historicalHydrogenFailure(query, "ERROR", "INVALID_SAMPLE_TIME", "历史采样时间不符合查询条件")
|
||||
}
|
||||
allowed, err = repository.HistoricalHydrogenAuthorized(ctx, appID, query.VIN, point.ObservedAt, now)
|
||||
if err != nil {
|
||||
return failure(err)
|
||||
}
|
||||
if !allowed {
|
||||
return historicalHydrogenFailure(query, "FORBIDDEN", "HISTORY_FORBIDDEN", "车辆或历史时刻未授权")
|
||||
}
|
||||
result := HistoricalHydrogenResult{RequestID: query.RequestID, VIN: query.VIN, QueryTime: query.Time, RemainingHydrogenKgStatus: point.Status, HydrogenRecordTime: historicalTime(point.ObservedAt, s.location), HydrogenValueSource: historicalString(point.ValueSource), HydrogenSourceProtocol: historicalString(point.Protocol), SourceRecordID: historicalString(point.RecordID), SourceDataVersion: historicalString(point.SourceDataVersion), HydrogenCalculationVersion: historicalString(point.CalculationVersion), HydrogenCapacityVersion: historicalString(point.CapacityVersion), UpdatedAt: historicalTime(point.UpdatedAt, s.location), ReasonCode: historicalString(point.ReasonCode), Message: historicalString(point.Message), HydrogenEstimatePressureMPa: point.EstimatePressureMPa, HydrogenEstimateTemperatureC: point.EstimateTemperatureC, HydrogenTankCapacityL: point.TankCapacityL, HydrogenPressureTemperatureSource: historicalString(point.PressureTemperatureSource)}
|
||||
difference := query.at.Sub(point.ObservedAt).Seconds()
|
||||
result.TimeDifferenceSeconds = &difference
|
||||
switch result.RemainingHydrogenKgStatus {
|
||||
case "NORMAL", "NO_DATA", "MISSING", "STALE", "UNSUPPORTED", "INVALID":
|
||||
default:
|
||||
return historicalHydrogenFailure(query, "ERROR", "INVALID_SOURCE_STATUS", "历史数据状态异常")
|
||||
}
|
||||
if result.RemainingHydrogenKgStatus == "NORMAL" {
|
||||
if point.RemainingHydrogenKg == nil {
|
||||
result.RemainingHydrogenKgStatus = "MISSING"
|
||||
result.ReasonCode = historicalString("MISSING_MASS")
|
||||
} else if math.IsNaN(*point.RemainingHydrogenKg) || math.IsInf(*point.RemainingHydrogenKg, 0) || *point.RemainingHydrogenKg < 0 {
|
||||
result.RemainingHydrogenKgStatus = "INVALID"
|
||||
result.ReasonCode = historicalString("INVALID_MASS")
|
||||
} else if difference > float64(tolerance) {
|
||||
result.RemainingHydrogenKgStatus = "STALE"
|
||||
result.ReasonCode = historicalString("SAMPLE_OUTSIDE_TOLERANCE")
|
||||
result.Message = historicalString("采样时间早于允许容差")
|
||||
} else if point.ValueSource == "ESTIMATED" && (point.CalculationVersion == "" || point.CapacityVersion == "" || point.EstimatePressureMPa == nil || point.EstimateTemperatureC == nil || point.TankCapacityL == nil || point.PressureTemperatureSource == "") {
|
||||
result.RemainingHydrogenKgStatus = "MISSING"
|
||||
result.ReasonCode = historicalString("MISSING_ESTIMATE_EVIDENCE")
|
||||
} else {
|
||||
value := *point.RemainingHydrogenKg
|
||||
result.RemainingHydrogenKg = &value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) HistoricalHydrogenAuthorized(ctx context.Context, appID uint64, vin string, at, now time.Time) (bool, error) {
|
||||
var exists bool
|
||||
err := r.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM vehicle_open_app a JOIN vehicle_open_app_vehicle g ON g.app_id=a.id WHERE a.id=? AND a.status='enabled' AND a.valid_from<=? AND (a.valid_to IS NULL OR a.valid_to>?) AND a.valid_from<=? AND (a.valid_to IS NULL OR a.valid_to>?) AND BINARY g.vin=BINARY ? AND g.valid_from<=? AND (g.valid_to IS NULL OR g.valid_to>?))`, appID, now, now, at, at, vin, at, at).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
type historicalHydrogenRateError struct{ RetryAfter int }
|
||||
|
||||
func (e *historicalHydrogenRateError) Error() string {
|
||||
return "historical hydrogen rate limit exceeded"
|
||||
}
|
||||
|
||||
type historicalHydrogenRateEntry struct {
|
||||
start time.Time
|
||||
count, active int
|
||||
}
|
||||
type historicalHydrogenLimiter struct {
|
||||
mu sync.Mutex
|
||||
entries map[uint64]*historicalHydrogenRateEntry
|
||||
}
|
||||
|
||||
func (l *historicalHydrogenLimiter) acquire(appID uint64, now time.Time) (func(), error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.entries == nil {
|
||||
l.entries = make(map[uint64]*historicalHydrogenRateEntry)
|
||||
}
|
||||
for id, entry := range l.entries {
|
||||
if entry.active == 0 && now.Sub(entry.start) >= 2*time.Minute {
|
||||
delete(l.entries, id)
|
||||
}
|
||||
}
|
||||
entry := l.entries[appID]
|
||||
if entry == nil {
|
||||
entry = &historicalHydrogenRateEntry{start: now}
|
||||
l.entries[appID] = entry
|
||||
}
|
||||
if now.Sub(entry.start) >= time.Minute {
|
||||
entry.start = now
|
||||
entry.count = 0
|
||||
}
|
||||
if entry.active >= 2 {
|
||||
return nil, &historicalHydrogenRateError{RetryAfter: 1}
|
||||
}
|
||||
if entry.count >= 30 {
|
||||
return nil, &historicalHydrogenRateError{RetryAfter: max(1, int(math.Ceil(time.Minute.Seconds()-now.Sub(entry.start).Seconds())))}
|
||||
}
|
||||
entry.count++
|
||||
entry.active++
|
||||
return func() { l.mu.Lock(); entry.active--; l.mu.Unlock() }, nil
|
||||
}
|
||||
func (h *Handler) historicalHydrogen(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request HistoricalHydrogenRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryHistoricalHydrogen(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
var limit *historicalHydrogenRateError
|
||||
if errors.As(err, &limit) {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(limit.RetryAfter))
|
||||
writeExternal(w, http.StatusTooManyRequests, ExternalResponse{Code: "RATE_LIMITED", Message: "请求过于频繁,请稍后重试", TraceID: traceID})
|
||||
return
|
||||
}
|
||||
writeExternalError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHistoricalHydrogenCrossMidnightKeepsInstantAndRevisionEvidence(t *testing.T) {
|
||||
service, repository, _ := historicalTestService()
|
||||
start := time.Date(2026, 8, 3, 23, 59, 55, 0, service.location)
|
||||
end := start.Add(20 * time.Second)
|
||||
repository.lookup = func(_ context.Context, _ string, at time.Time, _ string) (*HistoricalHydrogenPoint, error) {
|
||||
point := historicalTestPoint(at, 30.50012345)
|
||||
point.RecordID = at.Format(time.RFC3339)
|
||||
if at.Equal(end) {
|
||||
point.RemainingHydrogenKg = realLiveFloat(29.80012345)
|
||||
point.SourceDataVersion = "end-v2"
|
||||
}
|
||||
return point, nil
|
||||
}
|
||||
request := HistoricalHydrogenRequest{Queries: []HistoricalHydrogenQuery{
|
||||
{RequestID: "job-end", VIN: historicalTestVIN, Time: end.Format("2006-01-02 15:04:05")},
|
||||
{RequestID: "job-start", VIN: historicalTestVIN, Time: start.Format("2006-01-02 15:04:05")},
|
||||
}}
|
||||
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "cross-midnight", request)
|
||||
if err != nil || len(results) != 2 {
|
||||
t.Fatalf("%+v %v", results, err)
|
||||
}
|
||||
for i, at := range []time.Time{end, start} {
|
||||
got := results[i]
|
||||
if got.RequestID != request.Queries[i].RequestID || got.QueryTime != request.Queries[i].Time || got.HydrogenRecordTime == nil || *got.HydrogenRecordTime != at.Format(time.RFC3339) || got.RemainingHydrogenKgStatus != "NORMAL" || *got.TimeDifferenceSeconds != 0 {
|
||||
t.Fatalf("midnight instant lost: %+v", got)
|
||||
}
|
||||
}
|
||||
if *results[0].RemainingHydrogenKg != 29.80012345 || *results[1].RemainingHydrogenKg != 30.50012345 || *results[0].SourceRecordID == *results[1].SourceRecordID || *results[0].SourceDataVersion == *results[1].SourceDataVersion {
|
||||
t.Fatal("endpoint precision or distinct provenance lost")
|
||||
}
|
||||
}
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HistoricalHydrogenStart is the first supported event-time boundary. This is
|
||||
// an API coverage boundary, not a claim that every VIN has retained records.
|
||||
var HistoricalHydrogenStart = time.Date(2026, 8, 1, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60))
|
||||
|
||||
type HistoricalHydrogenPoint struct {
|
||||
VIN, Protocol, RecordID, SourceDataVersion string
|
||||
ObservedAt, ReceivedAt, UpdatedAt time.Time
|
||||
RemainingHydrogenKg *float64
|
||||
Status, ValueSource, CalculationVersion, CapacityVersion, ReasonCode, Message string
|
||||
EstimatePressureMPa, EstimateTemperatureC, TankCapacityL *float64
|
||||
PressureTemperatureSource string
|
||||
}
|
||||
|
||||
type historicalHydrogenFrame struct {
|
||||
VIN, Protocol, EventID, FrameID, Parsed, ParseStatus string
|
||||
EventMS, ReceivedMS, TimestampMS sql.NullInt64
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) HistoricalHydrogen(ctx context.Context, vin string, at time.Time, protocol string) (*HistoricalHydrogenPoint, error) {
|
||||
if protocol == "" {
|
||||
protocol = "GB32960"
|
||||
}
|
||||
if protocol != "GB32960" {
|
||||
return &HistoricalHydrogenPoint{VIN: vin, Protocol: protocol, Status: "UNSUPPORTED", ReasonCode: "UNSUPPORTED_HYDROGEN_PROTOCOL", Message: "该协议暂不支持历史剩余氢质量"}, nil
|
||||
}
|
||||
if r.tdengine == nil {
|
||||
return nil, errors.New("TDengine is not configured for historical hydrogen query")
|
||||
}
|
||||
query, err := historicalHydrogenQuery(r.tdDatabase, vin, at, protocol)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for candidate := 0; candidate < 32; candidate++ {
|
||||
var frame historicalHydrogenFrame
|
||||
frame, err = r.loadHistoricalHydrogenCandidate(ctx, query)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if frame.VIN != vin || frame.Protocol != protocol {
|
||||
return nil, errors.New("historical hydrogen source identity mismatch")
|
||||
}
|
||||
if err := r.hydrateHistoricalHydrogenFrame(ctx, &frame); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
point := historicalHydrogenFromFrame(frame)
|
||||
if point.ObservedAt.IsZero() || point.ObservedAt.After(at) || point.ObservedAt.Before(HistoricalHydrogenStart) {
|
||||
return nil, errors.New("historical hydrogen source time out of bounds")
|
||||
}
|
||||
if point.ReasonCode != "MISSING_HYDROGEN_MEASUREMENT" {
|
||||
return &point, nil
|
||||
}
|
||||
// A hydrated chunk can prove that a candidate has no hydrogen fields. Only
|
||||
// in that case move to the next record; invalid/incomplete readings stop.
|
||||
clause := fmt.Sprintf(" AND (event_time<%d OR (event_time=%d AND ts<%d))", frame.EventMS.Int64, frame.EventMS.Int64, frame.TimestampMS.Int64)
|
||||
query = strings.Replace(query, " ORDER BY", clause+" ORDER BY", 1)
|
||||
}
|
||||
return nil, errors.New("historical hydrogen candidate budget exceeded")
|
||||
}
|
||||
|
||||
func historicalHydrogenQuery(database, vin string, at time.Time, protocol string) (string, error) {
|
||||
if !validTDIdentifier(database) || vin == "" || protocol != "GB32960" || at.Before(HistoricalHydrogenStart) {
|
||||
return "", errors.New("invalid historical hydrogen query")
|
||||
}
|
||||
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||
fields := []string{`parsed_json LIKE '%"chunked":true%'`}
|
||||
for _, key := range append(append([]string{}, hydrogenMassFields...), realtimeHydrogenPressureField, realtimeHydrogenTemperatureField) {
|
||||
fields = append(fields, "parsed_json LIKE "+quote("%\""+key+"\":%"))
|
||||
}
|
||||
// Raw rows are JSON-marshaled by ingestion (compact key-colon syntax). Do not
|
||||
// filter invalid values/parse status or restrict arrival <= query event time:
|
||||
// a delayed upload remains valid historical evidence for its collection time.
|
||||
return `SELECT vin,protocol,event_id,frame_id,CAST(event_time AS BIGINT),CAST(received_at AS BIGINT),CAST(ts AS BIGINT),parsed_json,parse_status FROM ` + database + `.raw_frames WHERE vin=` + quote(vin) + ` AND protocol=` + quote(protocol) + ` AND event_time>=` + strconv.FormatInt(HistoricalHydrogenStart.UnixMilli(), 10) + ` AND event_time<=` + strconv.FormatInt(at.UnixMilli(), 10) + ` AND (` + strings.Join(fields, " OR ") + `) ORDER BY event_time DESC,ts DESC LIMIT 2`, nil
|
||||
}
|
||||
|
||||
func historicalHydrogenFromFrame(frame historicalHydrogenFrame) HistoricalHydrogenPoint {
|
||||
point := HistoricalHydrogenPoint{VIN: frame.VIN, Protocol: frame.Protocol, Status: "MISSING"}
|
||||
if frame.EventMS.Valid && frame.EventMS.Int64 > 0 {
|
||||
point.ObservedAt = time.UnixMilli(frame.EventMS.Int64)
|
||||
}
|
||||
if frame.ReceivedMS.Valid && frame.ReceivedMS.Int64 > 0 {
|
||||
point.ReceivedAt = time.UnixMilli(frame.ReceivedMS.Int64)
|
||||
point.UpdatedAt = point.ReceivedAt
|
||||
}
|
||||
// The content fingerprint exposes corrections even if storage keeps the
|
||||
// original arrival timestamp. It is not part of calculation comparability.
|
||||
identity := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%d", frame.VIN, frame.Protocol, frame.EventID, frame.FrameID, frame.EventMS.Int64, frame.TimestampMS.Int64)))
|
||||
point.RecordID = "raw-sha256:" + hex.EncodeToString(identity[:])
|
||||
content := sha256.Sum256([]byte(frame.Parsed + "\x00" + frame.ParseStatus))
|
||||
point.SourceDataVersion = "sha256:" + hex.EncodeToString(content[:])
|
||||
fail := func(status, reason, message string) HistoricalHydrogenPoint {
|
||||
point.Status = status
|
||||
point.ReasonCode = reason
|
||||
point.Message = message
|
||||
return point
|
||||
}
|
||||
if frame.ParseStatus != "OK" {
|
||||
return fail("INVALID", "RAW_FRAME_PARSE_FAILED", "历史原始记录解析异常")
|
||||
}
|
||||
var fields map[string]any
|
||||
if json.Unmarshal([]byte(frame.Parsed), &fields) != nil {
|
||||
return fail("INVALID", "INVALID_RAW_JSON", "历史原始数据格式异常")
|
||||
}
|
||||
for _, key := range hydrogenMassFields {
|
||||
value, exists := fields[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
mass, valid := numericValue(value)
|
||||
point.ValueSource = "REPORTED"
|
||||
point.CalculationVersion = "REPORTED_HYDROGEN_KG_V1"
|
||||
if !valid || math.IsNaN(mass) || math.IsInf(mass, 0) || mass < 0 || mass > 200 {
|
||||
return fail("INVALID", "INVALID_MASS_READING", "历史剩余氢质量读数无效")
|
||||
}
|
||||
point.RemainingHydrogenKg = &mass
|
||||
point.Status = "NORMAL"
|
||||
return point
|
||||
}
|
||||
pv, pExists := fields[realtimeHydrogenPressureField]
|
||||
tv, tExists := fields[realtimeHydrogenTemperatureField]
|
||||
if !pExists && !tExists {
|
||||
return fail("MISSING", "MISSING_HYDROGEN_MEASUREMENT", "历史原始记录缺少氢质量及温压")
|
||||
}
|
||||
pressure, pOK := numericValue(pv)
|
||||
temperature, tOK := numericValue(tv)
|
||||
if (pExists && (!pOK || !finiteRealtime(pressure) || pressure < 0 || pressure > 70)) || (tExists && (!tOK || !finiteRealtime(temperature) || temperature < -40 || temperature > 726.85)) {
|
||||
return fail("INVALID", "INVALID_PRESSURE_TEMPERATURE", "历史温度或压力读数无效")
|
||||
}
|
||||
if !pExists || !tExists {
|
||||
return fail("MISSING", "INCOMPLETE_PRESSURE_TEMPERATURE", "历史同帧温度或压力缺失")
|
||||
}
|
||||
point.EstimatePressureMPa = &pressure
|
||||
point.EstimateTemperatureC = &temperature
|
||||
point.PressureTemperatureSource = "MAX_SENSOR_AGGREGATE"
|
||||
// Current capacity, current stream state and daily aggregate parameters do
|
||||
// not establish a versioned total tank volume valid at this historical event.
|
||||
return fail("MISSING", "MISSING_HISTORICAL_CAPACITY_VERSION", "缺少适用于该历史时点的储氢容积版本证据")
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) hydrateHistoricalHydrogenFrame(ctx context.Context, frame *historicalHydrogenFrame) error {
|
||||
var manifest struct {
|
||||
Chunked bool `json:"chunked"`
|
||||
PayloadKind string `json:"payload_kind"`
|
||||
EventID string `json:"event_id"`
|
||||
ChunkCount int `json:"chunk_count"`
|
||||
}
|
||||
if json.Unmarshal([]byte(frame.Parsed), &manifest) != nil || !manifest.Chunked {
|
||||
return nil
|
||||
}
|
||||
if manifest.ChunkCount <= 0 || manifest.ChunkCount > 256 || (manifest.PayloadKind != "parsed_fields" && manifest.PayloadKind != "parsed_json") || (manifest.EventID != "" && manifest.EventID != frame.EventID) {
|
||||
return errors.New("invalid historical hydrogen chunk manifest")
|
||||
}
|
||||
quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||
query := `SELECT chunk_index,chunk_count,chunk_text FROM ` + r.tdDatabase + `.raw_frame_payload_chunks WHERE vin=` + quote(frame.VIN) + ` AND protocol=` + quote(frame.Protocol) + ` AND event_id=` + quote(frame.EventID) + ` AND frame_id=` + quote(frame.FrameID) + ` AND payload_kind=` + quote(manifest.PayloadKind) + ` ORDER BY chunk_index LIMIT ` + strconv.Itoa(manifest.ChunkCount+1)
|
||||
rows, err := r.tdengine.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
parts := make([]string, manifest.ChunkCount)
|
||||
seen := make([]bool, manifest.ChunkCount)
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var index, total int
|
||||
var part string
|
||||
if err := rows.Scan(&index, &total, &part); err != nil {
|
||||
return err
|
||||
}
|
||||
if index < 0 || index >= len(parts) || seen[index] || total != len(parts) {
|
||||
return errors.New("inconsistent historical hydrogen chunks")
|
||||
}
|
||||
seen[index] = true
|
||||
parts[index] = part
|
||||
count++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if count != len(parts) {
|
||||
return errors.New("incomplete historical hydrogen chunks")
|
||||
}
|
||||
frame.Parsed = strings.Join(parts, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read a second candidate to detect ambiguous ordering across TDengine child
|
||||
// tables. Never select arbitrarily or skip a tied hydrogen frame when advancing
|
||||
// past a hydrated non-hydrogen candidate.
|
||||
func (r *MySQLRepository) loadHistoricalHydrogenCandidate(ctx context.Context, query string) (historicalHydrogenFrame, error) {
|
||||
rows, err := r.tdengine.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return historicalHydrogenFrame{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var first historicalHydrogenFrame
|
||||
scan := func(frame *historicalHydrogenFrame) error {
|
||||
return rows.Scan(&frame.VIN, &frame.Protocol, &frame.EventID, &frame.FrameID, &frame.EventMS, &frame.ReceivedMS, &frame.TimestampMS, &frame.Parsed, &frame.ParseStatus)
|
||||
}
|
||||
if !rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return first, err
|
||||
}
|
||||
return first, sql.ErrNoRows
|
||||
}
|
||||
if err := scan(&first); err != nil {
|
||||
return first, err
|
||||
}
|
||||
if rows.Next() {
|
||||
var second historicalHydrogenFrame
|
||||
if err := scan(&second); err != nil {
|
||||
return first, err
|
||||
}
|
||||
if first.EventMS == second.EventMS && first.TimestampMS == second.TimestampMS {
|
||||
return first, errors.New("ambiguous historical hydrogen records at identical collection and storage timestamps")
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return first, err
|
||||
}
|
||||
return first, nil
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func historicalTestFrame(parsed string) historicalHydrogenFrame {
|
||||
return historicalHydrogenFrame{VIN: "V1", Protocol: "GB32960", EventID: "E1", FrameID: "F1", Parsed: parsed, ParseStatus: "OK", EventMS: sql.NullInt64{Int64: HistoricalHydrogenStart.Add(time.Hour).UnixMilli(), Valid: true}, ReceivedMS: sql.NullInt64{Int64: HistoricalHydrogenStart.Add(2 * time.Hour).UnixMilli(), Valid: true}}
|
||||
}
|
||||
func TestHistoricalHydrogenRawPrecisionAndInvalidPriority(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
parsed, status, reason string
|
||||
mass *float64
|
||||
}{
|
||||
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`, "NORMAL", "", realLiveFloat(0)},
|
||||
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":32.658123456}`, "NORMAL", "", realLiveFloat(32.658123456)},
|
||||
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":-1}`, "INVALID", "INVALID_MASS_READING", nil},
|
||||
{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null,"gd_fc_vehicle_hydrogen_mass_kg":10}`, "INVALID", "INVALID_MASS_READING", nil},
|
||||
{fmtRealtimePT(35, 15), "MISSING", "MISSING_HISTORICAL_CAPACITY_VERSION", nil},
|
||||
{fmtRealtimePT(0, 15), "MISSING", "MISSING_HISTORICAL_CAPACITY_VERSION", nil},
|
||||
{`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":null}`, "INVALID", "INVALID_PRESSURE_TEMPERATURE", nil},
|
||||
{`{"gb32960.fuel_cell.max_hydrogen_pressure_mpa":20}`, "MISSING", "INCOMPLETE_PRESSURE_TEMPERATURE", nil},
|
||||
} {
|
||||
got := historicalHydrogenFromFrame(historicalTestFrame(tc.parsed))
|
||||
if got.Status != tc.status || got.ReasonCode != tc.reason || (got.RemainingHydrogenKg == nil) != (tc.mass == nil) || tc.mass != nil && *got.RemainingHydrogenKg != *tc.mass {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
frame := historicalTestFrame(`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":1}`)
|
||||
first := historicalHydrogenFromFrame(frame)
|
||||
frame.Parsed = `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":2}`
|
||||
second := historicalHydrogenFromFrame(frame)
|
||||
if first.RecordID != second.RecordID || first.SourceDataVersion == second.SourceDataVersion || first.UpdatedAt != second.UpdatedAt {
|
||||
t.Fatal("revision tracking invalid")
|
||||
}
|
||||
frame.ParseStatus = "ERROR"
|
||||
if got := historicalHydrogenFromFrame(frame); got.Status != "INVALID" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
func TestHistoricalHydrogenQueryEventTimeAndSafeBounds(t *testing.T) {
|
||||
q, err := historicalHydrogenQuery("vehicle_ts", "VIN'1", HistoricalHydrogenStart.Add(time.Hour), "GB32960")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"vin='VIN''1'", "event_time>=", "event_time<=", "ORDER BY event_time DESC,ts DESC LIMIT 2", `"chunked":true`} {
|
||||
if !strings.Contains(q, want) {
|
||||
t.Fatal(q)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"parse_status='OK'", "ts<=", "IS NOT NULL"} {
|
||||
if strings.Contains(q, bad) {
|
||||
t.Fatal(q)
|
||||
}
|
||||
}
|
||||
if _, err := historicalHydrogenQuery("bad;sql", "V1", HistoricalHydrogenStart, "GB32960"); err == nil {
|
||||
t.Fatal("unsafe db")
|
||||
}
|
||||
}
|
||||
func TestHistoricalHydrogenRepositoryPreservesDelayedUpload(t *testing.T) {
|
||||
td, mock, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||
at := HistoricalHydrogenStart.Add(time.Hour)
|
||||
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.UnixMilli(), at.Add(time.Hour).UnixMilli(), at.Add(time.Hour).UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":0}`, "OK"))
|
||||
got, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960")
|
||||
if err != nil || got == nil || got.Status != "NORMAL" || !got.ReceivedAt.After(at) {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestHistoricalHydrogenChunkMissingFailsInsteadOfFallback(t *testing.T) {
|
||||
td, mock, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||
frame := historicalTestFrame(`{"chunked":true,"payload_kind":"parsed_fields","event_id":"E1","chunk_count":2}`)
|
||||
mock.ExpectQuery("SELECT chunk_index,chunk_count,chunk_text").WillReturnRows(sqlmock.NewRows([]string{"index", "count", "text"}).AddRow(0, 2, `{"x":`))
|
||||
if err := r.hydrateHistoricalHydrogenFrame(context.Background(), &frame); err == nil {
|
||||
t.Fatal("missing chunk silently accepted")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestHistoricalHydrogenChunkHydration(t *testing.T) {
|
||||
td, mock, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||
frame := historicalTestFrame(`{"chunked":true,"payload_kind":"parsed_fields","event_id":"E1","chunk_count":2}`)
|
||||
mock.ExpectQuery("SELECT chunk_index,chunk_count,chunk_text").WillReturnRows(sqlmock.NewRows([]string{"index", "count", "text"}).AddRow(0, 2, `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":`).AddRow(1, 2, `0}`))
|
||||
if err := r.hydrateHistoricalHydrogenFrame(context.Background(), &frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := historicalHydrogenFromFrame(frame)
|
||||
if got.Status != "NORMAL" || *got.RemainingHydrogenKg != 0 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenNewestInvalidNeverQueriesOlder(t *testing.T) {
|
||||
td, mock, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||
at := HistoricalHydrogenStart.Add(time.Hour)
|
||||
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.UnixMilli(), at.UnixMilli(), at.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":null}`, "OK"))
|
||||
got, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960")
|
||||
if err != nil || got == nil || got.Status != "INVALID" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestHistoricalHydrogenRejectsFutureReturnedRow(t *testing.T) {
|
||||
td, mock, _ := sqlmock.New()
|
||||
defer td.Close()
|
||||
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||
at := HistoricalHydrogenStart.Add(time.Hour)
|
||||
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.Add(time.Second).UnixMilli(), at.UnixMilli(), at.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":10}`, "OK"))
|
||||
if _, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960"); err == nil {
|
||||
t.Fatal("future row accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenTiedCandidatesFailWithoutSkipping(t *testing.T) {
|
||||
for _, parsed := range []string{`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":10}`, `{"chunked":true,"payload_kind":"parsed_fields","event_id":"E1","chunk_count":1}`} {
|
||||
td, mock, _ := sqlmock.New()
|
||||
r := &MySQLRepository{tdengine: td, tdDatabase: "vehicle_ts"}
|
||||
at := HistoricalHydrogenStart.Add(time.Hour)
|
||||
mock.ExpectQuery("SELECT vin,protocol,event_id,frame_id").WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "event_id", "frame_id", "event", "received", "ts", "json", "status"}).AddRow("V1", "GB32960", "E1", "F1", at.UnixMilli(), at.UnixMilli(), at.UnixMilli(), parsed, "OK").AddRow("V1", "GB32960", "E2", "F2", at.UnixMilli(), at.UnixMilli(), at.UnixMilli(), `{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":20}`, "OK"))
|
||||
if _, err := r.HistoricalHydrogen(context.Background(), "V1", at, "GB32960"); err == nil || !strings.Contains(err.Error(), "ambiguous") {
|
||||
t.Fatalf("expected ambiguity, got %v", err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
td.Close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
const historicalTestVIN = "LA9GG68L2PBAF4773"
|
||||
const historicalTestKey = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
type historicalTestRepository struct {
|
||||
*fakeRepository
|
||||
lookup func(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error)
|
||||
allowed func(string, time.Time, time.Time) (bool, error)
|
||||
lookups atomic.Int32
|
||||
}
|
||||
|
||||
func (r *historicalTestRepository) HistoricalHydrogen(ctx context.Context, vin string, at time.Time, protocol string) (*HistoricalHydrogenPoint, error) {
|
||||
r.lookups.Add(1)
|
||||
if r.lookup != nil {
|
||||
return r.lookup(ctx, vin, at, protocol)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (r *historicalTestRepository) HistoricalHydrogenAuthorized(_ context.Context, _ uint64, vin string, at, now time.Time) (bool, error) {
|
||||
if r.allowed != nil {
|
||||
return r.allowed(vin, at, now)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
func historicalTestService() (*Service, *historicalTestRepository, time.Time) {
|
||||
repository := &historicalTestRepository{fakeRepository: &fakeRepository{app: AppCredential{ID: 9}}}
|
||||
service := NewService(repository)
|
||||
now := time.Date(2026, 9, 9, 12, 0, 0, 0, service.location)
|
||||
service.now = func() time.Time { return now }
|
||||
return service, repository, now
|
||||
}
|
||||
func historicalTestRequest(at time.Time) HistoricalHydrogenRequest {
|
||||
return HistoricalHydrogenRequest{Queries: []HistoricalHydrogenQuery{{RequestID: "start", VIN: historicalTestVIN, Time: at.Format("2006-01-02 15:04:05")}}}
|
||||
}
|
||||
func historicalTestPoint(at time.Time, kg float64) *HistoricalHydrogenPoint {
|
||||
return &HistoricalHydrogenPoint{VIN: historicalTestVIN, Protocol: "GB32960", ObservedAt: at, RemainingHydrogenKg: &kg, Status: "NORMAL", ValueSource: "REPORTED", RecordID: "record", SourceDataVersion: "data-v1", CalculationVersion: "decode-v1", UpdatedAt: at}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenValidation(t *testing.T) {
|
||||
service, _, now := historicalTestService()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
mutate func(*HistoricalHydrogenRequest)
|
||||
}{
|
||||
{"empty", func(r *HistoricalHydrogenRequest) { r.Queries = nil }},
|
||||
{"too many", func(r *HistoricalHydrogenRequest) { r.Queries = make([]HistoricalHydrogenQuery, 201) }},
|
||||
{"duplicate id", func(r *HistoricalHydrogenRequest) { r.Queries = append(r.Queries, r.Queries[0]) }},
|
||||
{"blank id", func(r *HistoricalHydrogenRequest) { r.Queries[0].RequestID = " " }},
|
||||
{"bad VIN", func(r *HistoricalHydrogenRequest) { r.Queries[0].VIN = "LA9GG68L2PBAI4773" }},
|
||||
{"fractional time", func(r *HistoricalHydrogenRequest) { r.Queries[0].Time = "2026-09-08 12:00:00.1" }},
|
||||
{"future", func(r *HistoricalHydrogenRequest) {
|
||||
r.Queries[0].Time = now.Add(time.Second).Format("2006-01-02 15:04:05")
|
||||
}},
|
||||
{"before coverage", func(r *HistoricalHydrogenRequest) { r.Queries[0].Time = "2026-07-31 23:59:59" }},
|
||||
{"protocol alias", func(r *HistoricalHydrogenRequest) { r.Queries[0].Protocol = "MQTT" }},
|
||||
{"negative tolerance", func(r *HistoricalHydrogenRequest) { v := -1; r.MaxTimeDifferenceSeconds = &v }},
|
||||
{"too large tolerance", func(r *HistoricalHydrogenRequest) { v := 301; r.MaxTimeDifferenceSeconds = &v }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
request := historicalTestRequest(now.Add(-time.Hour))
|
||||
tc.mutate(&request)
|
||||
if _, _, err := service.validateHistoricalHydrogen(request, now); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
request := historicalTestRequest(now)
|
||||
request.Queries[0].RequestID = " original id "
|
||||
parsed, tolerance, err := service.validateHistoricalHydrogen(request, now)
|
||||
if err != nil || tolerance != 300 || parsed[0].Protocol != "GB32960" || parsed[0].RequestID != " original id " {
|
||||
t.Fatalf("%+v %d %v", parsed, tolerance, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenNormalZeroPrecisionStaleAndInvalid(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kg float64
|
||||
age time.Duration
|
||||
tolerance int
|
||||
status string
|
||||
}{
|
||||
{"zero", 0, 0, 0, "NORMAL"}, {"precision", 12.3456789, time.Second, 300, "NORMAL"}, {"exact tolerance", 2, 300 * time.Second, 300, "NORMAL"}, {"stale", 2, 300*time.Second + time.Millisecond, 300, "STALE"}, {"not exact", 2, time.Millisecond, 0, "STALE"}, {"invalid negative", -1, 0, 300, "INVALID"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
service, repository, now := historicalTestService()
|
||||
at := now.Add(-time.Hour)
|
||||
repository.lookup = func(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error) {
|
||||
return historicalTestPoint(at.Add(-tc.age), tc.kg), nil
|
||||
}
|
||||
request := historicalTestRequest(at)
|
||||
request.MaxTimeDifferenceSeconds = &tc.tolerance
|
||||
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := results[0]
|
||||
if got.RemainingHydrogenKgStatus != tc.status {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if tc.status == "NORMAL" {
|
||||
if got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != tc.kg {
|
||||
t.Fatal("lost zero or precision")
|
||||
}
|
||||
} else if got.RemainingHydrogenKg != nil {
|
||||
t.Fatal("non-normal quantity exposed")
|
||||
}
|
||||
if got.TimeDifferenceSeconds == nil || *got.TimeDifferenceSeconds != tc.age.Seconds() {
|
||||
t.Fatalf("time delta=%v", got.TimeDifferenceSeconds)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenBatchForbiddenNeverLeaksSample(t *testing.T) {
|
||||
service, repository, now := historicalTestService()
|
||||
at := now.Add(-time.Hour)
|
||||
repository.lookup = func(_ context.Context, _ string, at time.Time, _ string) (*HistoricalHydrogenPoint, error) {
|
||||
return historicalTestPoint(at.Add(-time.Minute), 4), nil
|
||||
}
|
||||
repository.allowed = func(_ string, sample, _ time.Time) (bool, error) { return !sample.Before(at), nil }
|
||||
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", historicalTestRequest(at))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := results[0]
|
||||
if got.RemainingHydrogenKgStatus != "FORBIDDEN" || got.RemainingHydrogenKg != nil || got.HydrogenRecordTime != nil || got.SourceRecordID != nil || got.HydrogenSourceProtocol != nil || got.SourceDataVersion != nil || got.UpdatedAt != nil {
|
||||
t.Fatalf("sample leaked: %+v", got)
|
||||
}
|
||||
repository.allowed = func(string, time.Time, time.Time) (bool, error) { return false, nil }
|
||||
repository.lookups.Store(0)
|
||||
results, err = service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", historicalTestRequest(at))
|
||||
if err != nil || repository.lookups.Load() != 0 || results[0].RemainingHydrogenKgStatus != "FORBIDDEN" {
|
||||
t.Fatalf("queried forbidden data: %+v %v", results, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenMixedBatchAndNoProtocolFallback(t *testing.T) {
|
||||
service, repository, now := historicalTestService()
|
||||
at := now.Add(-time.Hour)
|
||||
repository.lookup = func(_ context.Context, _ string, sample time.Time, _ string) (*HistoricalHydrogenPoint, error) {
|
||||
if sample.Equal(at) {
|
||||
return nil, errors.New("private driver query")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
request := historicalTestRequest(at)
|
||||
request.Queries = append(request.Queries, HistoricalHydrogenQuery{RequestID: "none", VIN: historicalTestVIN, Time: at.Add(-time.Minute).Format("2006-01-02 15:04:05")}, HistoricalHydrogenQuery{RequestID: "unsupported", VIN: historicalTestVIN, Time: at.Format("2006-01-02 15:04:05"), Protocol: "JT808"})
|
||||
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, want := range []string{"ERROR", "NO_DATA", "UNSUPPORTED"} {
|
||||
if results[i].RequestID != request.Queries[i].RequestID || results[i].RemainingHydrogenKgStatus != want {
|
||||
t.Fatalf("%+v", results)
|
||||
}
|
||||
}
|
||||
if repository.lookups.Load() != 2 {
|
||||
t.Fatalf("fallback read count=%d", repository.lookups.Load())
|
||||
}
|
||||
encoded, _ := json.Marshal(results)
|
||||
if strings.Contains(string(encoded), "private") {
|
||||
t.Fatal("driver detail leaked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenRejectsFutureOrWrongIdentitySample(t *testing.T) {
|
||||
for _, kind := range []string{"future", "vin", "protocol"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
service, repository, now := historicalTestService()
|
||||
at := now.Add(-time.Hour)
|
||||
repository.lookup = func(context.Context, string, time.Time, string) (*HistoricalHydrogenPoint, error) {
|
||||
point := historicalTestPoint(at, 4)
|
||||
switch kind {
|
||||
case "future":
|
||||
point.ObservedAt = at.Add(time.Second)
|
||||
case "vin":
|
||||
point.VIN = "OTHER"
|
||||
case "protocol":
|
||||
point.Protocol = "JT808"
|
||||
}
|
||||
return point, nil
|
||||
}
|
||||
result, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", historicalTestRequest(at))
|
||||
if err != nil || result[0].RemainingHydrogenKgStatus != "ERROR" || result[0].SourceRecordID != nil {
|
||||
t.Fatalf("%+v %v", result, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenDeadlineKeepsEveryRequestID(t *testing.T) {
|
||||
service, repository, now := historicalTestService()
|
||||
repository.lookup = func(ctx context.Context, _ string, _ time.Time, _ string) (*HistoricalHydrogenPoint, error) {
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
request := historicalTestRequest(now.Add(-time.Hour))
|
||||
for i := 1; i < 8; i++ {
|
||||
query := request.Queries[0]
|
||||
query.RequestID = string(rune('a' + i))
|
||||
request.Queries = append(request.Queries, query)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
results, err := service.QueryHistoricalHydrogen(ctx, historicalTestKey, "trace", request)
|
||||
if err != nil || len(results) != 8 {
|
||||
t.Fatalf("%+v %v", results, err)
|
||||
}
|
||||
for i, got := range results {
|
||||
if got.RequestID != request.Queries[i].RequestID || got.RemainingHydrogenKgStatus != "ERROR" || got.RemainingHydrogenKg != nil {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
if repository.lookups.Load() > 4 {
|
||||
t.Fatal("more than four workers queried")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenHandlerAuthenticationAndRateLimit(t *testing.T) {
|
||||
service, repository, now := historicalTestService()
|
||||
handler := NewDataHandler(service)
|
||||
request := historicalTestRequest(now.Add(-time.Hour))
|
||||
request.Queries[0].Protocol = "JT808"
|
||||
body, _ := json.Marshal(request)
|
||||
call := func() *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(http.MethodPost, HistoricalHydrogenQueryPath, strings.NewReader(string(body)))
|
||||
r.Header.Set("Authorization", "Bearer "+historicalTestKey)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
repository.authErr = ErrUnauthorized
|
||||
if w := call(); w.Code != 401 {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
repository.authErr = nil
|
||||
for i := 0; i < 30; i++ {
|
||||
if w := call(); w.Code != 200 {
|
||||
t.Fatalf("request %d status=%d body=%s", i, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
w := call()
|
||||
if w.Code != 429 || w.Header().Get("Retry-After") != "60" || !strings.Contains(w.Body.String(), "RATE_LIMITED") {
|
||||
t.Fatalf("status=%d headers=%v body=%s", w.Code, w.Header(), w.Body.String())
|
||||
}
|
||||
if !IsPublicPath(HistoricalHydrogenQueryPath) {
|
||||
t.Fatal("missing public route")
|
||||
}
|
||||
}
|
||||
func TestHistoricalHydrogenConcurrentLimitAndWindowReset(t *testing.T) {
|
||||
var limiter historicalHydrogenLimiter
|
||||
now := time.Now()
|
||||
release1, err := limiter.acquire(1, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release2, err := limiter.acquire(1, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := limiter.acquire(1, now); err == nil {
|
||||
t.Fatal("third concurrent batch allowed")
|
||||
}
|
||||
release1()
|
||||
release2()
|
||||
for i := 2; i < 30; i++ {
|
||||
release, err := limiter.acquire(1, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
if _, err := limiter.acquire(1, now); err == nil {
|
||||
t.Fatal("31st batch allowed")
|
||||
}
|
||||
release, err := limiter.acquire(1, now.Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release()
|
||||
}
|
||||
func TestHistoricalHydrogenAuthorizationChecksAppAndGrantAtBothTimes(t *testing.T) {
|
||||
db, mock, _ := sqlmock.New()
|
||||
defer db.Close()
|
||||
now := time.Now()
|
||||
at := now.Add(-time.Hour)
|
||||
mock.ExpectQuery("SELECT EXISTS.*a.valid_to>\\?.*a.valid_to>\\?.*BINARY g.vin=BINARY \\?.*g.valid_to>\\?").WithArgs(uint64(9), now, now, at, at, historicalTestVIN, at, at).WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
allowed, err := NewMySQLRepository(db).HistoricalHydrogenAuthorized(context.Background(), 9, historicalTestVIN, at, now)
|
||||
if err != nil || allowed {
|
||||
t.Fatalf("allowed=%v err=%v", allowed, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenTwoHundredItemsShareEighteenLookups(t *testing.T) {
|
||||
service, repository, now := historicalTestService()
|
||||
repository.lookup = func(_ context.Context, _ string, at time.Time, _ string) (*HistoricalHydrogenPoint, error) {
|
||||
return historicalTestPoint(at, 12.3456789), nil
|
||||
}
|
||||
var request HistoricalHydrogenRequest
|
||||
for i := 0; i < 200; i++ {
|
||||
request.Queries = append(request.Queries, HistoricalHydrogenQuery{RequestID: strconv.Itoa(i), VIN: historicalTestVIN, Time: now.Add(-time.Duration(i%18) * time.Minute).Format("2006-01-02 15:04:05")})
|
||||
}
|
||||
results, err := service.QueryHistoricalHydrogen(context.Background(), historicalTestKey, "trace", request)
|
||||
if err != nil || len(results) != 200 || repository.lookups.Load() != 18 {
|
||||
t.Fatalf("len=%d lookups=%d err=%v", len(results), repository.lookups.Load(), err)
|
||||
}
|
||||
for i, got := range results {
|
||||
if got.RequestID != strconv.Itoa(i) || got.QueryTime != request.Queries[i].Time || got.RemainingHydrogenKgStatus != "NORMAL" || got.RemainingHydrogenKg == nil || *got.RemainingHydrogenKg != 12.3456789 {
|
||||
t.Fatalf("index=%d result=%+v", i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoricalHydrogenDecoderRejectsNullAndUnknownFields(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"queries":[{"requestId":"a","vin":"LA9GG68L2PBAF4773","time":"2026-08-01 00:00:00"}],"maxTimeDifferenceSeconds":null}`,
|
||||
`{"queries":[{"requestId":"a","vin":"LA9GG68L2PBAF4773","time":"2026-08-01 00:00:00","protocol":null}]}`,
|
||||
`{"queries":[{"requestId":"a","vin":"LA9GG68L2PBAF4773","time":"2026-08-01 00:00:00","unexpected":true}]}`,
|
||||
`{"queries":[],"unexpected":true}`,
|
||||
} {
|
||||
var request HistoricalHydrogenRequest
|
||||
if err := json.Unmarshal([]byte(body), &request); err == nil {
|
||||
t.Fatalf("accepted %s", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,9 +347,10 @@ func validCoordinate(longitude, latitude float64) bool {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
location *time.Location
|
||||
historicalLimiter historicalHydrogenLimiter
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
type parsedGrant struct {
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# 历史剩余氢量接口契约
|
||||
|
||||
契约版本:1.10.0;修订日期:2026-09-09。此文档定义实现与使用边界,不代替上线记录或真实历史覆盖验收报告。
|
||||
|
||||
## 用途与业务边界
|
||||
|
||||
`POST /api/v1/vehicles/hydrogen-remaining/history/query` 批量查询指定历史时刻的全车剩余氢质量。认证继续使用 `Authorization: Bearer <appKey>`,外层继续返回 `code / message / data / traceId`。实时剩余氢量和日用氢量接口保持原口径。
|
||||
|
||||
Seeker 负责保存和计算:
|
||||
|
||||
```text
|
||||
rawHydrogenDeltaKg = 起点剩余氢质量 − 终点剩余氢质量
|
||||
hydrogenConsumptionKg = max(rawHydrogenDeltaKg, 0)
|
||||
negativeDeltaClamped = rawHydrogenDeltaKg < 0
|
||||
```
|
||||
|
||||
两端均 NORMAL 且来源、协议、转换版本、容量版本可比时才计算,使用 API 返回的未额外舍入质量值,最后按展示需要舍入。任一端缺失、未授权、异常或不可比较则结果 null。真实两端质量相同、或负差值归零,可以产生0;负的剩余质量本身属于 INVALID。此公式是业务约定的两端储氢净减少量,不代表区间内加氢修正后的实际燃料电池消耗量;不能用日用氢量替代或按时长/里程分摊。
|
||||
|
||||
## 请求与授权
|
||||
|
||||
| 参数 | 约束 |
|
||||
| --- | --- |
|
||||
| queries | 必填,1–200项,单点也使用数组 |
|
||||
| queries[].requestId | 必填、非空、批内唯一、最多128字节,原样回显 |
|
||||
| queries[].vin | 规范化大写17位VIN,不含I/O/Q |
|
||||
| queries[].time | 严格北京时间 `yyyy-MM-dd HH:mm:ss`;从2026-08-01 00:00:00起,不允许未来时刻,更早或非法为400 |
|
||||
| queries[].protocol | GB32960/YUTONG_MQTT/JT808;省略固定GB32960,不跨协议回退,后两者当前UNSUPPORTED |
|
||||
| maxTimeDifferenceSeconds | 整数0–300,默认300;0要求精确采样,不静默放宽 |
|
||||
|
||||
appKey当前无效、停用或过期返回401。合法批量返回HTTP200/SUCCESS,各项以 remainingHydrogenKgStatus 判定;查询时刻及实际采样时刻都必须落在应用和逐车授权范围内,授权结束时间为不含端点。单项FORBIDDEN仍回显requestId/vin/queryTime,采样时间、记录标识、来源、版本等均不泄露。整个请求结构非法返回400,不提交部分查询。
|
||||
|
||||
每个app、每个服务实例限制30批/60秒,最多2个并发批;429的 `Retry-After` 为等待秒数。整个批次9秒预算,最多4个并发工作协程;失败/超时明确为单项ERROR,其他已完成项仍可用。客户端按Retry-After或有限退避重试,不将错误作为无数据缓存;建议按VIN+time+protocol+容差去重,并将作业起终点放同批核对。此限制不是全服务集群共享令牌桶。**200项是请求结构上限,不是200个不同点在9秒完成的性能保证**;建议首次使用20个不同点,出现超时后缩小批量并退避重试。重复点较多的200项测试不能证明200个不同点的吞吐能力,具体候选性能证据见发布验收记录。
|
||||
|
||||
## 历史证据要求
|
||||
|
||||
匹配使用真实 `event_time`,只能采用不晚于查询时刻的历史氢相关采样。最新氢相关帧异常或字段不全时显式返回异常/缺失,不能跳过它拿更旧正常值掩盖。请求时间、接收时间、数据库更新时间和当前实时快照均不能冒充采集时间;不插值、不把日统计还原成瞬时质量。
|
||||
|
||||
有终端质量上报时 `hydrogenValueSource=REPORTED` 仅确认终端上报,不能保证终端内部直接测量。平台估算 `ESTIMATED` 必须使用历史同帧温压及适用于该历史时点、有可追溯版本的 VIN 全车水容积。**当前容积表的现值不是历史有效性证明**,缺少历史适用证据时返回 MISSING,不把修改后的当前容积套回8月记录。最大温压聚合也不保证来自同一瓶,仍需披露选取方法。
|
||||
|
||||
两端可能命中同一原始帧,调用方必须保留 sourceRecordId 与实际采集时间识别采样分辨率不足。同批按规范化VIN+time+protocol去重,同一点共享一次查询/授权结果与来源版本,每个requestId仍各有独立结果;不同点不保证原子数据快照,同批不等于所有点同一修订版本。保存每项数据版本、算法/配置版本、更新时间及 traceId,结果发生变化时重新核对两端。
|
||||
|
||||
## 返回字段与状态
|
||||
|
||||
所有字段存在,无法提供时为null;requestId、vin、queryTime和remainingHydrogenKgStatus为非空基本字段。
|
||||
|
||||
| 字段 | 当前实现 |
|
||||
| --- | --- |
|
||||
| plateNumber | 无历史车牌证据,null,不用当前车牌冒充 |
|
||||
| remainingHydrogenKg | 仅NORMAL返回终端上报有效0–200kg;不额外round3,保留解析后数值精度 |
|
||||
| hydrogenRecordTime / timeDifferenceSeconds | 真实event_time(RFC3339带时区)及与查询时刻的秒差,可含小数 |
|
||||
| hydrogenValueSource / hydrogenSourceProtocol | 当前有效结果REPORTED / GB32960;ESTIMATED为契约预留,目前缺历史容量版本不输出估算值 |
|
||||
| sourceRecordId | raw-sha256采样身份指纹,不包含底层连接配置 |
|
||||
| sourceDataVersion | sha256原始JSON及parseStatus指纹;同一采样原地修订即使received_at不变也能识别 |
|
||||
| hydrogenCalculationVersion | 当前REPORTED_HYDROGEN_KG_V1;算法口径版本不同默认不混算 |
|
||||
| hydrogenCapacityVersion / hydrogenTankCapacityL | 当前无历史适用容积版本,两者null,不读取当前表假装历史有效 |
|
||||
| updatedAt | 原始记录received_at,非API当前时间,也不是采集时间 |
|
||||
| hydrogenEstimatePressureMPa / hydrogenEstimateTemperatureC / hydrogenPressureTemperatureSource | 有效历史同帧温压可供诊断返回,方法MAX_SENSOR_AGGREGATE;缺历史容量仍MISSING |
|
||||
| reasonCode / message | 机器原因与中文说明;非正常情况可解释,不暴露底层凭证 |
|
||||
|
||||
sourceDataVersion是单条内容修订证据,起终点本来就是不同内容,**不要求两端指纹相等**。可比性由VIN、数据来源、协议、算法及适用容量版本共同判断;更新时重新核对并留存两端证据。
|
||||
|
||||
| 状态 | 使用边界 |
|
||||
| --- | --- |
|
||||
| NORMAL | 有效质量且采样在容差内,才可参与已核实可比的差值计算 |
|
||||
| NO_DATA | 支持的历史查询范围内没有氢相关帧;不代表更早数据不存在 |
|
||||
| MISSING | 最近氢相关帧缺量、温压不全或缺历史容量版本;不跳过最新缺失帧 |
|
||||
| STALE | 有效质量命中超容差,数值null,可返回采样诊断信息 |
|
||||
| UNSUPPORTED | 显式请求当前不支持氢量的协议 |
|
||||
| INVALID | 最新负质量、无效读数、温压异常或原始解析异常,数值null |
|
||||
| FORBIDDEN | 查询时刻或采样时刻授权不满足,采样元数据全部null |
|
||||
| ERROR | 超时、上游失败、分块重组失败或候选预算耗尽;可以重试,不当查无数据 |
|
||||
|
||||
状态优先保留最新原始帧的INVALID/MISSING;只有原始质量有效时才因超容差变为STALE,不能用陈旧状态掩盖异常。分块报文会重组后确认是否氢相关;采集时间与入库时间均相同的候选发生冲突时返回ERROR,不任意择值;不完整分块处理失败为ERROR,最多检查32个候选的预算耗尽也为ERROR。
|
||||
|
||||
常见原因:NO_HISTORICAL_SAMPLE、SAMPLE_OUTSIDE_TOLERANCE、HISTORY_FORBIDDEN、PROTOCOL_UNSUPPORTED、UPSTREAM_TIMEOUT、UPSTREAM_FAILURE、INVALID_MASS_READING、INVALID_PRESSURE_TEMPERATURE、INCOMPLETE_PRESSURE_TEMPERATURE、MISSING_HISTORICAL_CAPACITY_VERSION、RAW_FRAME_PARSE_FAILED、INVALID_RAW_JSON。原因可随新增诊断扩展,客户端优先按状态处理未知原因。
|
||||
|
||||
## 覆盖与交付证据
|
||||
|
||||
接口仅支持2026-08-01北京时间起的请求与原始采样,较早请求400。这是接口能力边界,不表示此前所有原始数据不存在;更早保留范围不在本版承诺。日期可查询、授权覆盖、存在原始帧、存在有效氢量是四件不同的事,应逐车逐时点核对。生产原始历史采样测试、至少3车2段区间验证及缺失样本见本次发布验收报告;没有海港真实作业节点时,只能称历史采样区间验证,不宣称已通过海港作业联调。
|
||||
|
||||
## Seeker 接入检查
|
||||
|
||||
- VIN、requestId、queryTime 与请求逐项核对,不能以数组位置或当前车牌作为唯一关联依据。
|
||||
- 仅 NORMAL 进入差值计算;STALE、NO_DATA、MISSING、UNSUPPORTED、INVALID、FORBIDDEN、ERROR 均保持待数据/待核对,不补零。
|
||||
- 两端协议、REPORTED/ESTIMATED、转换版本及估算容量版本不同时默认不混算,须先核实可比性。
|
||||
- 原始质量、原始差值、归零标记、采样偏差、原始帧标识和版本均留存;展示舍入不回写覆盖计算证据。
|
||||
- 保留跨日作业两个时点,不切换为日报相减。集装箱分摊与碳排因子继续由 Seeker 维护。
|
||||
@@ -0,0 +1,51 @@
|
||||
# 历史剩余氢量接口发布验收
|
||||
|
||||
## 发布结果
|
||||
|
||||
- 生产版本:`open-platform-historical-hydrogen-202609090100`;北京时间2026-09-09 00:57:48确认就绪。
|
||||
- 正式服务:[开放平台](https://open.d.lnoneos.com),服务`lingniu-vehicle-open-platform`,端口20310。
|
||||
- 新接口:`POST /api/v1/vehicles/hydrogen-remaining/history/query`;[在线文档](https://open.d.lnoneos.com/open-api/docs/)及[OpenAPI 1.10.0](https://open.d.lnoneos.com/open-api/openapi.yaml)同步生效。
|
||||
- 最终候选与生产二进制SHA-256一致:`6bb05405b2dd126dd3a8fe13d226fd128a577154e00c2505bcba57f0aca5870b`。
|
||||
- 上一版`open-platform-hydrogen-estimate-202609082311`及原静态门户资源保留;无数据库迁移。发布脚本含失败自动回滚。
|
||||
|
||||
## 实现与边界
|
||||
|
||||
历史查询从北京时间2026-08-01起,按实际event_time匹配最近且不晚于请求时刻的氢相关原始帧,支持延迟上传;不会使用实时快照、日统计、插值或当前容积回套历史。缺历史容量版本的温压记录MISSING,当前有效历史质量仅REPORTED。真实零有效,负质量及显式无效值INVALID;非NORMAL质量null。最新异常不会被旧正常值掩盖。分块帧须完整重组,候选时间冲突或重组失败返回ERROR。
|
||||
|
||||
原始上报质量不额外舍入。返回采样时间、协议、稳定采样指纹sourceRecordId、独立内容版本sourceDataVersion、转换版本和接收时间updatedAt;内容修订即使保留接收时间也可通过指纹识别。历史车牌没有证据时null。采样与查询两个时刻均须满足应用和车辆授权,越权项不泄露采样元数据。
|
||||
|
||||
每批1–200项;requestId唯一并逐项回显。同批按VIN+time+protocol去重,相同点共享单次查询版本;不同点不保证原子快照。默认容差300秒,0要求精确命中。每应用每实例30批/60秒、2并发批;429附Retry-After。批次9秒、4工作协程,超时逐项ERROR。建议从20个不同点开始;200是结构上限,不能将重复点压测解读为200个不同历史点的性能保证。
|
||||
|
||||
完整契约见 `oneos-historical-hydrogen-api-contract.md`。作业净用氢量仍由Seeker按两端可比NORMAL值计算,不在平台新增日用氢量分摊。
|
||||
|
||||
## 验证证据
|
||||
|
||||
- `go test ./...`、`go test -race ./internal/openplatform`、`go vet ./internal/openplatform`、文档YAML校验、发布脚本自测与diff-check通过。
|
||||
- 单元/SQL测试覆盖真实零、原始精度、负数/null、非法及未来时间、容差边界、延迟上传、分块重组失败、同时间冲突、历史容量缺失、修订指纹、跨午夜、协议限制、查询/采样双重授权、混合错误、超时不丢项、200项去重映射、429并发/时间窗限制。
|
||||
- 生产只读覆盖调查确认8月1日已有氢质量原始帧,1024个VIN的既有应用及逐车授权覆盖8月1日至调查时刻;这不代表每台车每个时点有有效氢量。
|
||||
- 在生产机器的隔离候选进程127.0.0.1:20311测试后停止候选;未建立永久对外测试域名。
|
||||
- 选3台既有授权车辆,每车8月3日和15日各一个历史采样区间,每区间3条原始帧,共18帧。接口逐项比对原始质量、真实采集时间、VIN、requestId、来源及版本;全部NORMAL。
|
||||
- 最终候选18点2.989秒;200项含18个不同点2.997秒。正式HTTPS复测分别3.066秒和3.133秒,均全部NORMAL。
|
||||
- 正式HTTPS确认偏移1秒且容差0为STALE/null,默认容差命中同一sourceRecordId;部分FORBIDDEN和UNSUPPORTED不影响正常项;401、非法批次400、429及Retry-After通过。
|
||||
- 真实缺数样本:SAMPLE_VIN_1查询2026-08-01 00:00:00,返回NO_DATA、NO_HISTORICAL_SAMPLE,质量和采样信息为null,没有补零。
|
||||
- 候选与正式均回归100台实时、里程、日用氢量接口。正式耗时184/16/6毫秒;实时包含20条ESTIMATED kg与2个真实零,质量/满充百分比公式验证通过。日用氢量的NO_DATA/DATA_ANOMALY为数据状态,未把它们算作有效氢耗。
|
||||
- 正式健康、文档、OpenAPI和目录均200,systemd active;启动后日志未见新增服务异常。所有临时测试凭证及其授权已删除,未修改合作方既有密钥或授权。
|
||||
|
||||
## 脱敏历史样本
|
||||
|
||||
下表为真实历史遥测采样区间,**不是已取得海港作业节点证明的真实作业区间**。每区间中间一帧也已核验,完整脱敏返回保存在证据目录。两个端点相同质量是有效记录,不能据此推断全天或整次作业用氢为零。
|
||||
|
||||
| 车辆代号 | 采集时间(北京时间) | 剩余kg起点 → 终点 | 状态 |
|
||||
| --- | --- | --- | --- |
|
||||
| SAMPLE_VIN_1 | 2026-08-03T08:50:09+08:00 → 2026-08-03T08:50:29+08:00 | 2.9 → 2.9 | NORMAL |
|
||||
| SAMPLE_VIN_1 | 2026-08-15T00:09:57+08:00 → 2026-08-15T00:10:19+08:00 | 5.5 → 5.5 | NORMAL |
|
||||
| SAMPLE_VIN_2 | 2026-08-03T00:00:00+08:00 → 2026-08-03T00:00:20+08:00 | 3.3 → 3.3 | NORMAL |
|
||||
| SAMPLE_VIN_2 | 2026-08-15T06:40:02+08:00 → 2026-08-15T06:40:21+08:00 | 1.9 → 1.9 | NORMAL |
|
||||
| SAMPLE_VIN_3 | 2026-08-03T00:00:06+08:00 → 2026-08-03T00:00:26+08:00 | 5.7 → 5.7 | NORMAL |
|
||||
| SAMPLE_VIN_3 | 2026-08-15T00:00:04+08:00 → 2026-08-15T00:00:24+08:00 | 4.4 → 4.4 | NORMAL |
|
||||
|
||||
## 未覆盖的业务验收
|
||||
|
||||
海港真实作业开始/结束节点未提供,因此还未完成“3台车、每台2段真实海港作业”的端到端Seeker联调。本次完成历史接口与原始采样验收及部署。较早于2026-08-01的查询不在本版开放范围;当前容量历史版本证据不足,历史估算不输出数值。这些限制已同步在线契约,未伪造历史配置或作业记录。
|
||||
|
||||
脱敏响应、探针源码、候选与正式验收输出、发布SHA等位于工作区`outputs/historical-hydrogen-release-20260909/`。不含密钥;私有原始样本仅为服务器临时验收文件,验收后移除。
|
||||
Reference in New Issue
Block a user