feat: sync vehicle monitoring and accumulated platform updates

Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates.

Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
This commit is contained in:
lingniu
2026-10-26 18:05:59 +08:00
parent 8b32ae2988
commit d6b957dfc8
290 changed files with 28238 additions and 1647 deletions
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: 车辆数据开放平台 API
version: 1.10.0
version: 1.12.2
license:
name: Proprietary
description: |
@@ -16,6 +16,90 @@ tags:
- name: 开放平台管理
description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口
paths:
/api/v1/vehicles/operating-data/query:
post:
tags: [合作方数据接口]
summary: 批量查询车辆综合运营数据
description: |
responseMode省略或FULL保持已上线完整结构;SIMPLE固定返回9个业务字段并在内部按质量过滤。
SIMPLE的百公里氢耗字段暂固定null,不进行未经证明可比的日用氢/日里程相除;不影响summary成功计数。
SIMPLE summary按所选展示字段可用性统计;FULL按子查询执行结果统计。未选类型对应的SIMPLE字段仍返回null但不参与计数。
SIMPLE舍弃陈旧/无可信采集时间的剩余氢量、异常用氢及结转补零;原完整数据和来源状态仍在内部保留。
车牌可选,省略或空数组查询所选时间范围均有效授权的全部车辆(取交集),无车辆返回空数组;省略或空数组不受200辆上限限制,不分页、不截断。显式指定车牌最多200项,超出返回BATCH_SIZE_EXCEEDED。规范化后不允许重复。
dataTypes省略时查询实时、单日里程和单日用氢;未选块省略。含日统计时dailyDate必填且不得为未来日期。
protocolPriority仅影响DAILY_MILEAGE,未选里程时不可提供;实时保持原有选源。
appKey须当前有效,实时车辆授权覆盖requestAt,日统计授权完整覆盖查询自然日。任一越权整批403;先授权再读取。
各数据块保留源字段、质量与时间,不是共同快照;不计算百公里氢耗。同日或NORMAL不能证明统计区间可比。
FULL的queryStatus仅表示子服务可见执行结果,SUCCESS仍可能NO_DATA、DATA_ANOMALY或实时补充降级MISSING/UNKNOWN。
里程结转沿用status=NORMAL及dataQuality=CARRIED_FORWARD,不能解释为实测零;储氢陈旧和估算来源原样保留。
各块失败独立返回FAILED及错误码,不以NO_DATA或旧值掩盖。所有子查询失败仍返回200和准确summary。
所有时间戳转为带+08:00的RFC3339,未知可选字段显式null;原独立接口不受影响。
每应用每服务实例30批/60秒、最多2批并发;新端点全局每实例最多16批。429包含Retry-After,多实例配额由网关统一协调。
总预算6秒,批量子查询共享最多4.5秒预算;上层网关需给出更长预算。未承诺未经压测的P95目标。
operationId: queryVehicleOperatingData
security:
- AppKeyAuth: []
requestBody:
required: true
description: 最大64KiB;不接受未知字段或显式null。
content:
application/json:
schema:
$ref: '#/components/schemas/OperatingDataQuery'
example:
responseMode: SIMPLE
plateNumbers: [粤A09635F]
dailyDate: '2026-10-23'
dataTypes: [REALTIME, DAILY_MILEAGE, DAILY_HYDROGEN]
protocolPriority: [GB32960, MQTT, JT808]
responses:
'200':
description: 聚合处理完成;FULL逐块检查执行和质量,SIMPLE返回9字段并按所选展示字段可用性汇总。
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/OperatingDataResponse'
- $ref: '#/components/schemas/OperatingSimpleResponse'
'400':
description: 参数、日期或协议不合法;显式plateNumbers超过200项时code为BATCH_SIZE_EXCEEDED
content:
application/json:
schema:
$ref: '#/components/schemas/OperatingErrorResponse'
'401':
description: appKey当前无效
content:
application/json:
schema:
$ref: '#/components/schemas/OperatingErrorResponse'
'403':
description: 车辆或所选时段未授权
content:
application/json:
schema:
$ref: '#/components/schemas/OperatingErrorResponse'
'413':
description: 请求体超过64KiB
content:
application/json:
schema:
$ref: '#/components/schemas/OperatingErrorResponse'
'429':
description: 应用或全局限额超限
headers:
Retry-After:
schema: {type: integer, minimum: 1}
content:
application/json:
schema:
$ref: '#/components/schemas/OperatingErrorResponse'
'500':
description: 授权依赖、身份映射或聚合内部异常
content:
application/json:
schema:
$ref: '#/components/schemas/OperatingErrorResponse'
/api/v1/vehicles/hydrogen-remaining/history/query:
post:
tags: [合作方数据接口]
@@ -137,7 +221,7 @@ paths:
tags: [合作方数据接口]
summary: 分页查询车辆区间日里程
description: |
最长支持 366 天。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。
最长支持 3 年(1096 天)。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
@@ -491,6 +575,391 @@ components:
schema:
$ref: '#/components/schemas/ErrorResponse'
schemas:
OperatingSimpleVehicle:
type: "object"
additionalProperties: false
required: ["plateNumber", "reportTime", "offline", "remainingHydrogenKg", "socPercent", "totalMileageKm", "dailyHydrogenConsumptionKg", "dailyMileageKm", "hydrogenConsumptionPer100KmKg"]
properties:
plateNumber:
type: "string"
description: "规范化后的请求车牌,保持请求顺序。"
reportTime:
type: "string"
nullable: true
description: "实时选中主记录的event_time,即真实源采集/上报时刻;缺失为null,不使用received_at、updated_at或请求时间替代。FULL recordTime仍保持原兼容口径。"
format: "date-time"
offline:
type: "boolean"
nullable: true
description: "主实时记录可用且online已知时取反;无记录或查询失败为null,不能当成离线。"
remainingHydrogenKg:
type: "number"
nullable: true
description: "剩余氢量kg。仅kg字段NORMAL、数值有效且有可信采集时间,响应时不超过300秒并不晚于服务时点1分钟时展示;陈旧/缺失/异常为null,真实0保留。"
minimum: 0
socPercent:
type: "number"
nullable: true
description: "动力电池SOC百分比;实时主记录有效且数值在0–100内,未知为null。"
minimum: 0
maximum: 100
totalMileageKm:
type: "number"
nullable: true
description: "实时主记录累计里程km;不使用日统计累计值兜底。"
minimum: 0
dailyHydrogenConsumptionKg:
type: "number"
nullable: true
description: "queryDate用氢量kg;子查询成功、status=NORMAL、qualityStatus=OK时返回有效非负数值,否则null。日内值可为PRELIMINARY,不保证终报。"
minimum: 0
dailyMileageKm:
type: "number"
nullable: true
description: "queryDate里程km;执行成功、业务NORMAL且非CARRIED_FORWARD时返回有效非负数值;实测零保留,结转补零和异常为null。"
minimum: 0
hydrogenConsumptionPer100KmKg:
type: "number"
nullable: true
description: "kg/100km预留字段;当前缺少可比区间完整证据,固定null,不参与summary计数。禁止调用方以同日日指标直接相除。"
enum: [null]
OperatingSimpleSummary:
type: "object"
description: "按所选展示字段的可用性统计:实时5项+日里程1项+日用氢1项。全有值successCount、部分有值partialSuccessCount、全null failedCount。plateNumber和暂未支持的百公里氢耗不参与;未选类型不参与。null及failedCount并不等于查询执行失败,不作为自动重试依据。"
required: ["requestedCount", "returnedCount", "successCount", "partialSuccessCount", "failedCount"]
properties:
requestedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/requestedCount"}
returnedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/returnedCount"}
successCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/successCount"}
partialSuccessCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/partialSuccessCount"}
failedCount: {"$ref": "#/components/schemas/OperatingDataSummary/properties/failedCount"}
OperatingSimpleResponse:
type: "object"
required: ["code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"]
properties:
code: {"$ref": "#/components/schemas/OperatingDataResponse/properties/code"}
message: {"$ref": "#/components/schemas/OperatingDataResponse/properties/message"}
traceId: {"$ref": "#/components/schemas/OperatingDataResponse/properties/traceId"}
queryDate: {"$ref": "#/components/schemas/OperatingDataResponse/properties/queryDate"}
timezone: {"$ref": "#/components/schemas/OperatingDataResponse/properties/timezone"}
requestAt: {"$ref": "#/components/schemas/OperatingDataResponse/properties/requestAt"}
generatedAt: {"$ref": "#/components/schemas/OperatingDataResponse/properties/generatedAt"}
data:
type: "array"
minItems: 0
items: {"$ref": "#/components/schemas/OperatingSimpleVehicle"}
summary: {"$ref": "#/components/schemas/OperatingSimpleSummary"}
OperatingDataQuery:
type: "object"
oneOf:
- required: [dataTypes]
properties:
dataTypes: {type: array, minItems: 1, maxItems: 1, items: {type: string, enum: [REALTIME]}}
- required: [dailyDate]
not:
required: [dataTypes]
properties:
dataTypes: {type: array, minItems: 1, maxItems: 1, items: {type: string, enum: [REALTIME]}}
additionalProperties: false
properties:
responseMode:
type: string
enum: [FULL, SIMPLE]
default: FULL
description: 省略保持完整结构;显式SIMPLE返回固定9字段;不接受空字符串或null。
plateNumbers:
type: "array"
minItems: 0
maxItems: 200
uniqueItems: true
items:
type: "string"
minLength: 1
maxLength: 32
description: "可选;省略或空数组查询所选时间范围内均有效授权的全部车辆,按车牌排序。省略或空数组返回全部有效授权车辆,不受200辆上限限制,不分页、不截断;显式指定最多200项,超出返回BATCH_SIZE_EXCEEDED。去首尾空白、转大写后仍必须唯一。"
dailyDate:
type: "string"
format: "date"
description: "含日统计时必填;仅实时可省略;提供时仍校验。无固定历史下界,能否取得结果取决于有效授权及保留的统计投影,不承诺历史完整性。"
dataTypes:
type: "array"
minItems: 1
maxItems: 3
uniqueItems: true
items:
type: "string"
enum: ["REALTIME", "DAILY_MILEAGE", "DAILY_HYDROGEN"]
description: "省略默认全部;未选数据块不出现。"
protocolPriority:
type: "array"
minItems: 1
maxItems: 3
uniqueItems: true
items:
type: "string"
enum: ["GB32960", "MQTT", "JT808"]
description: "仅选DAILY_MILEAGE时允许;未列出协议不兜底。"
OperatingRealtimeVehicleResultSuccess:
type: "object"
required: ["queryStatus", "errorCode", "errorMessage", "gpsFixStatus", "locationRecordTime", "coordinateSystem", "protocol", "longitude", "latitude", "speedKmh", "socPercent", "totalMileageKm", "recordTime", "timeDifferenceSeconds", "online", "activeToday", "motionStatus", "locationAvailable", "status", "hydrogenPressureTemperatureSource", "remainingHydrogenPercentSource", "hydrogenFullCapacityKg", "hydrogenTankCapacityL", "hydrogenFullPressureMPa", "hydrogenReferenceTemperatureC", "hydrogenEstimatePressureMPa", "hydrogenEstimateTemperatureC", "hydrogenCalculationVersion", "hydrogenCapacitySource", "hydrogenPercentReason", "remainingHydrogenKg", "remainingHydrogenPercent", "hydrogenRecordTime", "hydrogenDataStatus", "remainingHydrogenKgStatus", "remainingHydrogenPercentStatus", "hydrogenValueSource", "hydrogenSourceProtocol", "hydrogenStaleAfterSeconds", "hydrogenExpectedIntervalSeconds"]
properties:
queryStatus:
type: "string"
enum: ["SUCCESS"]
errorCode:
type: "string"
nullable: true
enum: [null]
errorMessage:
type: "string"
nullable: true
enum: [null]
gpsFixStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/gpsFixStatus"}
locationRecordTime:
type: "string"
format: "date-time"
nullable: true
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
coordinateSystem: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/coordinateSystem"}
protocol:
type: "string"
nullable: true
description: "沿用源字段含义;原可选字段缺失时输出null。"
longitude: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/longitude"}
latitude: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/latitude"}
speedKmh: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/speedKmh"}
socPercent:
type: "number"
minimum: 0
maximum: 100
nullable: true
description: "动力电池SOC;未知为null。"
totalMileageKm: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/totalMileageKm"}
recordTime:
type: "string"
format: "date-time"
nullable: true
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
timeDifferenceSeconds:
type: "integer"
minimum: 0
nullable: true
description: "源主记录年龄,不表示实际离线时长。"
online: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/online"}
activeToday: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/activeToday"}
motionStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/motionStatus"}
locationAvailable: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/locationAvailable"}
status: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/status"}
hydrogenPressureTemperatureSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenPressureTemperatureSource"}
remainingHydrogenPercentSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercentSource"}
hydrogenFullCapacityKg: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenFullCapacityKg"}
hydrogenTankCapacityL: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenTankCapacityL"}
hydrogenFullPressureMPa: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenFullPressureMPa"}
hydrogenReferenceTemperatureC: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenReferenceTemperatureC"}
hydrogenEstimatePressureMPa: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenEstimatePressureMPa"}
hydrogenEstimateTemperatureC: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenEstimateTemperatureC"}
hydrogenCalculationVersion: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenCalculationVersion"}
hydrogenCapacitySource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenCapacitySource"}
hydrogenPercentReason: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenPercentReason"}
remainingHydrogenKg: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenKg"}
remainingHydrogenPercent: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercent"}
hydrogenRecordTime:
type: "string"
format: "date-time"
nullable: true
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
hydrogenDataStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenDataStatus"}
remainingHydrogenKgStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenKgStatus"}
remainingHydrogenPercentStatus: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/remainingHydrogenPercentStatus"}
hydrogenValueSource: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenValueSource"}
hydrogenSourceProtocol: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenSourceProtocol"}
hydrogenStaleAfterSeconds: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenStaleAfterSeconds"}
hydrogenExpectedIntervalSeconds: {"$ref": "#/components/schemas/RealtimeVehicleResult/properties/hydrogenExpectedIntervalSeconds"}
OperatingMileageResultSuccess:
type: "object"
required: ["queryStatus", "errorCode", "errorMessage", "statisticsStartTime", "statisticsEndTime", "date", "dailyMileageKm", "totalMileageKm", "dataTime", "updatedAt", "sourceProtocol", "dataQuality", "status"]
properties:
queryStatus:
type: "string"
enum: ["SUCCESS"]
errorCode:
type: "string"
nullable: true
enum: [null]
errorMessage:
type: "string"
nullable: true
enum: [null]
statisticsStartTime:
type: "string"
format: "date-time"
nullable: true
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
statisticsEndTime:
type: "string"
format: "date-time"
nullable: true
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
date: {"$ref": "#/components/schemas/MileageResult/properties/date"}
dailyMileageKm: {"$ref": "#/components/schemas/MileageResult/properties/dailyMileageKm"}
totalMileageKm: {"$ref": "#/components/schemas/MileageResult/properties/totalMileageKm"}
dataTime:
type: "string"
format: "date-time"
nullable: true
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
updatedAt:
type: "string"
format: "date-time"
nullable: true
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
sourceProtocol: {"$ref": "#/components/schemas/MileageResult/properties/sourceProtocol"}
dataQuality:
type: "string"
nullable: true
description: "沿用源字段含义;原可选字段缺失时输出null。"
status: {"$ref": "#/components/schemas/MileageResult/properties/status"}
OperatingHydrogenResultSuccess:
type: "object"
required: ["queryStatus", "errorCode", "errorMessage", "statisticsStartTime", "statisticsEndTime", "updatedAt", "date", "hydrogenConsumptionKg", "calculationPhase", "algorithmVersion", "qualityStatus", "qualityReason", "status"]
properties:
queryStatus:
type: "string"
enum: ["SUCCESS"]
errorCode:
type: "string"
nullable: true
enum: [null]
errorMessage:
type: "string"
nullable: true
enum: [null]
statisticsStartTime:
type: "string"
format: "date-time"
nullable: true
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
statisticsEndTime:
type: "string"
format: "date-time"
nullable: true
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
updatedAt:
type: "string"
format: "date-time"
nullable: true
description: "保留源时刻,输出RFC3339带+08:00;未知为null。"
date: {"$ref": "#/components/schemas/HydrogenResult/properties/date"}
hydrogenConsumptionKg: {"$ref": "#/components/schemas/HydrogenResult/properties/hydrogenConsumptionKg"}
calculationPhase:
type: "string"
nullable: true
description: "沿用源字段含义;原可选字段缺失时输出null。"
algorithmVersion:
type: "string"
nullable: true
description: "沿用源字段含义;原可选字段缺失时输出null。"
qualityStatus:
type: "string"
nullable: true
description: "沿用源字段含义;原可选字段缺失时输出null。"
qualityReason:
type: "string"
nullable: true
description: "沿用源字段含义;原可选字段缺失时输出null。"
status: {"$ref": "#/components/schemas/HydrogenResult/properties/status"}
OperatingDataBlockFailure:
type: "object"
additionalProperties: false
required: ["queryStatus", "status", "errorCode", "errorMessage"]
properties:
queryStatus:
type: "string"
enum: ["FAILED"]
status:
type: "string"
nullable: true
enum: [null]
errorCode:
type: "string"
enum: ["REALTIME_QUERY_FAILED", "REALTIME_QUERY_TIMEOUT", "MILEAGE_QUERY_FAILED", "MILEAGE_QUERY_TIMEOUT", "HYDROGEN_QUERY_FAILED", "HYDROGEN_QUERY_TIMEOUT", "SOURCE_DATA_INVALID"]
errorMessage:
type: "string"
OperatingVehicleResult:
type: "object"
required: ["vin", "plateNumber"]
properties:
vin:
type: "string"
plateNumber:
type: "string"
realtime:
oneOf: [{"$ref": "#/components/schemas/OperatingRealtimeVehicleResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}]
dailyMileage:
oneOf: [{"$ref": "#/components/schemas/OperatingMileageResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}]
dailyHydrogen:
oneOf: [{"$ref": "#/components/schemas/OperatingHydrogenResultSuccess"}, {"$ref": "#/components/schemas/OperatingDataBlockFailure"}]
OperatingDataSummary:
type: "object"
description: "按车辆统计所选块的queryStatus;NO_DATA/异常仍为执行成功。successCount+partialSuccessCount+failedCount=returnedCount=requestedCount。"
required: ["requestedCount", "returnedCount", "successCount", "partialSuccessCount", "failedCount"]
properties:
requestedCount:
type: "integer"
minimum: 0
returnedCount:
type: "integer"
minimum: 0
successCount:
type: "integer"
minimum: 0
partialSuccessCount:
type: "integer"
minimum: 0
failedCount:
type: "integer"
minimum: 0
OperatingDataResponse:
type: "object"
required: ["code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"]
properties:
code:
type: "string"
enum: ["SUCCESS"]
message:
type: "string"
traceId:
type: "string"
queryDate:
type: "string"
format: "date"
nullable: true
description: "包含日统计时为dailyDate;仅实时为null。"
timezone:
type: "string"
enum: ["Asia/Shanghai"]
requestAt:
type: "string"
format: "date-time"
generatedAt:
type: "string"
format: "date-time"
data:
type: "array"
minItems: 0
items: {"$ref": "#/components/schemas/OperatingVehicleResult"}
summary: {"$ref": "#/components/schemas/OperatingDataSummary"}
OperatingErrorResponse:
type: "object"
required: ["code", "message", "traceId"]
properties:
code:
type: "string"
enum: ["INVALID_REQUEST", "BATCH_SIZE_EXCEEDED", "INVALID_DATE_FORMAT", "INVALID_DATE_RANGE", "UNAUTHORIZED", "FORBIDDEN", "PAYLOAD_TOO_LARGE", "RATE_LIMIT_EXCEEDED", "INTERNAL_ERROR"]
message:
type: "string"
traceId:
type: "string"
VehicleDailyQuery:
type: object
additionalProperties: false
@@ -541,7 +1010,7 @@ components:
endDate:
type: string
format: date
description: 区间结束自然日,含当日;与 startDate 最多相隔 365 天
description: 区间结束自然日,含当日;与 startDate 最多相隔 1095 天(含首尾共1096天)
plateNumbers:
type: array
maxItems: 5000
@@ -132,3 +132,31 @@ func TestHistoricalHydrogenDocumentationDefinesEvidenceAndFailureBoundaries(t *t
}
}
}
func TestOperatingDataDocumentationAndCatalog(t *testing.T) {
for _, text := range []string{OperatingDataQueryPath, "queryStatus", "dailyDate", "partialSuccessCount", "CARRIED_FORWARD", "Retry-After", "MISSING"} {
if !strings.Contains(string(openAPISpec), text) {
t.Errorf("OpenAPI missing %s", text)
}
if !strings.Contains(string(simpleDocsHTML), text) {
t.Errorf("HTML missing %s", text)
}
}
found := false
for _, product := range dataProducts() {
if product.Path == OperatingDataQueryPath {
found = product.Code == "operating_data"
}
}
if !found {
t.Fatal("aggregate API missing from catalog")
}
}
func TestOperatingSimpleModeDocumentation(t *testing.T) {
for _, field := range []string{"responseMode", "SIMPLE", "reportTime", "offline", "dailyHydrogenConsumptionKg", "hydrogenConsumptionPer100KmKg"} {
if !strings.Contains(string(openAPISpec), field) || !strings.Contains(string(simpleDocsHTML), field) {
t.Errorf("SIMPLE contract missing %s", field)
}
}
}
@@ -58,6 +58,7 @@ func NewExternalHandler(service *Service, portal *PortalService) *Handler {
}
func (h *Handler) registerExternalDataRoutes() {
h.mux.HandleFunc("POST "+OperatingDataQueryPath, h.operatingData)
h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen)
h.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, h.historicalHydrogen)
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
@@ -109,6 +110,7 @@ func (h *Handler) registerPortalRoutes() {
func NewDataHandler(service *Service) *Handler {
handler := &Handler{service: service, mux: http.NewServeMux()}
handler.mux.HandleFunc("POST "+OperatingDataQueryPath, handler.operatingData)
handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen)
handler.mux.HandleFunc("POST "+HistoricalHydrogenQueryPath, handler.historicalHydrogen)
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
@@ -125,7 +127,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
func IsPublicPath(path string) bool {
return path == HistoricalHydrogenQueryPath || path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == StationaryVehicleQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
return path == OperatingDataQueryPath || 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) {
@@ -720,6 +722,10 @@ func externalTraceID(r *http.Request) string {
if traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id")); traceID != "" && len(traceID) <= 64 {
return traceID
}
return newExternalTraceID()
}
func newExternalTraceID() string {
var value [16]byte
if _, err := rand.Read(value[:]); err == nil {
return hex.EncodeToString(value[:])
@@ -756,6 +762,7 @@ func requestRemoteAddress(r *http.Request) string {
func dataProducts() []DataProduct {
return []DataProduct{
{Code: "operating_data", Name: "车辆综合运营数据", Description: "批量聚合实时、单日里程与单日用氢,分别保留质量、时间和执行状态。", Version: "v1", Status: "available", Method: http.MethodPost, Path: OperatingDataQueryPath, Unit: ""},
{Code: "historical_hydrogen_remaining", Name: "历史剩余氢质量", Description: "按VIN与历史北京时间批量查询全车剩余氢质量、采样时间及来源证据。", Version: "v1", Status: "available", Method: http.MethodPost, Path: HistoricalHydrogenQueryPath, Unit: "kg"},
{
Code: "daily_hydrogen", Name: "单日用氢量",
@@ -157,22 +157,23 @@ type RealtimeVehicleRequest struct {
}
type RealtimeVehiclePoint struct {
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
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
TotalMileageAvailable *bool
ObservedAt time.Time
Online bool
ActiveToday bool
}
type RealtimeVehicleResult struct {
@@ -63,6 +63,11 @@ WHERE app_key_hash=?
}
func (r *MySQLRepository) AuthorizedVehicles(ctx context.Context, appID uint64, plates []string, dayStart, dayEnd time.Time) (map[string]AuthorizedVehicle, error) {
// Instant grants expire exclusively; a whole-day window may end at valid_to.
endOperator := ">="
if dayStart.Equal(dayEnd) {
endOperator = ">"
}
args := make([]any, 0, len(plates)+3)
args = append(args, appID, dayStart, dayEnd)
query := `
@@ -71,7 +76,7 @@ FROM vehicle_open_app_vehicle g
JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
WHERE g.app_id=?
AND g.valid_from<=?
AND (g.valid_to IS NULL OR g.valid_to>=?)
AND (g.valid_to IS NULL OR g.valid_to` + endOperator + `?)
AND TRIM(COALESCE(b.plate,''))<>''`
if len(plates) > 0 {
placeholders := strings.TrimRight(strings.Repeat("?,", len(plates)), ",")
@@ -195,7 +200,7 @@ func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, n
args = append(args, now.Add(-10*time.Minute))
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,
COALESCE(l.speed_kmh,0),l.soc_percent,l.total_mileage_km,l.updated_at,
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
@@ -212,12 +217,15 @@ ORDER BY l.vin,
onlineThreshold := now.Add(-time.Minute)
for rows.Next() {
var point RealtimeVehiclePoint
var soc sql.NullFloat64
var soc, totalMileage sql.NullFloat64
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 {
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &soc, &totalMileage, &point.ObservedAt, &point.ActiveToday, &locationAt, &receivedAt, &eventID); err != nil {
return nil, err
}
point.TotalMileageKm = totalMileage.Float64 // Preserve the FULL endpoint's compatibility value.
available := totalMileage.Valid
point.TotalMileageAvailable = &available
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)
@@ -0,0 +1,502 @@
package openplatform
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
)
const OperatingDataQueryPath = "/api/v1/vehicles/operating-data/query"
const operatingBudget = 6 * time.Second
const operatingQueryBudget = 4500 * time.Millisecond
const (
operatingRealtime = "REALTIME"
operatingMileage = "DAILY_MILEAGE"
operatingHydrogen = "DAILY_HYDROGEN"
)
type OperatingDataRequest struct {
ResponseMode string `json:"responseMode,omitempty"`
PlateNumbers []string `json:"plateNumbers,omitempty"`
DailyDate string `json:"dailyDate,omitempty"`
DataTypes []string `json:"dataTypes,omitempty"`
ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"`
}
func (r *OperatingDataRequest) UnmarshalJSON(data []byte) error {
type plain OperatingDataRequest
var value plain
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
if fields == nil {
return fmt.Errorf("request must be an object")
}
if _, exists := fields["dailyDate"]; exists && value.DailyDate == "" {
return fmt.Errorf("dailyDate must not be empty")
}
if _, exists := fields["responseMode"]; exists && value.ResponseMode == "" {
return fmt.Errorf("responseMode must not be empty")
}
for key, raw := range fields {
if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
return fmt.Errorf("%s must not be null", key)
}
}
*r = OperatingDataRequest(value)
return nil
}
// RawMessage retains source number precision while normalizing optional fields
// without duplicating the existing result DTOs or changing their serializers.
type OperatingDataBlock map[string]json.RawMessage
type OperatingVehicleResult struct {
VIN string `json:"vin"`
PlateNumber string `json:"plateNumber"`
Realtime OperatingDataBlock `json:"realtime,omitempty"`
DailyMileage OperatingDataBlock `json:"dailyMileage,omitempty"`
DailyHydrogen OperatingDataBlock `json:"dailyHydrogen,omitempty"`
}
type OperatingDataSummary struct {
RequestedCount int `json:"requestedCount"`
ReturnedCount int `json:"returnedCount"`
SuccessCount int `json:"successCount"`
PartialSuccessCount int `json:"partialSuccessCount"`
FailedCount int `json:"failedCount"`
}
type OperatingDataResponse struct {
Code string `json:"code"`
Message string `json:"message"`
TraceID string `json:"traceId"`
QueryDate *string `json:"queryDate"`
Timezone string `json:"timezone"`
RequestAt string `json:"requestAt"`
GeneratedAt string `json:"generatedAt"`
Data []OperatingVehicleResult `json:"data"`
Summary OperatingDataSummary `json:"summary"`
}
type operatingBatchSizeError struct{}
func (*operatingBatchSizeError) Error() string { return "plateNumbers exceeds 200" }
func (*operatingBatchSizeError) Unwrap() error { return ErrInvalidRequest }
type operatingDateError struct{ code string }
func (e *operatingDateError) Error() string { return e.code }
func (s *Service) validateOperatingData(r OperatingDataRequest, now time.Time) ([]string, map[string]bool, []string, time.Time, error) {
if r.ResponseMode != "" && r.ResponseMode != "FULL" && r.ResponseMode != "SIMPLE" {
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: responseMode must be FULL or SIMPLE", ErrInvalidRequest)
}
if len(r.PlateNumbers) > 200 {
return nil, nil, nil, time.Time{}, &operatingBatchSizeError{}
}
plates, err := normalizePlates(r.PlateNumbers, 200)
if err != nil {
return nil, nil, nil, time.Time{}, err
}
kinds := r.DataTypes
if kinds == nil {
kinds = []string{operatingRealtime, operatingMileage, operatingHydrogen}
}
selected := make(map[string]bool, 3)
for _, kind := range kinds {
if selected[kind] || (kind != operatingRealtime && kind != operatingMileage && kind != operatingHydrogen) {
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: invalid dataTypes", ErrInvalidRequest)
}
selected[kind] = true
}
if len(selected) == 0 {
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: empty dataTypes", ErrInvalidRequest)
}
var day time.Time
if r.DailyDate != "" || selected[operatingMileage] || selected[operatingHydrogen] {
day, err = time.ParseInLocation("2006-01-02", r.DailyDate, s.location)
if err != nil || day.Format("2006-01-02") != r.DailyDate {
return nil, nil, nil, time.Time{}, &operatingDateError{"INVALID_DATE_FORMAT"}
}
if day.After(now) {
return nil, nil, nil, time.Time{}, &operatingDateError{"INVALID_DATE_RANGE"}
}
}
if r.ProtocolPriority.Present && !selected[operatingMileage] {
return nil, nil, nil, time.Time{}, fmt.Errorf("%w: protocolPriority requires DAILY_MILEAGE", ErrInvalidRequest)
}
protocols, err := normalizeProtocolPriority(r.ProtocolPriority)
return plates, selected, protocols, day, err
}
// Validate every selected authorization scope before reading business data. The
// same frozen VIN mapping is then reused by all three batched repositories.
func (s *Service) authorizeOperatingScopes(ctx context.Context, key string, app AppCredential, plates []string, selected map[string]bool, now, day time.Time) (AppCredential, map[string]AuthorizedVehicle, error) {
hash := sha256.Sum256([]byte(strings.ToLower(key)))
allVehicles := len(plates) == 0
var vehicles map[string]AuthorizedVehicle
check := func(start, end time.Time) error {
found, err := s.repository.AuthorizedVehicles(ctx, app.ID, plates, start, end)
if err != nil {
return err
}
if !allVehicles && len(found) != len(plates) {
return ErrForbidden
}
checkedPlates := plates
if allVehicles {
checkedPlates = vehiclePlates(found)
}
for _, plate := range checkedPlates {
v, ok := found[plate]
if !ok || strings.TrimSpace(v.VIN) == "" || (v.Plate != "" && v.Plate != plate) {
return ErrForbidden
}
if previous, exists := vehicles[plate]; exists && previous.VIN != v.VIN {
return errors.New("operating query identity changed")
}
}
if allVehicles && vehicles != nil {
// Only return vehicles authorized for every selected time window.
for plate := range vehicles {
if _, exists := found[plate]; !exists {
delete(vehicles, plate)
}
}
} else {
vehicles = found
}
if vehicles == nil {
vehicles = make(map[string]AuthorizedVehicle)
}
return nil
}
if selected[operatingRealtime] {
if err := check(now, now); err != nil {
return app, nil, err
}
}
if selected[operatingMileage] || selected[operatingHydrogen] {
end := day.AddDate(0, 0, 1)
historicalApp, err := s.repository.Authenticate(ctx, hash, now, day, end)
if errors.Is(err, ErrUnauthorized) {
return app, nil, ErrForbidden
}
if err != nil {
return app, nil, err
}
if historicalApp.ID != app.ID {
return app, nil, errors.New("operating query app identity changed")
}
if err := check(day, end); err != nil {
return app, nil, err
}
}
return app, vehicles, nil
}
func (s *Service) QueryOperatingData(ctx context.Context, key, traceID string, request OperatingDataRequest) (*OperatingDataResponse, error) {
now := s.now().In(s.location)
plates, selected, protocols, day, err := s.validateOperatingData(request, now)
if err != nil {
return nil, err
}
bounded, cancel := context.WithTimeout(ctx, operatingBudget)
defer cancel()
// Authenticate before applying the existing per-app limiter. Independent state
// avoids consuming the historical-hydrogen endpoint's quota.
if !appKeyPattern.MatchString(key) {
return nil, ErrUnauthorized
}
app, err := s.repository.Authenticate(bounded, sha256.Sum256([]byte(strings.ToLower(key))), now, now, now)
if err != nil {
return nil, err
}
release, err := s.operatingLimiter.acquire(app.ID, now)
if err != nil {
return nil, err
}
defer release()
select {
case s.operatingSlots <- struct{}{}:
defer func() { <-s.operatingSlots }()
default:
return nil, &historicalHydrogenRateError{RetryAfter: 1}
}
// Authentication is shared with scope checks below, not repeated per type.
app, vehicles, err := s.authorizeOperatingScopes(bounded, key, app, plates, selected, now, day)
if err != nil {
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", "denied", traceID, len(plates), "scope validation failed")
return nil, err
}
if len(plates) == 0 {
plates = vehiclePlates(vehicles)
}
result := &OperatingDataResponse{Code: "SUCCESS", Message: "success", TraceID: traceID, Timezone: "Asia/Shanghai", RequestAt: now.Format(time.RFC3339Nano), Data: make([]OperatingVehicleResult, len(plates)), Summary: OperatingDataSummary{RequestedCount: len(plates), ReturnedCount: len(plates)}}
if selected[operatingMileage] || selected[operatingHydrogen] {
result.QueryDate = &request.DailyDate
}
if len(plates) == 0 {
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", "success", traceID, 0, "")
result.GeneratedAt = s.now().In(s.location).Format(time.RFC3339Nano)
return result, nil
}
vins := vehicleVINs(vehicles)
type completed struct {
kind string
blocks []OperatingDataBlock
}
done := make(chan completed, len(selected))
queryCtx, queryCancel := context.WithTimeout(bounded, operatingQueryBudget)
defer queryCancel()
for kind := range selected {
go func(kind string) {
started := time.Now()
var blocks []OperatingDataBlock
var err error
switch kind {
case operatingRealtime:
var points map[string]RealtimeVehiclePoint
points, err = s.repository.RealtimeVehicles(queryCtx, vins, now)
if err == nil {
values := buildRealtimeVehicleResults(plates, vehicles, points, now, s.location)
if request.ResponseMode == "SIMPLE" {
prepareSimpleRealtime(values, plates, vehicles, points, s.location)
}
blocks = operatingBlocks(values, s.location)
}
case operatingMileage:
var values map[string]DailyMileage
values, err = s.repository.ReconciledMileageRange(queryCtx, vins, request.DailyDate, request.DailyDate, protocols)
if err == nil {
blocks = operatingBlocks(buildMileageResults(plates, vehicles, values, request.DailyDate), s.location)
}
case operatingHydrogen:
var values map[string]DailyHydrogen
values, err = s.repository.DailyHydrogen(queryCtx, vins, request.DailyDate)
if err == nil {
blocks = operatingBlocks(buildHydrogenResults(plates, vehicles, values, request.DailyDate), s.location)
}
}
if err != nil {
blocks = make([]OperatingDataBlock, len(plates))
for i := range blocks {
blocks[i] = operatingFailure(kind, errors.Is(err, context.DeadlineExceeded))
}
}
if err != nil {
log.Printf("openplatform operating query unavailable: traceId=%s appId=%d type=%s durationMs=%d errorType=%T", traceID, app.ID, kind, time.Since(started).Milliseconds(), err)
}
done <- completed{kind, blocks}
}(kind)
}
byKind := make(map[string][]OperatingDataBlock, len(selected))
for len(byKind) < len(selected) {
select {
case result := <-done:
byKind[result.kind] = result.blocks
case <-queryCtx.Done():
// Drain already completed work before timing out only the outstanding types.
for {
select {
case result := <-done:
byKind[result.kind] = result.blocks
default:
goto drained
}
}
drained:
for kind := range selected {
if _, ok := byKind[kind]; !ok {
blocks := make([]OperatingDataBlock, len(plates))
for i := range blocks {
blocks[i] = operatingFailure(kind, true)
}
byKind[kind] = blocks
}
}
}
}
for i, plate := range plates {
row := OperatingVehicleResult{VIN: vehicles[plate].VIN, PlateNumber: plate}
succeeded := 0
for kind, blocks := range byKind {
block := blocks[i]
if string(block["queryStatus"]) == `"SUCCESS"` {
succeeded++
}
switch kind {
case operatingRealtime:
row.Realtime = block
case operatingMileage:
row.DailyMileage = block
case operatingHydrogen:
row.DailyHydrogen = block
}
}
switch {
case succeeded == len(selected):
result.Summary.SuccessCount++
case succeeded == 0:
result.Summary.FailedCount++
default:
result.Summary.PartialSuccessCount++
}
result.Data[i] = row
}
status := "success"
if result.Summary.SuccessCount != len(plates) {
status = "partial"
if result.Summary.FailedCount == len(plates) {
status = "error"
}
}
detail := ""
if request.ResponseMode == "SIMPLE" {
detail = "responseMode=SIMPLE"
}
_ = s.repository.Audit(bounded, app.ID, "operating_data_query", status, traceID, len(plates), detail)
result.GeneratedAt = s.now().In(s.location).Format(time.RFC3339Nano)
return result, nil
}
func operatingFailure(kind string, timeout bool) OperatingDataBlock {
prefix := map[string]string{operatingRealtime: "REALTIME", operatingMileage: "MILEAGE", operatingHydrogen: "HYDROGEN"}[kind]
suffix := "_QUERY_FAILED"
if timeout {
suffix = "_QUERY_TIMEOUT"
}
code, _ := json.Marshal(prefix + suffix)
return OperatingDataBlock{"queryStatus": json.RawMessage(`"FAILED"`), "status": json.RawMessage("null"), "errorCode": code, "errorMessage": json.RawMessage(`"data query unavailable"`)}
}
func operatingBlocks[T any](values []T, location *time.Location) []OperatingDataBlock {
blocks := make([]OperatingDataBlock, len(values))
for i, value := range values {
block, err := operatingBlock(value, location)
if err != nil {
block = OperatingDataBlock{"queryStatus": json.RawMessage(`"FAILED"`), "status": json.RawMessage("null"), "errorCode": json.RawMessage(`"SOURCE_DATA_INVALID"`), "errorMessage": json.RawMessage(`"source data cannot be serialized"`)}
}
blocks[i] = block
}
return blocks
}
func operatingBlock(value any, location *time.Location) (OperatingDataBlock, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
var block OperatingDataBlock
if err = json.Unmarshal(data, &block); err != nil {
return nil, err
}
delete(block, "vin")
delete(block, "plateNumber")
var optional []string
switch value.(type) {
case RealtimeVehicleResult:
optional = []string{"protocol", "recordTime", "socPercent", "timeDifferenceSeconds"}
case MileageResult:
optional = []string{"dataQuality"}
case HydrogenResult:
optional = []string{"calculationPhase", "algorithmVersion", "qualityStatus", "qualityReason"}
}
for _, name := range optional {
if _, exists := block[name]; !exists {
block[name] = json.RawMessage("null")
}
}
for _, name := range []string{"recordTime", "locationRecordTime", "hydrogenRecordTime", "dataTime", "updatedAt", "statisticsStartTime", "statisticsEndTime"} {
raw, ok := block[name]
if !ok || string(raw) == "null" {
continue
}
var text string
if err = json.Unmarshal(raw, &text); err != nil {
return nil, err
}
at, parseErr := time.Parse(time.RFC3339Nano, text)
if parseErr != nil {
at, parseErr = time.ParseInLocation("2006-01-02 15:04:05", text, location)
}
if parseErr != nil {
return nil, parseErr
}
block[name], _ = json.Marshal(at.In(location).Format(time.RFC3339Nano))
}
block["queryStatus"] = json.RawMessage(`"SUCCESS"`)
block["errorCode"] = json.RawMessage("null")
block["errorMessage"] = json.RawMessage("null")
return block, nil
}
func (h *Handler) operatingData(w http.ResponseWriter, r *http.Request) {
traceID := newExternalTraceID()
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
var request OperatingDataRequest
decoder := json.NewDecoder(r.Body)
if err := decoder.Decode(&request); err != nil {
writeOperatingDecodeError(w, traceID, err)
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
writeOperatingDecodeError(w, traceID, err)
return
}
result, err := h.service.QueryOperatingData(r.Context(), externalBearer(r), traceID, request)
if err != nil {
var limit *historicalHydrogenRateError
var date *operatingDateError
var batch *operatingBatchSizeError
switch {
case errors.As(err, &limit):
w.Header().Set("Retry-After", strconv.Itoa(limit.RetryAfter))
writeExternal(w, http.StatusTooManyRequests, ExternalResponse{Code: "RATE_LIMIT_EXCEEDED", Message: "请求过于频繁,请稍后重试", TraceID: traceID})
case errors.As(err, &batch):
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "BATCH_SIZE_EXCEEDED", Message: "plateNumbers最多200项;查询全部授权车辆请省略plateNumbers或传空数组", TraceID: traceID})
case errors.As(err, &date):
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: date.code, Message: "dailyDate无效或超出允许范围", TraceID: traceID})
default:
writeExternalError(w, traceID, err)
}
return
}
// Large batches need no pretty-printing: keep the new endpoint compact while
// preserving all existing endpoints' response formatting.
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if request.ResponseMode == "SIMPLE" {
response := simplifyOperatingData(result, h.service.now().In(h.service.location))
response.GeneratedAt = h.service.now().In(h.service.location).Format(time.RFC3339Nano)
_ = json.NewEncoder(w).Encode(response)
return
}
_ = json.NewEncoder(w).Encode(result)
}
func writeOperatingDecodeError(w http.ResponseWriter, traceID string, err error) {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
writeExternal(w, http.StatusRequestEntityTooLarge, ExternalResponse{Code: "PAYLOAD_TOO_LARGE", Message: "请求体超过64KiB", TraceID: traceID})
return
}
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: "请求参数不正确", TraceID: traceID})
}
@@ -0,0 +1,138 @@
package openplatform
import (
"encoding/json"
"math"
"time"
)
// All nine keys are serialized, including unknown and unselected values as null.
type OperatingSimpleVehicle struct {
PlateNumber string `json:"plateNumber"`
ReportTime *string `json:"reportTime"`
Offline *bool `json:"offline"`
RemainingHydrogenKg *float64 `json:"remainingHydrogenKg"`
SOCPercent *float64 `json:"socPercent"`
TotalMileageKm *float64 `json:"totalMileageKm"`
DailyHydrogenConsumptionKg *float64 `json:"dailyHydrogenConsumptionKg"`
DailyMileageKm *float64 `json:"dailyMileageKm"`
HydrogenConsumptionPer100KmKg *float64 `json:"hydrogenConsumptionPer100KmKg"`
}
// Explicit fields shadow the embedded FULL data and summary in JSON; all shared
// envelope metadata remains identical. The original result is not mutated.
type OperatingSimpleResponse struct {
*OperatingDataResponse
Data []OperatingSimpleVehicle `json:"data"`
Summary OperatingDataSummary `json:"summary"`
}
func simplifyOperatingData(full *OperatingDataResponse, now time.Time) *OperatingSimpleResponse {
metadata := *full
metadata.Data = nil // Do not retain the large internal FULL payload during encoding.
result := &OperatingSimpleResponse{OperatingDataResponse: &metadata, Data: make([]OperatingSimpleVehicle, len(full.Data)), Summary: OperatingDataSummary{RequestedCount: full.Summary.RequestedCount, ReturnedCount: len(full.Data)}}
for i, source := range full.Data {
row := OperatingSimpleVehicle{PlateNumber: source.PlateNumber}
expected, available := 0, 0
if source.Realtime != nil {
expected += 5
if simpleBlockNormal(source.Realtime) {
row.ReportTime = simpleTime(source.Realtime, "recordTime")
var online *bool
if json.Unmarshal(source.Realtime["online"], &online) == nil && online != nil {
offline := !*online
row.Offline = &offline
}
row.SOCPercent = simpleNumber(source.Realtime, "socPercent", 100)
row.TotalMileageKm = simpleNumber(source.Realtime, "totalMileageKm", math.MaxFloat64)
if simpleText(source.Realtime, "remainingHydrogenKgStatus") == "NORMAL" {
if at := simpleTime(source.Realtime, "hydrogenRecordTime"); at != nil {
observed, _ := time.Parse(time.RFC3339Nano, *at)
if !observed.After(now.Add(time.Minute)) && now.Sub(observed) <= time.Duration(realtimeHydrogenStaleSeconds)*time.Second {
row.RemainingHydrogenKg = simpleNumber(source.Realtime, "remainingHydrogenKg", 500)
}
}
}
}
for _, valid := range []bool{row.ReportTime != nil, row.Offline != nil, row.RemainingHydrogenKg != nil, row.SOCPercent != nil, row.TotalMileageKm != nil} {
if valid {
available++
}
}
}
if source.DailyMileage != nil {
expected++
if simpleBlockNormal(source.DailyMileage) && simpleText(source.DailyMileage, "dataQuality") != "CARRIED_FORWARD" {
row.DailyMileageKm = simpleNumber(source.DailyMileage, "dailyMileageKm", math.MaxFloat64)
}
if row.DailyMileageKm != nil {
available++
}
}
if source.DailyHydrogen != nil {
expected++
if simpleBlockNormal(source.DailyHydrogen) && simpleText(source.DailyHydrogen, "qualityStatus") == "OK" {
row.DailyHydrogenConsumptionKg = simpleNumber(source.DailyHydrogen, "hydrogenConsumptionKg", math.MaxFloat64)
}
if row.DailyHydrogenConsumptionKg != nil {
available++
}
}
// No comparable-coverage proof is available from the independent projections.
// Do not manufacture kg/100km by dividing same-day values. The reserved field
// stays null and is excluded from availability counts until formally supported.
switch {
case available == 0:
result.Summary.FailedCount++
case available == expected:
result.Summary.SuccessCount++
default:
result.Summary.PartialSuccessCount++
}
result.Data[i] = row
}
return result
}
func simpleBlockNormal(block OperatingDataBlock) bool {
return simpleText(block, "queryStatus") == "SUCCESS" && simpleText(block, "status") == StatusNormal
}
func simpleText(block OperatingDataBlock, key string) string {
var value string
_ = json.Unmarshal(block[key], &value)
return value
}
func simpleNumber(block OperatingDataBlock, key string, maximum float64) *float64 {
var value *float64
if json.Unmarshal(block[key], &value) != nil || value == nil || math.IsNaN(*value) || math.IsInf(*value, 0) || *value < 0 || *value > maximum {
return nil
}
return value
}
func simpleTime(block OperatingDataBlock, key string) *string {
value := simpleText(block, key)
at, err := time.Parse(time.RFC3339Nano, value)
if err != nil || at.IsZero() {
return nil
}
return &value
}
// The legacy FULL recordTime follows projection.updated_at, and its total
// mileage historically coerces SQL NULL to zero. SIMPLE uses the actual event
// time and nullable source evidence without changing the FULL contract.
func prepareSimpleRealtime(values []RealtimeVehicleResult, plates []string, vehicles map[string]AuthorizedVehicle, points map[string]RealtimeVehiclePoint, location *time.Location) {
for i, plate := range plates {
point, ok := points[vehicles[plate].VIN]
if !ok {
continue
}
values[i].RecordTime = ""
if !point.LocationObservedAt.IsZero() {
values[i].RecordTime = point.LocationObservedAt.In(location).Format(time.RFC3339Nano)
}
if point.TotalMileageAvailable != nil && !*point.TotalMileageAvailable {
values[i].TotalMileageKm = nil
}
}
}
@@ -0,0 +1,254 @@
package openplatform
import (
"context"
"encoding/json"
"errors"
"github.com/DATA-DOG/go-sqlmock"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
)
func simpleFixture(t testing.TB) (*OperatingDataResponse, time.Time) {
t.Helper()
now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
block := func(text string) OperatingDataBlock {
var b OperatingDataBlock
if err := json.Unmarshal([]byte(text), &b); err != nil {
t.Fatal(err)
}
return b
}
return &OperatingDataResponse{Code: "SUCCESS", Message: "success", TraceID: "trace", QueryDate: stringPointer("2026-10-23"), Timezone: "Asia/Shanghai", RequestAt: now.Format(time.RFC3339), GeneratedAt: now.Format(time.RFC3339), Summary: OperatingDataSummary{RequestedCount: 1, ReturnedCount: 1, SuccessCount: 1}, Data: []OperatingVehicleResult{{VIN: "PRIVATE_VIN", PlateNumber: "粤A12345F",
Realtime: block(`{"queryStatus":"SUCCESS","status":"NORMAL","recordTime":"2026-10-23T11:59:59+08:00","online":false,"remainingHydrogenKg":0,"remainingHydrogenKgStatus":"NORMAL","hydrogenRecordTime":"2026-10-23T11:59:59+08:00","socPercent":0,"totalMileageKm":0}`),
DailyMileage: block(`{"queryStatus":"SUCCESS","status":"NORMAL","dailyMileageKm":0,"dataQuality":null,"totalMileageKm":999}`),
DailyHydrogen: block(`{"queryStatus":"SUCCESS","status":"NORMAL","qualityStatus":"OK","hydrogenConsumptionKg":0}`),
}}}, now
}
func TestSimpleOperatingDataNineFieldsAndZeroValues(t *testing.T) {
full, now := simpleFixture(t)
result := simplifyOperatingData(full, now)
row := result.Data[0]
if row.Offline == nil || !*row.Offline || row.ReportTime == nil {
t.Fatal("report time/offline mapping lost")
}
for _, v := range []*float64{row.RemainingHydrogenKg, row.SOCPercent, row.TotalMileageKm, row.DailyHydrogenConsumptionKg, row.DailyMileageKm} {
if v == nil || *v != 0 {
t.Fatal("real zero dropped")
}
}
if row.HydrogenConsumptionPer100KmKg != nil || result.Summary.SuccessCount != 1 {
t.Fatal("reserved field affects display availability")
}
data, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
var encoded map[string]json.RawMessage
if err = json.Unmarshal(data, &encoded); err != nil {
t.Fatal(err)
}
expected := []string{"code", "message", "traceId", "queryDate", "timezone", "requestAt", "generatedAt", "data", "summary"}
if len(encoded) != len(expected) {
t.Fatalf("extra envelope fields: %s", data)
}
for _, key := range expected {
if _, ok := encoded[key]; !ok {
t.Fatalf("missing %s", key)
}
}
var rows []map[string]json.RawMessage
_ = json.Unmarshal(encoded["data"], &rows)
keys := []string{"plateNumber", "reportTime", "offline", "remainingHydrogenKg", "socPercent", "totalMileageKm", "dailyHydrogenConsumptionKg", "dailyMileageKm", "hydrogenConsumptionPer100KmKg"}
if len(rows[0]) != len(keys) {
t.Fatalf("not exactly nine fields: %s", data)
}
for _, key := range keys {
if _, ok := rows[0][key]; !ok {
t.Fatalf("missing simple key %s", key)
}
}
if string(rows[0]["hydrogenConsumptionPer100KmKg"]) != "null" || strings.Contains(string(data), "PRIVATE_VIN") {
t.Fatal("unproven derived value or internal identity exposed")
}
if !reflect.DeepEqual(full.Summary, OperatingDataSummary{RequestedCount: 1, ReturnedCount: 1, SuccessCount: 1}) {
t.Fatal("FULL result mutated")
}
}
func TestSimpleOperatingDataQualityAndPartialFailures(t *testing.T) {
cases := []struct{ name, block, key, raw, field string }{
{"stale hydrogen", "realtime", "remainingHydrogenKgStatus", `"STALE"`, "remainingHydrogenKg"},
{"missing hydrogen", "realtime", "remainingHydrogenKgStatus", `"MISSING"`, "remainingHydrogenKg"},
{"unsupported hydrogen", "realtime", "remainingHydrogenKgStatus", `"UNSUPPORTED"`, "remainingHydrogenKg"},
{"expired hydrogen evidence", "realtime", "hydrogenRecordTime", `"2026-10-23T11:54:59+08:00"`, "remainingHydrogenKg"},
{"future hydrogen evidence", "realtime", "hydrogenRecordTime", `"2026-10-23T12:01:01+08:00"`, "remainingHydrogenKg"},
{"missing hydrogen time", "realtime", "hydrogenRecordTime", `null`, "remainingHydrogenKg"},
{"negative mass", "realtime", "remainingHydrogenKg", `-1`, "remainingHydrogenKg"},
{"invalid soc", "realtime", "socPercent", `101`, "socPercent"},
{"missing realtime total", "realtime", "totalMileageKm", `null`, "totalMileageKm"},
{"carried zero", "mileage", "dataQuality", `"CARRIED_FORWARD"`, "dailyMileageKm"},
{"mileage anomaly", "mileage", "status", `"DATA_ANOMALY"`, "dailyMileageKm"},
{"hydrogen anomaly", "hydrogen", "status", `"DATA_ANOMALY"`, "dailyHydrogenConsumptionKg"},
{"hydrogen suspect", "hydrogen", "qualityStatus", `"SUSPECT"`, "dailyHydrogenConsumptionKg"},
{"hydrogen failure", "hydrogen", "queryStatus", `"FAILED"`, "dailyHydrogenConsumptionKg"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
full, now := simpleFixture(t)
row := &full.Data[0]
b := row.Realtime
if tc.block == "mileage" {
b = row.DailyMileage
}
if tc.block == "hydrogen" {
b = row.DailyHydrogen
}
b[tc.key] = json.RawMessage(tc.raw)
result := simplifyOperatingData(full, now)
data, _ := json.Marshal(result.Data[0])
var fields map[string]json.RawMessage
_ = json.Unmarshal(data, &fields)
if string(fields[tc.field]) != "null" || result.Summary.PartialSuccessCount != 1 {
t.Fatalf("unsafe value or summary: %s %+v", data, result.Summary)
}
// Unrelated known values survive a failure.
if result.Data[0].ReportTime == nil || result.Data[0].Offline == nil {
t.Fatal("unrelated display data erased")
}
})
}
}
func TestSimpleOperatingDataSelectionsAndNoData(t *testing.T) {
full, now := simpleFixture(t)
full.Data[0].DailyMileage = nil
full.Data[0].DailyHydrogen = nil
full.QueryDate = nil
result := simplifyOperatingData(full, now)
if result.QueryDate != nil || result.Data[0].DailyMileageKm != nil || result.Data[0].DailyHydrogenConsumptionKg != nil || result.Summary.SuccessCount != 1 {
t.Fatal("unselected fields affected counts")
}
full.Data[0].Realtime = operatingFailure(operatingRealtime, true)
result = simplifyOperatingData(full, now)
if result.Data[0].Offline != nil || result.Data[0].ReportTime != nil || result.Summary.FailedCount != 1 {
t.Fatal("failure became offline or display success")
}
full.Data[0].Realtime = OperatingDataBlock{"queryStatus": json.RawMessage(`"SUCCESS"`), "status": json.RawMessage(`"NO_DATA"`), "online": json.RawMessage(`false`)}
result = simplifyOperatingData(full, now)
if result.Data[0].Offline != nil || result.Summary.FailedCount != 1 {
t.Fatal("missing main record became offline")
}
}
func TestSimpleOperatingDataHTTPModeAndCompatibility(t *testing.T) {
for _, mode := range []string{"", "FULL", "SIMPLE"} {
t.Run(mode, func(t *testing.T) {
s, f := newOperatingFixture()
f.query = func(_ context.Context, kind string) error {
if kind == operatingHydrogen {
return errors.New("upstream unavailable")
}
return nil
}
body := map[string]any{"plateNumbers": []string{"粤A12345F"}, "dailyDate": "2026-10-23"}
if mode != "" {
body["responseMode"] = mode
}
raw, _ := json.Marshal(body)
req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(string(raw)))
req.Header.Set("Authorization", "Bearer "+operatingTestKey)
w := httptest.NewRecorder()
NewDataHandler(s).ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("HTTP=%d body=%s", w.Code, w.Body.String())
}
var value struct {
Data []map[string]json.RawMessage `json:"data"`
Summary OperatingDataSummary `json:"summary"`
}
if err := json.Unmarshal(w.Body.Bytes(), &value); err != nil {
t.Fatal(err)
}
if mode == "SIMPLE" {
if len(value.Data[0]) != 9 || value.Summary.FailedCount != 1 {
t.Fatal("SIMPLE shape or availability wrong")
}
} else {
if _, ok := value.Data[0]["realtime"]; !ok || value.Summary.PartialSuccessCount != 1 {
t.Fatal("FULL compatibility lost")
}
}
})
}
for _, raw := range []string{`null`, `""`, `"simple"`, `"UNKNOWN"`, `[]`} {
t.Run("invalid "+raw, func(t *testing.T) {
s, _ := newOperatingFixture()
req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"responseMode":`+raw+`,"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23"}`))
w := httptest.NewRecorder()
NewDataHandler(s).ServeHTTP(w, req)
if w.Code != 400 {
t.Fatalf("invalid mode HTTP=%d", w.Code)
}
})
}
}
func BenchmarkSimpleOperatingData200Vehicles(b *testing.B) {
full, now := simpleFixture(b)
full.Data = make([]OperatingVehicleResult, 200)
fixture, _ := simpleFixture(b)
for i := range full.Data {
full.Data[i] = fixture.Data[0]
}
full.Summary.RequestedCount = 200
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if _, err := json.Marshal(simplifyOperatingData(full, now)); err != nil {
b.Fatal(err)
}
}
}
func TestSimpleUsesSourceEventTimeAndNullableMileage(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
now := time.Date(2026, 10, 23, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600))
eventAt := now.Add(-10 * time.Minute)
mock.ExpectQuery("l.soc_percent,l.total_mileage_km,l.updated_at").WithArgs(time.Date(2026, 10, 23, 0, 0, 0, 0, now.Location()), "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", "event_time", "received_at", "event_id"}).AddRow("vin", "GB32960", 120, 30, 0, 0, nil, now, true, eventAt, now, "event"))
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{"vin"}, now)
if err != nil {
t.Fatal(err)
}
plates := []string{"粤A12345F"}
vehicles := map[string]AuthorizedVehicle{plates[0]: {VIN: "vin", Plate: plates[0]}}
full := buildRealtimeVehicleResults(plates, vehicles, points, now, now.Location())
if full[0].TotalMileageKm == nil || *full[0].TotalMileageKm != 0 || full[0].RecordTime != now.Format("2006-01-02 15:04:05") {
t.Fatal("FULL compatibility changed")
}
prepareSimpleRealtime(full, plates, vehicles, points, now.Location())
if full[0].TotalMileageKm != nil || full[0].RecordTime != eventAt.Format(time.RFC3339Nano) {
t.Fatal("SIMPLE used update time or fabricated zero")
}
if err = mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
// An absent event time cannot be replaced with received_at or updated_at.
point := points["vin"]
point.LocationObservedAt = time.Time{}
available := true
point.TotalMileageAvailable = &available
points["vin"] = point
values := buildRealtimeVehicleResults(plates, vehicles, points, now, now.Location())
prepareSimpleRealtime(values, plates, vehicles, points, now.Location())
if values[0].RecordTime != "" || values[0].TotalMileageKm == nil || *values[0].TotalMileageKm != 0 {
t.Fatal("missing source time or real zero mishandled")
}
}
@@ -0,0 +1,649 @@
package openplatform
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"github.com/DATA-DOG/go-sqlmock"
"net/http/httptest"
"reflect"
"regexp"
"strings"
"sync"
"testing"
"time"
)
const operatingTestKey = "0123456789abcdef0123456789abcdef"
type operatingRepository struct {
*fakeRepository
mu sync.Mutex
calls map[string]int
auth func(time.Time, time.Time) error
scope func(time.Time, time.Time) (map[string]AuthorizedVehicle, error)
query func(context.Context, string) error
protocols []string
}
func (f *operatingRepository) Authenticate(_ context.Context, _ [sha256.Size]byte, _ time.Time, start, end time.Time) (AppCredential, error) {
f.mu.Lock()
f.calls["auth"]++
f.mu.Unlock()
if f.auth != nil {
if err := f.auth(start, end); err != nil {
return AppCredential{}, err
}
}
return f.app, nil
}
func (f *operatingRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates []string, start, end time.Time) (map[string]AuthorizedVehicle, error) {
f.mu.Lock()
f.calls["scope"]++
f.mu.Unlock()
if f.scope != nil {
return f.scope(start, end)
}
if len(plates) == 0 {
plates = vehiclePlates(f.vehicles)
}
found := make(map[string]AuthorizedVehicle, len(plates))
for _, plate := range plates {
if v, ok := f.vehicles[plate]; ok {
found[plate] = v
}
}
return found, nil
}
func (f *operatingRepository) before(ctx context.Context, kind string) error {
f.mu.Lock()
f.calls[kind]++
f.mu.Unlock()
if f.query != nil {
return f.query(ctx, kind)
}
return nil
}
func (f *operatingRepository) RealtimeVehicles(ctx context.Context, _ []string, _ time.Time) (map[string]RealtimeVehiclePoint, error) {
return f.realtime, f.before(ctx, operatingRealtime)
}
func (f *operatingRepository) DailyHydrogen(ctx context.Context, _ []string, _ string) (map[string]DailyHydrogen, error) {
return f.hydrogen, f.before(ctx, operatingHydrogen)
}
func (f *operatingRepository) ReconciledMileageRange(ctx context.Context, _ []string, _, _ string, protocols []string) (map[string]DailyMileage, error) {
f.mu.Lock()
f.protocols = append([]string(nil), protocols...)
f.mu.Unlock()
return f.mileage, f.before(ctx, operatingMileage)
}
func newOperatingFixture() (*Service, *operatingRepository) {
f := &operatingRepository{fakeRepository: &fakeRepository{app: AppCredential{ID: 7}, vehicles: map[string]AuthorizedVehicle{"粤A12345F": {VIN: "LTEST32960VIN0001", Plate: "粤A12345F"}, "粤B12345F": {VIN: "LTEST32960VIN0002", Plate: "粤B12345F"}}}, calls: map[string]int{}}
s := NewService(f)
s.now = func() time.Time { return time.Date(2026, 10, 23, 9, 30, 0, 0, s.location) }
return s, f
}
func operatingRequest() OperatingDataRequest {
return OperatingDataRequest{PlateNumbers: []string{"粤B12345F", " 粤a12345f "}, DailyDate: "2026-10-23"}
}
func blockText(b OperatingDataBlock, key string) string {
var v string
_ = json.Unmarshal(b[key], &v)
return v
}
func TestOperatingDataBatchesInParallelAndPreservesSemantics(t *testing.T) {
s, f := newOperatingFixture()
now := s.now()
f.realtime = map[string]RealtimeVehiclePoint{"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: now.Add(-time.Second), Online: true, SpeedKmh: 5, LiveHydrogen: RealtimeHydrogenData{RemainingHydrogenKg: float64Pointer(0), RemainingHydrogenKgStatus: "NORMAL", RemainingHydrogenPercentStatus: "MISSING", HydrogenDataStatus: "PARTIAL", HydrogenValueSource: stringPointer("REPORTED"), HydrogenRecordTime: stringPointer("2026-10-23T09:29:59+08:00")}}}
f.hydrogen = map[string]DailyHydrogen{"LTEST32960VIN0001": {QualityStatus: "SUSPECT", QualityReason: "test anomaly", ConsumptionKg: 3, CalculationPhase: "PRELIMINARY", AlgorithmVersion: "test-v1"}}
f.mileage = map[string]DailyMileage{dailyMileageKey("LTEST32960VIN0001", "2026-10-23"): {Date: "2026-10-22", Protocol: "GB32960", MileageKm: 0, TotalMileageKm: 200, DataTime: "2026-10-22T23:00:00+08:00", UpdatedAt: "2026-10-23T05:00:00+08:00", DataQuality: "CARRIED_FORWARD"}}
started := make(chan string, 3)
release := make(chan struct{})
f.query = func(ctx context.Context, kind string) error {
started <- kind
select {
case <-release:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
done := make(chan *OperatingDataResponse, 1)
errs := make(chan error, 1)
go func() {
r, e := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest())
done <- r
errs <- e
}()
for i := 0; i < 3; i++ {
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("batched queries did not start concurrently")
}
}
close(release)
result := <-done
if err := <-errs; err != nil {
t.Fatal(err)
}
if len(result.Data) != 2 || result.Data[0].PlateNumber != "粤B12345F" || result.Summary.SuccessCount != 2 || result.QueryDate == nil {
t.Fatalf("unexpected response: %+v", result)
}
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
if f.calls[kind] != 1 {
t.Fatalf("%s calls=%d", kind, f.calls[kind])
}
}
if f.calls["auth"] != 2 || f.calls["scope"] != 2 {
t.Fatalf("duplicate authorization reads: %v", f.calls)
}
row := result.Data[1]
if string(row.Realtime["remainingHydrogenKg"]) != "0" || blockText(row.Realtime, "hydrogenDataStatus") != "PARTIAL" || blockText(row.Realtime, "recordTime") != "2026-10-23T09:29:59+08:00" {
t.Fatalf("live quality/zero/time lost: %s", mustOperatingJSON(row.Realtime))
}
if blockText(row.DailyMileage, "dataQuality") != "CARRIED_FORWARD" || blockText(row.DailyMileage, "status") != "NORMAL" || string(row.DailyMileage["dailyMileageKm"]) != "0" {
t.Fatal("carry-forward semantics changed")
}
if blockText(row.DailyHydrogen, "status") != "DATA_ANOMALY" || string(row.DailyHydrogen["hydrogenConsumptionKg"]) != "3" {
t.Fatal("anomaly evidence lost")
}
if blockText(result.Data[0].Realtime, "status") != "NO_DATA" || string(result.Data[0].Realtime["socPercent"]) != "null" {
t.Fatal("missing record not preserved")
}
// Compare with the independent endpoints through their original query services.
f.query = nil
old := NewService(f.fakeRepository)
old.now = s.now
plates := []string{"粤B12345F", "粤A12345F"}
rt, err := old.QueryRealtimeVehicles(context.Background(), operatingTestKey, "old", RealtimeVehicleRequest{PlateNumbers: plates})
if err != nil {
t.Fatal(err)
}
h, err := old.QueryHydrogen(context.Background(), operatingTestKey, "old", QueryRequest{PlateNumbers: plates, Date: "2026-10-23"})
if err != nil {
t.Fatal(err)
}
m, err := old.QueryMileage(context.Background(), operatingTestKey, "old", QueryRequest{PlateNumbers: plates, Date: "2026-10-23"})
if err != nil {
t.Fatal(err)
}
for i := range rt {
a, _ := operatingBlock(rt[i], s.location)
b, _ := operatingBlock(h[i], s.location)
c, _ := operatingBlock(m[i], s.location)
if !reflect.DeepEqual(a, result.Data[i].Realtime) || !reflect.DeepEqual(b, result.Data[i].DailyHydrogen) || !reflect.DeepEqual(c, result.Data[i].DailyMileage) {
t.Fatal("independent query mismatch")
}
}
}
func mustOperatingJSON(v any) string { b, _ := json.Marshal(v); return string(b) }
func TestOperatingDataAuthorizationFailsBeforeReadingData(t *testing.T) {
for _, scenario := range []string{"invalid key", "current key", "history key", "current scope", "history scope", "identity conflict", "scope unavailable", "auth unavailable"} {
t.Run(scenario, func(t *testing.T) {
s, f := newOperatingFixture()
key := operatingTestKey
want := ErrForbidden
if scenario == "invalid key" {
key = "bad"
want = ErrUnauthorized
}
f.auth = func(start, end time.Time) error {
if scenario == "current key" && start.Equal(end) {
return ErrUnauthorized
}
if scenario == "history key" && !start.Equal(end) {
return ErrUnauthorized
}
if scenario == "auth unavailable" {
return errors.New("database unavailable")
}
return nil
}
f.scope = func(start, end time.Time) (map[string]AuthorizedVehicle, error) {
if scenario == "current scope" && start.Equal(end) || scenario == "history scope" && !start.Equal(end) {
return map[string]AuthorizedVehicle{}, nil
}
if scenario == "scope unavailable" {
return nil, errors.New("database unavailable")
}
found := map[string]AuthorizedVehicle{}
for k, v := range f.vehicles {
found[k] = v
}
if scenario == "identity conflict" && !start.Equal(end) {
found["粤A12345F"] = AuthorizedVehicle{VIN: "different"}
}
return found, nil
}
_, err := s.QueryOperatingData(context.Background(), key, "trace", operatingRequest())
switch scenario {
case "current key":
want = ErrUnauthorized
case "auth unavailable", "scope unavailable", "identity conflict":
want = nil
}
if err == nil || want != nil && !errors.Is(err, want) {
t.Fatalf("err=%v want=%v", err, want)
}
if want == nil && (errors.Is(err, ErrUnauthorized) || errors.Is(err, ErrForbidden)) {
t.Fatal("infrastructure error hidden as denial")
}
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
if f.calls[kind] != 0 {
t.Fatal("queried data before all scopes passed")
}
}
})
}
}
func TestOperatingDataSelectionsAndFailures(t *testing.T) {
for _, scenario := range []string{"only realtime", "only mileage", "hydrogen failure", "all failed", "invalid source"} {
t.Run(scenario, func(t *testing.T) {
s, f := newOperatingFixture()
r := operatingRequest()
switch scenario {
case "only realtime":
r.DataTypes = []string{operatingRealtime}
r.DailyDate = ""
case "only mileage":
r.DataTypes = []string{operatingMileage}
r.ProtocolPriority = ProtocolPriority{Present: true, Values: []string{"JT808"}}
case "invalid source":
f.realtime = map[string]RealtimeVehiclePoint{"LTEST32960VIN0001": {ObservedAt: s.now(), LiveHydrogen: RealtimeHydrogenData{HydrogenDataStatus: "NORMAL", HydrogenRecordTime: stringPointer("bad timestamp")}}}
}
f.query = func(_ context.Context, kind string) error {
if scenario == "all failed" || scenario == "hydrogen failure" && kind == operatingHydrogen {
return errors.New("SQL secret should not escape")
}
return nil
}
result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r)
if err != nil {
t.Fatal(err)
}
switch scenario {
case "only realtime":
if result.QueryDate != nil || result.Data[0].DailyMileage != nil || result.Data[0].DailyHydrogen != nil || f.calls["auth"] != 1 || f.calls["scope"] != 1 {
t.Fatal("unselected work executed")
}
case "only mileage":
if result.Data[0].Realtime != nil || f.calls["scope"] != 1 || !reflect.DeepEqual(f.protocols, []string{"JT808"}) {
t.Fatalf("selection/protocol ignored: %v", f.protocols)
}
case "hydrogen failure":
if result.Summary.PartialSuccessCount != 2 || blockText(result.Data[0].DailyHydrogen, "errorCode") != "HYDROGEN_QUERY_FAILED" || len(result.Data[0].DailyHydrogen) != 4 {
t.Fatal("failure envelope incorrect")
}
case "all failed":
if result.Summary.FailedCount != 2 {
t.Fatal("all-failed count incorrect")
}
case "invalid source":
if blockText(result.Data[1].Realtime, "errorCode") != "SOURCE_DATA_INVALID" || result.Summary.PartialSuccessCount != 1 {
t.Fatal("invalid source not isolated")
}
}
if strings.Contains(mustOperatingJSON(result), "SQL secret") {
t.Fatal("upstream details leaked")
}
})
}
}
func TestOperatingDataDeadlineKeepsCompletedBlocks(t *testing.T) {
s, f := newOperatingFixture()
exited := make(chan struct{})
f.query = func(ctx context.Context, kind string) error {
if kind == operatingHydrogen {
<-ctx.Done()
close(exited)
return ctx.Err()
}
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
result, err := s.QueryOperatingData(ctx, operatingTestKey, "trace", operatingRequest())
if err != nil {
t.Fatal(err)
}
if result.Summary.PartialSuccessCount != 2 || blockText(result.Data[0].DailyHydrogen, "errorCode") != "HYDROGEN_QUERY_TIMEOUT" {
t.Fatalf("deadline discarded successful blocks: %+v", result.Summary)
}
select {
case <-exited:
case <-time.After(time.Second):
t.Fatal("query not canceled")
}
}
func TestOperatingDataHTTPValidationAndRouting(t *testing.T) {
tests := []struct {
body string
status int
}{
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23"}`, 200},
{`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`, 200},
{`{"dailyDate":"2026-10-23"}`, 200},
{`{"plateNumbers":[],"dailyDate":"2026-10-23"}`, 200},
{`{"dataTypes":["REALTIME"]}`, 200},
{`{"plateNumbers":[],"dataTypes":["REALTIME"],"responseMode":"SIMPLE"}`, 200},
{`{"plateNumbers":null,"dataTypes":["REALTIME"]}`, 400},
{`{}`, 400}, {`null`, 400}, {`[]`, 400}, {`{"plateNumbers":[]}`, 400},
{`{"plateNumbers":["粤A12345F"," 粤a12345f "],"dailyDate":"2026-10-23"}`, 400},
{`{"plateNumbers":["粤A12345F"],"dailyDate":null}`, 400},
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-24"}`, 400},
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":null}`, 400},
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":[]}`, 400},
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","dataTypes":["REALTIME","REALTIME"]}`, 400},
{`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"],"protocolPriority":["GB32960"]}`, 400},
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","protocolPriority":null}`, 400},
{`{"plateNumbers":["粤A12345F"],"dailyDate":"2026-10-23","extra":1}`, 400},
{`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]} {}`, 400},
{`{"plateNumbers":["` + strings.Repeat("x", 65<<10) + `"]}`, 413},
}
for i, tc := range tests {
t.Run(fmt.Sprint(i), func(t *testing.T) {
s, _ := newOperatingFixture()
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(tc.body))
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
w := httptest.NewRecorder()
NewDataHandler(s).ServeHTTP(w, r)
if w.Code != tc.status {
t.Fatalf("status=%d want=%d body=%s", w.Code, tc.status, w.Body.String())
}
})
}
if !IsPublicPath(OperatingDataQueryPath) {
t.Fatal("new endpoint not registered as appKey route")
}
for _, construct := range []func(*Service) *Handler{NewHandler, func(s *Service) *Handler { return NewExternalHandler(s, nil) }} {
s, _ := newOperatingFixture()
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`))
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
w := httptest.NewRecorder()
construct(s).ServeHTTP(w, r)
if w.Code != 200 {
t.Fatalf("handler route missing: %d", w.Code)
}
}
}
func TestOperatingDataLimitsAndBatchBoundary(t *testing.T) {
s, f := newOperatingFixture()
r := OperatingDataRequest{DataTypes: []string{operatingRealtime}}
for i := 0; i < 200; i++ {
plate := fmt.Sprintf("TEST%03d", i)
r.PlateNumbers = append(r.PlateNumbers, plate)
f.vehicles[plate] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)}
}
// A repository returns only the authorized requested subset.
f.scope = func(_, _ time.Time) (map[string]AuthorizedVehicle, error) {
v := map[string]AuthorizedVehicle{}
for _, p := range r.PlateNumbers {
v[p] = f.vehicles[p]
}
return v, nil
}
result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r)
if err != nil || len(result.Data) != 200 {
t.Fatalf("boundary: %v", err)
}
r.PlateNumbers = append(r.PlateNumbers, "TEST201")
if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r); !errors.Is(err, ErrInvalidRequest) {
t.Fatal("201 accepted")
}
s, f = newOperatingFixture()
_ = f
for i := 0; i < 30; i++ {
if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest()); err != nil {
t.Fatal(err)
}
}
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`))
req.Header.Set("Authorization", "Bearer "+operatingTestKey)
NewDataHandler(s).ServeHTTP(w, req)
if w.Code != 429 || w.Header().Get("Retry-After") == "" || !strings.Contains(w.Body.String(), "RATE_LIMIT_EXCEEDED") {
t.Fatalf("limit: %d %s", w.Code, w.Body.String())
}
// Global capacity rejects work before any scope/data reads.
s, f = newOperatingFixture()
for i := 0; i < cap(s.operatingSlots); i++ {
s.operatingSlots <- struct{}{}
}
if _, err = s.QueryOperatingData(context.Background(), operatingTestKey, "trace", operatingRequest()); err == nil || f.calls["scope"] != 0 {
t.Fatal("global capacity ignored")
}
}
func BenchmarkOperatingData200Vehicles(b *testing.B) {
s, f := newOperatingFixture()
r := operatingRequest()
r.PlateNumbers = nil
f.vehicles = map[string]AuthorizedVehicle{}
for i := 0; i < 200; i++ {
p := fmt.Sprintf("TEST%03d", i)
r.PlateNumbers = append(r.PlateNumbers, p)
f.vehicles[p] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)}
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.operatingLimiter = historicalHydrogenLimiter{}
f.audits = nil
if _, err := s.QueryOperatingData(context.Background(), operatingTestKey, "bench", r); err != nil {
b.Fatal(err)
}
}
}
func TestOperatingInstantGrantExpiresExclusively(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatal(err)
}
defer db.Close()
now := time.Date(2026, 10, 23, 10, 0, 0, 0, time.UTC)
mock.ExpectQuery(regexp.QuoteMeta("AND (g.valid_to IS NULL OR g.valid_to>?)")).WithArgs(uint64(7), now, now, "粤A12345F").WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}))
_, err = NewMySQLRepository(db).AuthorizedVehicles(context.Background(), 7, []string{"粤A12345F"}, now, now)
if err != nil {
t.Fatal(err)
}
if err = mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
func TestOperatingDataRetainsAllBusinessFields(t *testing.T) {
s, _ := newOperatingFixture()
for _, value := range []any{RealtimeVehicleResult{}, MileageResult{}, HydrogenResult{}} {
block, err := operatingBlock(value, s.location)
if err != nil {
t.Fatal(err)
}
var check func(reflect.Type)
check = func(typ reflect.Type) {
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if field.Anonymous {
check(field.Type)
continue
}
name := strings.Split(field.Tag.Get("json"), ",")[0]
if name == "vin" || name == "plateNumber" || name == "" {
continue
}
if _, ok := block[name]; !ok {
t.Errorf("%s missing %s", typ.Name(), name)
}
}
}
check(reflect.TypeOf(value))
if _, exists := block["vin"]; exists {
t.Fatal("duplicated identity")
}
}
}
func TestOperatingDataGeneratesDistinctTraceIDs(t *testing.T) {
s, _ := newOperatingFixture()
handler := NewDataHandler(s)
seen := map[string]bool{}
for i := 0; i < 2; i++ {
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345F"],"dataTypes":["REALTIME"]}`))
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
r.Header.Set("X-Trace-Id", "reused-client-id")
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
var result OperatingDataResponse
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if w.Code != 200 || result.TraceID == "reused-client-id" || len(result.TraceID) != 32 || seen[result.TraceID] {
t.Fatalf("invalid trace: %s", w.Body.String())
}
seen[result.TraceID] = true
}
}
func TestOperatingDataOptionalPlatesAuthorization(t *testing.T) {
for _, scenario := range []string{"all", "realtime only", "history only", "intersection", "empty current", "empty history", "no grants", "identity conflict", "large all", "large before intersection"} {
t.Run(scenario, func(t *testing.T) {
s, f := newOperatingFixture()
r := OperatingDataRequest{DailyDate: "2026-10-23"}
if scenario == "realtime only" {
r.DataTypes = []string{operatingRealtime}
r.DailyDate = ""
}
if scenario == "history only" {
r.DataTypes = []string{operatingMileage, operatingHydrogen}
}
f.scope = func(start, end time.Time) (map[string]AuthorizedVehicle, error) {
found := map[string]AuthorizedVehicle{}
for plate, v := range f.vehicles {
found[plate] = v
}
current := start.Equal(end)
if scenario == "no grants" || scenario == "empty current" && current || scenario == "empty history" && !current {
return map[string]AuthorizedVehicle{}, nil
}
if scenario == "intersection" && !current {
delete(found, "粤B12345F")
found["NEW"] = AuthorizedVehicle{VIN: "OTHER"}
}
if scenario == "identity conflict" && !current {
found["粤B12345F"] = AuthorizedVehicle{VIN: "CHANGED"}
}
if scenario == "large all" || scenario == "large before intersection" && current {
for i := 0; i < 201; i++ {
found[fmt.Sprintf("EXTRA%03d", i)] = AuthorizedVehicle{VIN: fmt.Sprintf("VIN%014d", i)}
}
}
return found, nil
}
result, err := s.QueryOperatingData(context.Background(), operatingTestKey, "trace", r)
if scenario == "identity conflict" {
if err == nil {
t.Fatalf("expected authorization error, got %v", err)
}
} else {
if err != nil {
t.Fatal(err)
}
want := 2
if scenario == "large all" {
want = 203
}
if scenario == "intersection" {
want = 1
}
if scenario == "empty current" || scenario == "empty history" || scenario == "no grants" {
want = 0
}
if result.Data == nil || len(result.Data) != want || result.Summary.RequestedCount != want || result.Summary.ReturnedCount != want {
t.Fatalf("incorrect authorized result: %+v", result)
}
if want > 0 && scenario != "large all" && result.Data[0].PlateNumber != "粤A12345F" {
t.Fatal("default vehicle order is not stable")
}
}
if err != nil || len(result.Data) == 0 {
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
if f.calls[kind] != 0 {
t.Fatal("business data read without an authorized batch")
}
}
}
})
}
}
func TestOperatingData1151AuthorizedVehiclesHTTP(t *testing.T) {
for _, mode := range []string{"FULL", "SIMPLE"} {
for _, empty := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/empty=%t", mode, empty), func(t *testing.T) {
s, f := newOperatingFixture()
f.vehicles = map[string]AuthorizedVehicle{}
for i := 0; i < 1151; i++ {
plate := fmt.Sprintf("TEST%04d", i)
f.vehicles[plate] = AuthorizedVehicle{Plate: plate, VIN: fmt.Sprintf("VIN%014d", i)}
}
payload := map[string]any{"responseMode": mode, "dailyDate": "2026-10-23"}
if empty {
payload["plateNumbers"] = []string{}
}
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(mustOperatingJSON(payload)))
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
w := httptest.NewRecorder()
NewDataHandler(s).ServeHTTP(w, r)
var result struct {
Data []struct {
PlateNumber string `json:"plateNumber"`
} `json:"data"`
Summary OperatingDataSummary `json:"summary"`
}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
if w.Code != 200 || len(result.Data) != 1151 || result.Summary.RequestedCount != 1151 || result.Summary.ReturnedCount != 1151 {
t.Fatalf("status=%d rows=%d summary=%+v", w.Code, len(result.Data), result.Summary)
}
for i, row := range result.Data {
if row.PlateNumber != fmt.Sprintf("TEST%04d", i) {
t.Fatal("missing, duplicate or unordered authorized vehicle")
}
}
for _, kind := range []string{operatingRealtime, operatingMileage, operatingHydrogen} {
if f.calls[kind] != 1 {
t.Fatalf("%s not queried in one batch", kind)
}
}
})
}
}
}
func TestOperatingDataExplicitBatchSizeErrorHTTP(t *testing.T) {
s, _ := newOperatingFixture()
plates := make([]string, 201)
for i := range plates {
plates[i] = fmt.Sprintf("TEST%04d", i)
}
r := httptest.NewRequest("POST", OperatingDataQueryPath, strings.NewReader(mustOperatingJSON(map[string]any{"plateNumbers": plates, "dataTypes": []string{operatingRealtime}})))
r.Header.Set("Authorization", "Bearer "+operatingTestKey)
w := httptest.NewRecorder()
NewDataHandler(s).ServeHTTP(w, r)
var response ExternalResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if w.Code != 400 || response.Code != "BATCH_SIZE_EXCEEDED" || !strings.Contains(response.Message, "200") || response.TraceID == "" {
t.Fatalf("status=%d response=%+v", w.Code, response)
}
}
@@ -282,18 +282,24 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "error", traceID, len(plates), err.Error())
return nil, err
}
results := buildRealtimeVehicleResults(plates, vehicles, points, now, s.location)
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
return results, nil
}
func buildRealtimeVehicleResults(plates []string, vehicles map[string]AuthorizedVehicle, points map[string]RealtimeVehiclePoint, now time.Time, location *time.Location) []RealtimeVehicleResult {
results := make([]RealtimeVehicleResult, 0, len(plates))
for _, plate := range plates {
vehicle := vehicles[plate]
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())
difference := int64(now.Sub(point.ObservedAt.In(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")
applyRealtimeLiveData(&item, point, location)
item.RecordTime = point.ObservedAt.In(location).Format("2006-01-02 15:04:05")
item.TimeDifferenceSeconds = &difference
item.Online = point.Online
item.ActiveToday = point.ActiveToday
@@ -315,8 +321,7 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
}
results = append(results, item)
}
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
return results, nil
return results
}
func (s *Service) QueryHydrogenStations(ctx context.Context, appKey, traceID string, request HydrogenStationRequest) ([]HydrogenStation, error) {
@@ -349,6 +354,8 @@ func validCoordinate(longitude, latitude float64) bool {
type Service struct {
historicalLimiter historicalHydrogenLimiter
operatingLimiter historicalHydrogenLimiter
operatingSlots chan struct{}
repository Repository
now func() time.Time
location *time.Location
@@ -362,9 +369,10 @@ type parsedGrant struct {
func NewService(repository Repository) *Service {
return &Service{
repository: repository,
now: time.Now,
location: time.FixedZone("Asia/Shanghai", 8*60*60),
repository: repository,
operatingSlots: make(chan struct{}, 16),
now: time.Now,
location: time.FixedZone("Asia/Shanghai", 8*60*60),
}
}
@@ -387,6 +395,12 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "error", traceID, len(plates), err.Error())
return nil, err
}
results := buildHydrogenResults(plates, vehicles, values, date)
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
return results, nil
}
func buildHydrogenResults(plates []string, vehicles map[string]AuthorizedVehicle, values map[string]DailyHydrogen, date string) []HydrogenResult {
results := make([]HydrogenResult, 0, len(plates))
for _, plate := range plates {
vehicle := vehicles[plate]
@@ -414,8 +428,7 @@ func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, req
}
results = append(results, item)
}
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
return results, nil
return results
}
func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, request QueryRequest) ([]MileageResult, error) {
@@ -441,6 +454,12 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
return nil, err
}
results := buildMileageResults(plates, vehicles, values, date)
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "")
return results, nil
}
func buildMileageResults(plates []string, vehicles map[string]AuthorizedVehicle, values map[string]DailyMileage, date string) []MileageResult {
results := make([]MileageResult, 0, len(plates))
for _, plate := range plates {
vehicle := vehicles[plate]
@@ -450,8 +469,7 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
}
results = append(results, item)
}
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "")
return results, nil
return results
}
func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, request MileageRangeRequest) (MileageRangeResponse, error) {
@@ -716,8 +734,8 @@ func (s *Service) validateMileageRange(request MileageRangeRequest) ([]string, s
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: endDate precedes startDate", ErrInvalidRequest)
}
end := endInclusive.AddDate(0, 0, 1)
if days := int(end.Sub(start).Hours() / 24); days < 1 || days > 366 {
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: date range exceeds 366 days", ErrInvalidRequest)
if days := int(end.Sub(start).Hours() / 24); days < 1 || days > 1096 {
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: date range exceeds 1096 days", ErrInvalidRequest)
}
pageSize := request.PageSize
if pageSize == 0 {
@@ -661,7 +661,7 @@ func TestMileageRangeSkipsPriorMileageLookupWhenEveryVINHasInitialData(t *testin
func TestMileageRangeValidationLimitsWindowAndPageSize(t *testing.T) {
service := NewService(&fakeRepository{})
for _, request := range []MileageRangeRequest{
{StartDate: "2025-07-01", EndDate: "2026-07-02", PageSize: 100},
{StartDate: "2023-07-01", EndDate: "2026-07-02", PageSize: 100},
{StartDate: "2026-07-02", EndDate: "2026-07-01", PageSize: 100},
{StartDate: "2026-07-01", EndDate: "2026-07-02", PageSize: 5001},
} {
@@ -801,3 +801,12 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String())
}
}
// Preserve the already deployed three-year window when adding new public APIs.
func TestMileageRangePreservesProductionThreeYearWindow(t *testing.T) {
s := NewService(&fakeRepository{})
_, _, _, _, _, _, _, err := s.validateMileageRange(MileageRangeRequest{StartDate: "2023-07-01", EndDate: "2026-06-30", PageSize: 1})
if err != nil {
t.Fatalf("production 1096-day window rejected: %v", err)
}
}
@@ -12,6 +12,8 @@ import (
"strconv"
"strings"
"time"
"lingniu/vehicle-data-platform/apps/api/internal/hydrogen"
)
var hydrogenMassFields = []string{
@@ -818,23 +820,7 @@ ORDER BY event_time ` + direction + ` LIMIT 1`
}
func PressureHydrogenMassKg(pressureMPa, temperatureC, capacityLiter float64) (float64, bool) {
temperatureK := temperatureC + 273.15
if pressureMPa < 0 || pressureMPa > 70 || temperatureK < 220 || temperatureK > 1000 || capacityLiter <= 0 || capacityLiter > 10000 {
return 0, false
}
a := [...]float64{0.05888460, -0.06136111, -0.002650473, 0.002731125, 0.001802374, -0.001150707, 0.00009588528, -0.0000001109040, 0.0000000001264403}
b := [...]float64{1.325, 1.87, 2.5, 2.8, 2.938, 3.14, 3.37, 3.75, 4.0}
c := [...]float64{1, 1, 2, 2, 2.42, 2.63, 3, 4, 5}
z := 1.0
for index := range a {
z += a[index] * math.Pow(100/temperatureK, b[index]) * math.Pow(pressureMPa, c[index])
}
if z <= 0 {
return 0, false
}
density := pressureMPa * 1000 / (8.314472 * temperatureK * z) * 0.00201588 * 1000
mass := density * capacityLiter / 1000
return mass, !math.IsNaN(mass) && !math.IsInf(mass, 0) && mass >= 0 && mass <= 500
return hydrogen.PressureMassKg(pressureMPa, temperatureC, capacityLiter)
}
func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {