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:
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 {
|
||||
|
||||
Reference in New Issue
Block a user