feat: expand vehicle data platform capabilities
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>车辆数据开放平台</title>
|
||||
<style>
|
||||
:root{color-scheme:light;--ink:#13213c;--muted:#60708c;--line:#dce3ed;--blue:#1768e5;--soft:#f4f7fb}
|
||||
*{box-sizing:border-box}body{margin:0;font:15px/1.65 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:var(--ink);background:#fff}
|
||||
main{max-width:980px;margin:auto;padding:48px 24px 80px}header{padding:34px;border:1px solid var(--line);border-radius:18px;background:linear-gradient(135deg,#f5f9ff,#eef4ff)}
|
||||
h1{margin:0 0 10px;font-size:34px}h2{margin:42px 0 14px;font-size:22px}h3{margin:24px 0 10px;font-size:17px}
|
||||
p{margin:8px 0;color:var(--muted)}a{color:var(--blue)}nav{display:flex;gap:12px;flex-wrap:wrap;margin-top:20px}
|
||||
nav a{padding:8px 13px;border:1px solid #b9cef0;border-radius:9px;text-decoration:none;background:#fff}
|
||||
code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}pre{overflow:auto;padding:18px;border-radius:12px;background:#101827;color:#e9f1ff;font-size:13px}
|
||||
table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:11px;border-bottom:1px solid var(--line);vertical-align:top}th{background:var(--soft)}
|
||||
.method{display:inline-block;margin-right:8px;padding:2px 8px;border-radius:6px;background:#dff3e5;color:#136b35;font-weight:700}
|
||||
.note{padding:14px 16px;border-left:4px solid var(--blue);background:var(--soft);color:var(--muted)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>车辆数据开放平台</h1>
|
||||
<p>面向合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。接口使用独立的 32 位 appKey 认证。</p>
|
||||
<nav>
|
||||
<a href="/open-api/swagger/">Swagger 在线调试</a>
|
||||
<a href="/open-api/openapi.yaml">下载 OpenAPI 3.0</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<h2>认证</h2>
|
||||
<pre>Authorization: Bearer <32位appKey>
|
||||
Content-Type: application/json</pre>
|
||||
<p class="note">appKey 由平台管理员创建并授权车辆。Key 和逐车授权都必须完整覆盖所查询的自然日。</p>
|
||||
|
||||
<h2>开放接口</h2>
|
||||
<h3><span class="method">POST</span>/api/v1/vehicles/hydrogen-consumption/query</h3>
|
||||
<p>查询指定车辆的单日用氢量,单位 kg。</p>
|
||||
<h3><span class="method">POST</span>/api/v1/vehicles/mileage/query</h3>
|
||||
<p>查询指定车辆的单日行驶里程、累计总里程、实际来源协议、车辆源数据时间和投影更新时间,单位 km。</p>
|
||||
<p class="note">以上两个按日接口的 plateNumbers 可选;省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。</p>
|
||||
<h3><span class="method">POST</span>/api/v1/vehicles/mileage/range/query</h3>
|
||||
<p>按最长 366 天区间分页查询逐车逐日里程。首次请求固化授权车辆清单,后续使用 nextCursor 翻页。</p>
|
||||
<p class="note">两个里程接口均可传 protocolPriority,唯一外部值为 GB32960、MQTT、JT808。逐车逐日按数组顺序选择第一个有效协议;未列出的协议完全禁用。省略字段时保持现有默认选源行为。</p>
|
||||
<p class="note">查询日没有有效里程但此前存在有效累计里程时,日里程补 0,累计总里程、来源协议和数据时间沿用最近有效统计;updatedAt 显示上一个统计周期的计算时间。</p>
|
||||
<h3><span class="method">POST</span>/api/v1/vehicles/total-mileage/query</h3>
|
||||
<p>按 VIN 和北京时间查询不晚于指定时刻的最近一条总里程,返回实际采集协议、记录时间和时间差秒数。</p>
|
||||
|
||||
<h2>总里程协议口径</h2>
|
||||
<table>
|
||||
<thead><tr><th>protocol 唯一规范值</th><th>总里程含义</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>GB32960</td><td>车辆仪表盘累计总里程,对应 GB/T 32960 整车数据累计里程</td></tr>
|
||||
<tr><td>YUTONG_MQTT</td><td>车辆仪表盘或车端控制器累计总里程,由 MQTT 平台上报</td></tr>
|
||||
<tr><td>JT808</td><td>定位终端累计里程,由 GPS/终端侧计算,不等同于车辆仪表盘里程</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="note">protocol 不传时严格按 GB32960 > YUTONG_MQTT > JT808 选择首个有数据的协议。接口取不晚于请求时间的最近记录,不跨协议拼接里程。</p>
|
||||
|
||||
<h2>请求示例</h2>
|
||||
<pre>curl -X POST 'https://your-host/api/v1/vehicles/hydrogen-consumption/query' \
|
||||
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"plateNumbers": ["粤A12345", "粤B67890"],
|
||||
"date": "2026-07-01"
|
||||
}'</pre>
|
||||
|
||||
<h3>查询全部授权车辆的单日数据</h3>
|
||||
<pre>{
|
||||
"date": "2026-07-01"
|
||||
}</pre>
|
||||
|
||||
<h3>按自定义协议优先级查询单日里程</h3>
|
||||
<pre>{
|
||||
"plateNumbers": ["粤A12345"],
|
||||
"date": "2026-07-01",
|
||||
"protocolPriority": ["JT808", "GB32960", "MQTT"]
|
||||
}</pre>
|
||||
|
||||
<h3>指定时刻总里程</h3>
|
||||
<pre>curl -X POST 'https://your-host/api/v1/vehicles/total-mileage/query' \
|
||||
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"vin": "LA9GG68L2PBAF4790",
|
||||
"time": "2026-07-21 09:30:00",
|
||||
"protocol": "GB32960"
|
||||
}'</pre>
|
||||
|
||||
<h3>车辆区间日里程</h3>
|
||||
<pre>curl -X POST 'https://your-host/api/v1/vehicles/mileage/range/query' \
|
||||
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"startDate": "2026-07-01",
|
||||
"endDate": "2026-07-23",
|
||||
"protocolPriority": ["GB32960", "MQTT"],
|
||||
"pageSize": 5000
|
||||
}'</pre>
|
||||
<p class="note">下一页保持原请求参数不变,并传入上一页 nextCursor;同一次分页查询的 snapshotId 保持不变。</p>
|
||||
|
||||
<h2>响应示例</h2>
|
||||
<h3>车辆单日里程</h3>
|
||||
<pre>{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
"vin": "LNB00000000000001",
|
||||
"plateNumber": "粤A12345",
|
||||
"date": "2026-07-01",
|
||||
"dailyMileageKm": 182.437,
|
||||
"totalMileageKm": 12345.679,
|
||||
"dataTime": "2026-07-01T23:58:45+08:00",
|
||||
"updatedAt": "2026-07-02T05:10:00+08:00",
|
||||
"sourceProtocol": "GB32960",
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"traceId": "b7ff5582ab1a4e13bfb4f10943685599"
|
||||
}</pre>
|
||||
<p class="note">里程状态为 NORMAL 时,dailyMileageKm、totalMileageKm、dataTime 与 updatedAt 均有值;NO_DATA 时相关数据字段为 null,真实零里程仍为 NORMAL。</p>
|
||||
<h3>车辆区间日里程响应</h3>
|
||||
<pre>{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
"vin": "LNB00000000000001",
|
||||
"plateNumber": "粤A12345",
|
||||
"date": "2026-07-01",
|
||||
"dailyMileageKm": 182.437,
|
||||
"dataTime": "2026-07-01T23:58:45+08:00",
|
||||
"updatedAt": "2026-07-02T05:10:00+08:00",
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"snapshotId": "9f8a74efbf9846349ae5676f3a5c0de8",
|
||||
"nextCursor": null,
|
||||
"traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
|
||||
}</pre>
|
||||
<h3>车辆单日用氢量</h3>
|
||||
<pre>{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": [{
|
||||
"plateNumber": "粤A12345",
|
||||
"date": "2026-07-01",
|
||||
"hydrogenConsumptionKg": 12.315,
|
||||
"status": "NORMAL"
|
||||
}],
|
||||
"traceId": "4ccf63c4e51d4d4ab9107d931783a53e"
|
||||
}</pre>
|
||||
|
||||
<h3>指定时刻总里程响应</h3>
|
||||
<pre>{
|
||||
"code": "SUCCESS",
|
||||
"message": "success",
|
||||
"data": {
|
||||
"vin": "LA9GG68L2PBAF4790",
|
||||
"queryTime": "2026-07-21 09:30:00",
|
||||
"totalMileageKm": 12345.678,
|
||||
"protocol": "GB32960",
|
||||
"protocolInput": "GB32960",
|
||||
"mileageMeaning": "车辆仪表盘累计总里程(GB/T 32960整车数据累计里程)",
|
||||
"recordTime": "2026-07-21 09:29:45",
|
||||
"timeDifferenceSeconds": 15,
|
||||
"selectionPolicy": "GB32960 > YUTONG_MQTT > JT808",
|
||||
"status": "NORMAL"
|
||||
},
|
||||
"traceId": "95bddca78133474fa2bf56ecdf758e22"
|
||||
}</pre>
|
||||
|
||||
<h2>状态与错误码</h2>
|
||||
<table>
|
||||
<thead><tr><th>HTTP</th><th>code</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>200</td><td>SUCCESS</td><td>查询成功;无统计数据的车辆以 NO_DATA 返回</td></tr>
|
||||
<tr><td>400</td><td>INVALID_REQUEST</td><td>请求格式、车牌或数量不正确</td></tr>
|
||||
<tr><td>400</td><td>INVALID_DATE_FORMAT</td><td>日期不是 yyyy-MM-dd</td></tr>
|
||||
<tr><td>400</td><td>INVALID_DATETIME_FORMAT</td><td>时间不是 yyyy-MM-dd HH:mm:ss</td></tr>
|
||||
<tr><td>401</td><td>UNAUTHORIZED</td><td>appKey 不存在、停用或过期</td></tr>
|
||||
<tr><td>403</td><td>FORBIDDEN</td><td>Key 或车辆授权未覆盖查询自然日</td></tr>
|
||||
<tr><td>500</td><td>INTERNAL_ERROR</td><td>服务内部异常</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,743 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: 车辆数据开放平台 API
|
||||
version: 1.5.0
|
||||
license:
|
||||
name: Proprietary
|
||||
description: |
|
||||
向授权合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。
|
||||
appKey 和逐车授权必须完整覆盖查询自然日。
|
||||
servers:
|
||||
- url: /
|
||||
description: 当前服务
|
||||
tags:
|
||||
- name: 合作方数据接口
|
||||
description: 使用 appKey 查询已授权车辆的日统计数据
|
||||
- name: 开放平台管理
|
||||
description: 仅车辆数据平台管理员可调用的应用和车辆授权管理接口
|
||||
paths:
|
||||
/api/v1/vehicles/hydrogen-consumption/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询车辆单日用氢量
|
||||
description: plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
|
||||
operationId: queryDailyHydrogenConsumption
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VehicleDailyQuery'
|
||||
example:
|
||||
date: '2026-07-01'
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功;无数据车辆仍保留在结果中
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HydrogenQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v1/vehicles/mileage/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询车辆单日里程
|
||||
description: |
|
||||
plateNumbers 省略或传空数组时,返回该应用在查询自然日有效授权的全部车辆。
|
||||
protocolPriority 传入时,逐车按数组顺序选择第一个有效协议,未列出的协议完全禁用且不会兜底;省略时保持平台默认选源行为。
|
||||
NORMAL 结果同时包含日里程、累计总里程、实际来源协议、源数据时间和投影更新时间。
|
||||
当日无有效里程时,日里程补 0,累计总里程、来源协议、dataTime 和 updatedAt 沿用此前最近的有效统计;updatedAt 仍为上一统计周期的计算时间。
|
||||
operationId: queryDailyMileage
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MileageDailyQuery'
|
||||
example:
|
||||
date: '2026-07-01'
|
||||
protocolPriority: [GB32960, MQTT, JT808]
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功;无数据车辆仍保留在结果中
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MileageQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v1/vehicles/mileage/range/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 分页查询车辆区间日里程
|
||||
description: |
|
||||
最长支持 366 天。plateNumbers 省略或传空数组时固化应用在整个查询区间有效授权的全部车辆。
|
||||
首次请求返回 snapshotId 和 nextCursor;后续请求保持原参数并传回 nextCursor。
|
||||
快照仅固化授权车辆清单,逐页读取已建立索引的日统计投影,不扫描原始时序明细。
|
||||
protocolPriority 对区间内每辆车、每个自然日独立生效;未列出的协议完全禁用。
|
||||
某日无有效里程时,dailyMileageKm 补 0,其余里程证据沿用此前最近的有效统计。
|
||||
operationId: queryDailyMileageRange
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MileageRangeQuery'
|
||||
example:
|
||||
startDate: '2026-07-01'
|
||||
endDate: '2026-07-23'
|
||||
plateNumbers: [沪A00001, 沪A00002]
|
||||
protocolPriority: [JT808, GB32960, MQTT]
|
||||
cursor: null
|
||||
pageSize: 5000
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功;每辆授权车辆每天均有一条 NORMAL 或 NO_DATA 记录
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MileageRangeQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v1/vehicles/total-mileage/query:
|
||||
post:
|
||||
tags: [合作方数据接口]
|
||||
summary: 查询指定时刻的车辆总里程
|
||||
description: |
|
||||
返回不晚于请求时间的最近一条有效总里程记录、实际记录时间和时间差秒数。
|
||||
protocol 不传时严格按 GB32960 > YUTONG_MQTT > JT808 选择首个有数据的协议。
|
||||
GB32960 和 YUTONG_MQTT 为车辆仪表盘或车端累计里程;JT808 为定位终端/GPS侧累计里程。
|
||||
operationId: queryTotalMileageAtTime
|
||||
security:
|
||||
- AppKeyAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TotalMileageQuery'
|
||||
example:
|
||||
vin: LA9GG68L2PBAF4790
|
||||
time: '2026-07-21 09:30:00'
|
||||
protocol: GB32960
|
||||
responses:
|
||||
'200':
|
||||
description: 查询成功;没有可用记录时返回 NO_DATA
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TotalMileageQueryResponse'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'500':
|
||||
$ref: '#/components/responses/InternalError'
|
||||
/api/v2/open-platform/apps:
|
||||
get:
|
||||
tags: [开放平台管理]
|
||||
summary: 查询开放平台应用
|
||||
operationId: listOpenPlatformApps
|
||||
security:
|
||||
- AdminBearer: []
|
||||
responses:
|
||||
'200':
|
||||
description: 应用列表
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminAppListResponse'
|
||||
'401':
|
||||
description: 未登录
|
||||
'403':
|
||||
description: 非管理员
|
||||
post:
|
||||
tags: [开放平台管理]
|
||||
summary: 创建应用和 appKey
|
||||
description: appKey 明文仅在本次响应中返回。
|
||||
operationId: createOpenPlatformApp
|
||||
security:
|
||||
- AdminBearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AppInput'
|
||||
responses:
|
||||
'200':
|
||||
description: 创建成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminAppCreatedResponse'
|
||||
'400':
|
||||
description: 参数错误
|
||||
'401':
|
||||
description: 未登录
|
||||
'403':
|
||||
description: 非管理员
|
||||
/api/v2/open-platform/apps/{id}:
|
||||
put:
|
||||
tags: [开放平台管理]
|
||||
summary: 更新应用状态和有效期
|
||||
operationId: updateOpenPlatformApp
|
||||
security:
|
||||
- AdminBearer: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AppId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AppInput'
|
||||
responses:
|
||||
'200':
|
||||
description: 更新成功
|
||||
'400':
|
||||
description: 参数错误
|
||||
'404':
|
||||
description: 应用不存在
|
||||
/api/v2/open-platform/apps/{id}/rotate-key:
|
||||
post:
|
||||
tags: [开放平台管理]
|
||||
summary: 轮换 appKey
|
||||
description: 旧 Key 立即失效,新 Key 明文仅在本次响应中返回。
|
||||
operationId: rotateOpenPlatformAppKey
|
||||
security:
|
||||
- AdminBearer: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AppId'
|
||||
responses:
|
||||
'200':
|
||||
description: 轮换成功
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AdminAppCreatedResponse'
|
||||
'404':
|
||||
description: 应用不存在
|
||||
/api/v2/open-platform/apps/{id}/vehicles:
|
||||
get:
|
||||
tags: [开放平台管理]
|
||||
summary: 查询应用的车辆授权
|
||||
operationId: listOpenPlatformVehicleGrants
|
||||
security:
|
||||
- AdminBearer: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AppId'
|
||||
responses:
|
||||
'200':
|
||||
description: 授权列表
|
||||
'404':
|
||||
description: 应用不存在
|
||||
put:
|
||||
tags: [开放平台管理]
|
||||
summary: 完整替换车辆授权
|
||||
operationId: replaceOpenPlatformVehicleGrants
|
||||
security:
|
||||
- AdminBearer: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/AppId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/VehicleGrantRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: 替换成功
|
||||
'400':
|
||||
description: VIN 或有效期不正确
|
||||
'404':
|
||||
description: 应用不存在
|
||||
components:
|
||||
securitySchemes:
|
||||
AppKeyAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: 32-character appKey
|
||||
description: 32 位无连字符 UUID appKey
|
||||
AdminBearer:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: Platform session token
|
||||
description: 车辆数据平台管理员令牌
|
||||
parameters:
|
||||
AppId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 1
|
||||
responses:
|
||||
BadRequest:
|
||||
description: 请求参数不正确
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
examples:
|
||||
invalidDate:
|
||||
value:
|
||||
code: INVALID_DATE_FORMAT
|
||||
message: date格式必须为yyyy-MM-dd
|
||||
traceId: 4ccf63c4e51d4d4ab9107d931783a53e
|
||||
Unauthorized:
|
||||
description: appKey 不存在、停用或过期
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
Forbidden:
|
||||
description: Key 或车辆授权未完整覆盖查询自然日
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
InternalError:
|
||||
description: 服务内部异常
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
schemas:
|
||||
VehicleDailyQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [date]
|
||||
properties:
|
||||
plateNumbers:
|
||||
type: array
|
||||
maxItems: 200
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: 可选;指定时查询这些已授权车牌,省略或传空数组时查询该自然日有效授权的全部车辆
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
description: 查询自然日,yyyy-MM-dd
|
||||
MileageDailyQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [date]
|
||||
properties:
|
||||
plateNumbers:
|
||||
type: array
|
||||
maxItems: 200
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: 可选;指定时查询这些已授权车牌,省略或传空数组时查询该自然日有效授权的全部车辆
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
description: 查询自然日,yyyy-MM-dd
|
||||
protocolPriority:
|
||||
$ref: '#/components/schemas/ProtocolPriority'
|
||||
MileageRangeQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [startDate, endDate]
|
||||
properties:
|
||||
startDate:
|
||||
type: string
|
||||
format: date
|
||||
description: 区间开始自然日,Asia/Shanghai
|
||||
endDate:
|
||||
type: string
|
||||
format: date
|
||||
description: 区间结束自然日,含当日;与 startDate 最多相隔 365 天
|
||||
plateNumbers:
|
||||
type: array
|
||||
maxItems: 5000
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
description: 可选;省略或传空数组时查询整个区间均有效授权的全部车辆
|
||||
protocolPriority:
|
||||
$ref: '#/components/schemas/ProtocolPriority'
|
||||
cursor:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 首次查询传 null 或省略;翻页时原样传入上一页 nextCursor
|
||||
pageSize:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 5000
|
||||
default: 5000
|
||||
ProtocolPriority:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 3
|
||||
uniqueItems: true
|
||||
items:
|
||||
type: string
|
||||
enum: [GB32960, MQTT, JT808]
|
||||
description: 可选;按数组顺序逐车逐日选择第一个有效协议。未列出的协议被禁用且不会作为兜底;省略字段时保持平台默认选源行为
|
||||
TotalMileageQuery:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [vin, time]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
pattern: '^[A-HJ-NPR-Z0-9]{17}$'
|
||||
description: 已授权车辆 VIN
|
||||
time:
|
||||
type: string
|
||||
pattern: '^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}$'
|
||||
description: 请求时刻,北京时间,固定格式 yyyy-MM-dd HH:mm:ss
|
||||
protocol:
|
||||
type: string
|
||||
enum: [GB32960, YUTONG_MQTT, JT808]
|
||||
description: 可选;只接受平台统一协议标识;不传时按 GB32960 > YUTONG_MQTT > JT808
|
||||
HydrogenResult:
|
||||
type: object
|
||||
required: [plateNumber, date, hydrogenConsumptionKg, status]
|
||||
properties:
|
||||
plateNumber:
|
||||
type: string
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
hydrogenConsumptionKg:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: 单日用氢量,kg;无数据时为 null
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
MileageResult:
|
||||
type: object
|
||||
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
plateNumber:
|
||||
type: string
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
dailyMileageKm:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: 单日里程,km;当日无记录但存在历史累计里程时为 0
|
||||
totalMileageKm:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: 当日所选协议最后有效累计总里程,km;status=NORMAL 时必定有值,NO_DATA 时为 null
|
||||
dataTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 本次统计实际采用的最后一条车辆源数据时间;status=NO_DATA 时为 null
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 本行所依据统计周期的计算时间;前向填充时为上一统计周期计算时间,status=NO_DATA 时为 null
|
||||
sourceProtocol:
|
||||
type: string
|
||||
enum: [GB32960, MQTT, JT808]
|
||||
nullable: true
|
||||
description: 本行实际选中的来源协议;status=NO_DATA 时为 null
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
MileageRangeResult:
|
||||
type: object
|
||||
required: [vin, plateNumber, date, dailyMileageKm, totalMileageKm, dataTime, updatedAt, sourceProtocol, status]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
plateNumber:
|
||||
type: string
|
||||
date:
|
||||
type: string
|
||||
format: date
|
||||
dailyMileageKm:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
minimum: 0
|
||||
description: 当日无记录但存在历史累计里程时为 0
|
||||
totalMileageKm:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
minimum: 0
|
||||
description: 当日所选协议累计总里程;缺日时沿用此前最近有效值
|
||||
dataTime:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: 本行所依据统计周期的计算时间;缺日补齐时为上一统计周期计算时间
|
||||
sourceProtocol:
|
||||
type: string
|
||||
enum: [GB32960, MQTT, JT808]
|
||||
nullable: true
|
||||
description: 本行实际选中的来源协议;status=NO_DATA 时为 null
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
DataStatus:
|
||||
type: string
|
||||
enum: [NORMAL, NO_DATA]
|
||||
HydrogenQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/HydrogenResult'
|
||||
MileageQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MileageResult'
|
||||
MileageRangeQueryResponse:
|
||||
type: object
|
||||
required: [code, message, data, snapshotId, nextCursor, traceId]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum: [SUCCESS]
|
||||
message:
|
||||
type: string
|
||||
example: success
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/MileageRangeResult'
|
||||
snapshotId:
|
||||
type: string
|
||||
description: 同一次分页查询保持不变的授权车辆快照标识
|
||||
nextCursor:
|
||||
type: string
|
||||
nullable: true
|
||||
description: 下一页游标;最后一页为 null
|
||||
traceId:
|
||||
type: string
|
||||
TotalMileageResult:
|
||||
type: object
|
||||
required: [vin, queryTime, totalMileageKm, selectionPolicy, status]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
queryTime:
|
||||
type: string
|
||||
description: 请求时刻,北京时间
|
||||
totalMileageKm:
|
||||
type: number
|
||||
format: double
|
||||
nullable: true
|
||||
description: 总里程,km;无数据时为 null
|
||||
protocol:
|
||||
type: string
|
||||
enum: [GB32960, YUTONG_MQTT, JT808]
|
||||
description: 实际采用的采集协议
|
||||
protocolInput:
|
||||
type: string
|
||||
description: 请求中指定的协议原值;未指定时省略
|
||||
mileageMeaning:
|
||||
type: string
|
||||
description: 当前协议总里程的业务含义
|
||||
recordTime:
|
||||
type: string
|
||||
description: 命中的实际采集记录时间,北京时间
|
||||
timeDifferenceSeconds:
|
||||
type: integer
|
||||
format: int64
|
||||
minimum: 0
|
||||
description: 请求时间减实际记录时间,单位秒
|
||||
selectionPolicy:
|
||||
type: string
|
||||
example: GB32960 > YUTONG_MQTT > JT808
|
||||
status:
|
||||
$ref: '#/components/schemas/DataStatus'
|
||||
TotalMileageQueryResponse:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||
- type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TotalMileageResult'
|
||||
SuccessEnvelope:
|
||||
type: object
|
||||
required: [code, message, traceId]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum: [SUCCESS]
|
||||
message:
|
||||
type: string
|
||||
example: success
|
||||
traceId:
|
||||
type: string
|
||||
example: 4ccf63c4e51d4d4ab9107d931783a53e
|
||||
ErrorResponse:
|
||||
type: object
|
||||
required: [code, message, traceId]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum:
|
||||
- INVALID_REQUEST
|
||||
- INVALID_DATE_FORMAT
|
||||
- UNAUTHORIZED
|
||||
- FORBIDDEN
|
||||
- INTERNAL_ERROR
|
||||
message:
|
||||
type: string
|
||||
traceId:
|
||||
type: string
|
||||
AppInput:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [name, status, validFrom]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
maxLength: 96
|
||||
example: 示例合作方
|
||||
status:
|
||||
type: string
|
||||
enum: [enabled, disabled]
|
||||
validFrom:
|
||||
type: string
|
||||
format: date-time
|
||||
example: '2026-07-01T00:00:00+08:00'
|
||||
validTo:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
example: '2027-07-01T00:00:00+08:00'
|
||||
App:
|
||||
type: object
|
||||
required: [id, name, appKeyPrefix, status, validFrom]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
format: int64
|
||||
name:
|
||||
type: string
|
||||
appKeyPrefix:
|
||||
type: string
|
||||
minLength: 8
|
||||
maxLength: 8
|
||||
status:
|
||||
type: string
|
||||
enum: [enabled, disabled]
|
||||
validFrom:
|
||||
type: string
|
||||
format: date-time
|
||||
validTo:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
AppCreated:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/App'
|
||||
- type: object
|
||||
required: [appKey]
|
||||
properties:
|
||||
appKey:
|
||||
type: string
|
||||
minLength: 32
|
||||
maxLength: 32
|
||||
pattern: '^[0-9a-f]{32}$'
|
||||
description: 仅本次响应返回
|
||||
VehicleGrantRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [vehicles]
|
||||
properties:
|
||||
vehicles:
|
||||
type: array
|
||||
maxItems: 500
|
||||
items:
|
||||
$ref: '#/components/schemas/VehicleGrantInput'
|
||||
VehicleGrantInput:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [vin, validFrom]
|
||||
properties:
|
||||
vin:
|
||||
type: string
|
||||
minLength: 17
|
||||
maxLength: 17
|
||||
validFrom:
|
||||
type: string
|
||||
format: date-time
|
||||
validTo:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
AdminAppListResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/App'
|
||||
traceId:
|
||||
type: string
|
||||
AdminAppCreatedResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AppCreated'
|
||||
traceId:
|
||||
type: string
|
||||
@@ -0,0 +1,17 @@
|
||||
window.addEventListener("load", function () {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/open-api/openapi.yaml",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
displayRequestDuration: true,
|
||||
filter: true,
|
||||
persistAuthorization: false,
|
||||
tryItOutEnabled: false,
|
||||
validatorUrl: null,
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "StandaloneLayout"
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>车辆数据开放平台 Swagger</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-bundle.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.17.14/swagger-ui-standalone-preset.js"></script>
|
||||
<script src="/open-api/swagger-init.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
83
vehicle-data-platform/apps/api/internal/openplatform/docs.go
Normal file
83
vehicle-data-platform/apps/api/internal/openplatform/docs.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
OpenAPISpecPath = "/open-api/openapi.yaml"
|
||||
SwaggerUIPath = "/open-api/swagger/"
|
||||
SimpleDocsPath = "/open-api/docs/"
|
||||
swaggerInitJSPath = "/open-api/swagger-init.js"
|
||||
)
|
||||
|
||||
//go:embed assets/openapi.yaml
|
||||
var openAPISpec []byte
|
||||
|
||||
//go:embed assets/swagger.html
|
||||
var swaggerHTML []byte
|
||||
|
||||
//go:embed assets/swagger-init.js
|
||||
var swaggerInitJS []byte
|
||||
|
||||
//go:embed assets/docs.html
|
||||
var simpleDocsHTML []byte
|
||||
|
||||
// WithDocs exposes version-matched API documentation without requiring a
|
||||
// production database connection or an authenticated platform session.
|
||||
func WithDocs(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/open-api":
|
||||
redirectDocs(w, r, SimpleDocsPath)
|
||||
case "/open-api/swagger":
|
||||
redirectDocs(w, r, SwaggerUIPath)
|
||||
case "/open-api/docs":
|
||||
redirectDocs(w, r, SimpleDocsPath)
|
||||
case OpenAPISpecPath:
|
||||
serveDocsAsset(w, r, "application/yaml; charset=utf-8", openAPISpec, "")
|
||||
case SwaggerUIPath:
|
||||
serveDocsAsset(w, r, "text/html; charset=utf-8", swaggerHTML,
|
||||
"default-src 'none'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; connect-src 'self'; font-src https://cdn.jsdelivr.net; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
|
||||
case swaggerInitJSPath:
|
||||
serveDocsAsset(w, r, "application/javascript; charset=utf-8", swaggerInitJS, "")
|
||||
case SimpleDocsPath:
|
||||
serveDocsAsset(w, r, "text/html; charset=utf-8", simpleDocsHTML,
|
||||
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'")
|
||||
default:
|
||||
next.ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func redirectDocs(w http.ResponseWriter, r *http.Request, target string) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||
}
|
||||
|
||||
func serveDocsAsset(w http.ResponseWriter, r *http.Request, contentType string, body []byte, csp string) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
if strings.TrimSpace(csp) != "" {
|
||||
w.Header().Set("Content-Security-Policy", csp)
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDocsRoutesServeOpenAPIAndBothDocumentationViews(t *testing.T) {
|
||||
handler := WithDocs(http.NotFoundHandler())
|
||||
tests := []struct {
|
||||
path string
|
||||
contentType string
|
||||
want string
|
||||
}{
|
||||
{OpenAPISpecPath, "application/yaml", "openapi: 3.0.3"},
|
||||
{SwaggerUIPath, "text/html", "swagger-ui-bundle.js"},
|
||||
{SimpleDocsPath, "text/html", "车辆数据开放平台"},
|
||||
{swaggerInitJSPath, "application/javascript", `persistAuthorization: false`},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, test.path, nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if !strings.HasPrefix(recorder.Header().Get("Content-Type"), test.contentType) {
|
||||
t.Fatalf("content-type=%q", recorder.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), test.want) {
|
||||
t.Fatalf("body does not contain %q", test.want)
|
||||
}
|
||||
if recorder.Header().Get("X-Content-Type-Options") != "nosniff" {
|
||||
t.Fatal("documentation asset must set nosniff")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsRedirectsAndRejectsMutatingMethods(t *testing.T) {
|
||||
handler := WithDocs(http.NotFoundHandler())
|
||||
redirect := httptest.NewRecorder()
|
||||
handler.ServeHTTP(redirect, httptest.NewRequest(http.MethodGet, "/open-api/docs", nil))
|
||||
if redirect.Code != http.StatusPermanentRedirect || redirect.Header().Get("Location") != SimpleDocsPath {
|
||||
t.Fatalf("status=%d location=%q", redirect.Code, redirect.Header().Get("Location"))
|
||||
}
|
||||
|
||||
post := httptest.NewRecorder()
|
||||
handler.ServeHTTP(post, httptest.NewRequest(http.MethodPost, OpenAPISpecPath, nil))
|
||||
if post.Code != http.StatusMethodNotAllowed || post.Header().Get("Allow") != "GET, HEAD" {
|
||||
t.Fatalf("status=%d allow=%q", post.Code, post.Header().Get("Allow"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAPISpecCoversPublicAndManagementEndpoints(t *testing.T) {
|
||||
spec := string(openAPISpec)
|
||||
for _, want := range []string{
|
||||
"openapi: 3.0.3",
|
||||
HydrogenQueryPath + ":",
|
||||
MileageQueryPath + ":",
|
||||
MileageRangeQueryPath + ":",
|
||||
"/api/v2/open-platform/apps:",
|
||||
"AppKeyAuth:",
|
||||
"AdminBearer:",
|
||||
"省略或传空数组时",
|
||||
"protocolPriority:",
|
||||
"sourceProtocol:",
|
||||
"enum: [GB32960, MQTT, JT808]",
|
||||
} {
|
||||
if !strings.Contains(spec, want) {
|
||||
t.Fatalf("OpenAPI spec missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
729
vehicle-data-platform/apps/api/internal/openplatform/handler.go
Normal file
729
vehicle-data-platform/apps/api/internal/openplatform/handler.go
Normal file
@@ -0,0 +1,729 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/httpx"
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/platform"
|
||||
)
|
||||
|
||||
const (
|
||||
HydrogenQueryPath = "/api/v1/vehicles/hydrogen-consumption/query"
|
||||
MileageQueryPath = "/api/v1/vehicles/mileage/query"
|
||||
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
|
||||
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
portal *PortalService
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
handler := &Handler{service: service, mux: http.NewServeMux()}
|
||||
handler.registerExternalDataRoutes()
|
||||
handler.registerAdminAppRoutes()
|
||||
return handler
|
||||
}
|
||||
|
||||
func NewAdminHandler(service *Service, portal *PortalService) *Handler {
|
||||
handler := &Handler{service: service, portal: portal, mux: http.NewServeMux()}
|
||||
handler.registerAdminAppRoutes()
|
||||
if portal != nil {
|
||||
handler.registerAdminUserRoutes()
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
func NewExternalHandler(service *Service, portal *PortalService) *Handler {
|
||||
handler := &Handler{service: service, portal: portal, mux: http.NewServeMux()}
|
||||
handler.registerExternalDataRoutes()
|
||||
if portal != nil {
|
||||
handler.registerPortalRoutes()
|
||||
}
|
||||
return handler
|
||||
}
|
||||
|
||||
func (h *Handler) registerExternalDataRoutes() {
|
||||
h.mux.HandleFunc("POST "+HydrogenQueryPath, h.hydrogen)
|
||||
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
||||
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
|
||||
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
|
||||
}
|
||||
|
||||
func (h *Handler) registerAdminAppRoutes() {
|
||||
h.mux.HandleFunc("GET /api/v2/open-platform/apps", h.listApps)
|
||||
h.mux.HandleFunc("POST /api/v2/open-platform/apps", h.createApp)
|
||||
h.mux.HandleFunc("PUT /api/v2/open-platform/apps/{id}", h.updateApp)
|
||||
h.mux.HandleFunc("POST /api/v2/open-platform/apps/{id}/rotate-key", h.rotateKey)
|
||||
h.mux.HandleFunc("GET /api/v2/open-platform/apps/{id}/vehicles", h.listVehicleGrants)
|
||||
h.mux.HandleFunc("PUT /api/v2/open-platform/apps/{id}/vehicles", h.replaceVehicleGrants)
|
||||
}
|
||||
|
||||
func (h *Handler) registerAdminUserRoutes() {
|
||||
h.mux.HandleFunc("GET /api/v2/open-platform/users", h.listPortalUsers)
|
||||
h.mux.HandleFunc("POST /api/v2/open-platform/users", h.createPortalUser)
|
||||
h.mux.HandleFunc("PUT /api/v2/open-platform/users/{id}", h.updatePortalUser)
|
||||
h.mux.HandleFunc("GET /api/v2/open-platform/users/{id}/apps", h.listPortalUserApps)
|
||||
h.mux.HandleFunc("PUT /api/v2/open-platform/users/{id}/apps", h.replacePortalUserApps)
|
||||
}
|
||||
|
||||
func (h *Handler) registerPortalRoutes() {
|
||||
h.mux.HandleFunc("POST /portal-api/auth/login", h.portalLogin)
|
||||
h.mux.HandleFunc("POST /portal-api/auth/logout", h.portalLogout)
|
||||
h.mux.HandleFunc("GET /portal-api/session", h.portalSession)
|
||||
h.mux.HandleFunc("GET /portal-api/catalog", h.portalCatalog)
|
||||
h.mux.HandleFunc("GET /portal-api/apps", h.portalApps)
|
||||
h.mux.HandleFunc("GET /portal-api/apps/{id}/vehicles", h.portalVehicles)
|
||||
h.mux.HandleFunc("GET /portal-api/apps/{id}/audit", h.portalAudit)
|
||||
h.mux.HandleFunc("POST /portal-api/apps/{id}/rotate-key", h.portalRotateKey)
|
||||
h.mux.HandleFunc("PUT /portal-api/account/password", h.portalChangePassword)
|
||||
h.mux.HandleFunc("GET /portal-api/admin/apps", h.portalAdminListApps)
|
||||
h.mux.HandleFunc("POST /portal-api/admin/apps", h.portalAdminCreateApp)
|
||||
h.mux.HandleFunc("PUT /portal-api/admin/apps/{id}", h.portalAdminUpdateApp)
|
||||
h.mux.HandleFunc("PUT /portal-api/admin/apps/{id}/vehicles", h.portalAdminReplaceVehicles)
|
||||
h.mux.HandleFunc("GET /portal-api/admin/vehicles", h.portalAdminVehicleCatalog)
|
||||
h.mux.HandleFunc("GET /portal-api/admin/users", h.portalAdminListUsers)
|
||||
h.mux.HandleFunc("POST /portal-api/admin/users", h.portalAdminCreateUser)
|
||||
h.mux.HandleFunc("PUT /portal-api/admin/users/{id}", h.portalAdminUpdateUser)
|
||||
h.mux.HandleFunc("GET /portal-api/admin/users/{id}/apps", h.portalAdminListUserApps)
|
||||
h.mux.HandleFunc("PUT /portal-api/admin/users/{id}/apps", h.portalAdminReplaceUserApps)
|
||||
}
|
||||
|
||||
func NewDataHandler(service *Service) *Handler {
|
||||
handler := &Handler{service: service, mux: http.NewServeMux()}
|
||||
handler.mux.HandleFunc("POST "+HydrogenQueryPath, handler.hydrogen)
|
||||
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
||||
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
|
||||
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
|
||||
return handler
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func IsPublicPath(path string) bool {
|
||||
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath
|
||||
}
|
||||
|
||||
func (h *Handler) hydrogen(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request QueryRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryHydrogen(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
writeExternalError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
|
||||
}
|
||||
|
||||
func (h *Handler) mileage(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request QueryRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryMileage(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
writeExternalError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
|
||||
}
|
||||
|
||||
func (h *Handler) mileageRange(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request MileageRangeRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
response, err := h.service.QueryMileageRange(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
writeExternalError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *Handler) totalMileage(w http.ResponseWriter, r *http.Request) {
|
||||
traceID := externalTraceID(r)
|
||||
var request TotalMileageQueryRequest
|
||||
if !decodeExternalBody(w, r, traceID, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.QueryTotalMileage(r.Context(), externalBearer(r), traceID, request)
|
||||
if err != nil {
|
||||
writeExternalError(w, traceID, err)
|
||||
return
|
||||
}
|
||||
writeExternal(w, http.StatusOK, ExternalResponse{Code: "SUCCESS", Message: "success", Data: data, TraceID: traceID})
|
||||
}
|
||||
|
||||
func (h *Handler) listApps(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.service.ListApps(r.Context())
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) createApp(w http.ResponseWriter, r *http.Request) {
|
||||
var input AppInput
|
||||
if !decodeAdminBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.CreateApp(r.Context(), input, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) updateApp(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input AppInput
|
||||
if !decodeAdminBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.UpdateApp(r.Context(), id, input, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) rotateKey(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.RotateKey(r.Context(), id, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) listVehicleGrants(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.ListVehicleGrants(r.Context(), id)
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) replaceVehicleGrants(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request VehicleGrantRequest
|
||||
if !decodeAdminBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.ReplaceVehicleGrants(r.Context(), id, request, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) listPortalUsers(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := h.portal.ListUsers(r.Context())
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) createPortalUser(w http.ResponseWriter, r *http.Request) {
|
||||
var input PortalUserInput
|
||||
if !decodeAdminBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.CreateUser(r.Context(), input, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) updatePortalUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input PortalUserInput
|
||||
if !decodeAdminBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.UpdateUser(r.Context(), id, input, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) listPortalUserApps(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.ListUserApps(r.Context(), id)
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) replacePortalUserApps(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := parseID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request PortalUserAppRequest
|
||||
if !decodeAdminBody(w, r, &request) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.ReplaceUserApps(r.Context(), id, request, platform.ActorFromContext(r.Context()))
|
||||
h.writeAdmin(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var input PortalLoginRequest
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.Login(r.Context(), input, requestRemoteAddress(r), r.UserAgent())
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalLogout(w http.ResponseWriter, r *http.Request) {
|
||||
h.portal.Logout(r.Context(), externalBearer(r))
|
||||
h.writePortal(w, r, map[string]bool{"loggedOut": true}, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) portalSession(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
h.writePortal(w, r, session, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
h.writePortal(w, r, dataProducts(), nil)
|
||||
}
|
||||
|
||||
func (h *Handler) portalApps(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
data, err := h.portal.Apps(r.Context(), session)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
appID, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
data, err := h.portal.Vehicles(r.Context(), session, appID)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAudit(w http.ResponseWriter, r *http.Request) {
|
||||
appID, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
data, err := h.portal.Audit(r.Context(), session, appID)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalRotateKey(w http.ResponseWriter, r *http.Request) {
|
||||
appID, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err == nil {
|
||||
err = h.portal.CanRotateKey(r.Context(), session, appID)
|
||||
}
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
data, err := h.service.RotateKey(r.Context(), appID, "portal:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
err = h.portal.ChangePassword(r.Context(), session, input.CurrentPassword, input.NewPassword)
|
||||
h.writePortal(w, r, map[string]bool{"changed": err == nil}, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminListApps(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePortalAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.service.ListApps(r.Context())
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminCreateApp(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input AppInput
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.CreateApp(r.Context(), input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminUpdateApp(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input AppInput
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.UpdateApp(r.Context(), id, input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminReplaceVehicles(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input VehicleGrantRequest
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.service.ReplaceVehicleGrants(r.Context(), id, input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminVehicleCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePortalAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.VehicleCatalog(r.Context())
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePortalAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.ListUsers(r.Context())
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input PortalUserInput
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.CreateUser(r.Context(), input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input PortalUserInput
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.UpdateUser(r.Context(), id, input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminListUserApps(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := h.requirePortalAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.ListUserApps(r.Context(), id)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) portalAdminReplaceUserApps(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := h.requirePortalAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, ok := parsePortalID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input PortalUserAppRequest
|
||||
if !decodePortalBody(w, r, &input) {
|
||||
return
|
||||
}
|
||||
data, err := h.portal.ReplaceUserApps(r.Context(), id, input, "portal-admin:"+session.Username)
|
||||
h.writePortal(w, r, data, err)
|
||||
}
|
||||
|
||||
func (h *Handler) requirePortalAdmin(w http.ResponseWriter, r *http.Request) (PortalSession, bool) {
|
||||
session, err := h.portal.Authenticate(r.Context(), externalBearer(r))
|
||||
if err == nil && session.UserType != "admin" {
|
||||
err = ErrForbidden
|
||||
}
|
||||
if err != nil {
|
||||
h.writePortal(w, r, nil, err)
|
||||
return PortalSession{}, false
|
||||
}
|
||||
return session, true
|
||||
}
|
||||
|
||||
func (h *Handler) writeAdmin(w http.ResponseWriter, r *http.Request, data any, err error) {
|
||||
traceID := externalTraceID(r)
|
||||
switch {
|
||||
case err == nil:
|
||||
httpx.WriteOK(w, traceID, data)
|
||||
case errors.Is(err, ErrInvalidRequest):
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), traceID)
|
||||
case errors.Is(err, ErrNotFound):
|
||||
httpx.WriteError(w, http.StatusNotFound, "NOT_FOUND", "开放平台应用不存在", "", traceID)
|
||||
default:
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "开放平台操作失败", err.Error(), traceID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) writePortal(w http.ResponseWriter, r *http.Request, data any, err error) {
|
||||
traceID := externalTraceID(r)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
switch {
|
||||
case err == nil:
|
||||
httpx.WriteOK(w, traceID, data)
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-open-platform-portal"`)
|
||||
httpx.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "登录状态无效或已过期", "", traceID)
|
||||
case errors.Is(err, ErrForbidden):
|
||||
httpx.WriteError(w, http.StatusForbidden, "FORBIDDEN", "当前账号无此操作权限", "", traceID)
|
||||
case errors.Is(err, ErrInvalidRequest):
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), traceID)
|
||||
case errors.Is(err, ErrNotFound):
|
||||
httpx.WriteError(w, http.StatusNotFound, "NOT_FOUND", "资源不存在", "", traceID)
|
||||
default:
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "开放平台操作失败", "", traceID)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeExternalBody(w http.ResponseWriter, r *http.Request, traceID string, output any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(output); err != nil {
|
||||
message := "请求参数不正确"
|
||||
if strings.Contains(err.Error(), "protocolPriority") {
|
||||
message = "protocolPriority必须是非空字符串数组"
|
||||
}
|
||||
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: message, TraceID: traceID})
|
||||
return false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
writeExternal(w, http.StatusBadRequest, ExternalResponse{Code: "INVALID_REQUEST", Message: "请求体只能包含一个JSON对象", TraceID: traceID})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodeAdminBody(w http.ResponseWriter, r *http.Request, output any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(output); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", err.Error(), externalTraceID(r))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func decodePortalBody(w http.ResponseWriter, r *http.Request, output any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(output); err != nil {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确", portalDecodeDetail(err), externalTraceID(r))
|
||||
return false
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "请求体只能包含一个JSON对象", "", externalTraceID(r))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func portalDecodeDetail(err error) string {
|
||||
var syntaxError *json.SyntaxError
|
||||
var typeError *json.UnmarshalTypeError
|
||||
switch {
|
||||
case errors.As(err, &syntaxError):
|
||||
return "请求内容不是有效的 JSON"
|
||||
case errors.As(err, &typeError):
|
||||
if typeError.Field != "" {
|
||||
return fmt.Sprintf("字段 %s 的数据类型不正确", typeError.Field)
|
||||
}
|
||||
return "请求字段的数据类型不正确"
|
||||
case errors.Is(err, io.EOF):
|
||||
return "请求内容不能为空"
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
return "请求包含未支持的字段 " + strings.TrimPrefix(err.Error(), "json: unknown field ")
|
||||
default:
|
||||
return "请求内容无法解析"
|
||||
}
|
||||
}
|
||||
|
||||
func writeExternalError(w http.ResponseWriter, traceID string, err error) {
|
||||
response := ExternalResponse{TraceID: traceID}
|
||||
status := http.StatusInternalServerError
|
||||
switch {
|
||||
case errors.Is(err, ErrUnauthorized):
|
||||
status, response.Code, response.Message = http.StatusUnauthorized, "UNAUTHORIZED", "身份认证失败"
|
||||
case errors.Is(err, ErrForbidden):
|
||||
status, response.Code, response.Message = http.StatusForbidden, "FORBIDDEN", "无数据访问权限"
|
||||
case errors.Is(err, ErrInvalidRequest):
|
||||
status, response.Code, response.Message = http.StatusBadRequest, "INVALID_REQUEST", "请求参数不正确"
|
||||
if strings.Contains(err.Error(), "invalid datetime") {
|
||||
response.Code, response.Message = "INVALID_DATETIME_FORMAT", "time格式必须为yyyy-MM-dd HH:mm:ss"
|
||||
} else if strings.Contains(err.Error(), "invalid date") {
|
||||
response.Code, response.Message = "INVALID_DATE_FORMAT", "date格式必须为yyyy-MM-dd"
|
||||
} else if strings.Contains(err.Error(), "protocolPriority must not be empty") {
|
||||
response.Message = "protocolPriority不能为空"
|
||||
} else if strings.Contains(err.Error(), "protocolPriority contains duplicate protocol") {
|
||||
response.Message = "protocolPriority不能包含重复协议"
|
||||
} else if strings.Contains(err.Error(), "protocolPriority contains unsupported protocol") {
|
||||
response.Message = "protocolPriority仅允许GB32960、MQTT、JT808"
|
||||
}
|
||||
default:
|
||||
response.Code, response.Message = "INTERNAL_ERROR", "服务内部异常"
|
||||
}
|
||||
if status == http.StatusUnauthorized {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="lingniu-vehicle-open-platform"`)
|
||||
}
|
||||
writeExternal(w, status, response)
|
||||
}
|
||||
|
||||
func writeExternal(w http.ResponseWriter, status int, response any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func externalBearer(r *http.Request) string {
|
||||
header := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||
if len(header) < 8 || !strings.EqualFold(header[:7], "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(header[7:])
|
||||
}
|
||||
|
||||
func externalTraceID(r *http.Request) string {
|
||||
if traceID := strings.TrimSpace(r.Header.Get("X-Trace-Id")); traceID != "" && len(traceID) <= 64 {
|
||||
return traceID
|
||||
}
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err == nil {
|
||||
return hex.EncodeToString(value[:])
|
||||
}
|
||||
return strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
}
|
||||
|
||||
func parseID(w http.ResponseWriter, r *http.Request) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "应用ID无效", "", externalTraceID(r))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parsePortalID(w http.ResponseWriter, r *http.Request) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "INVALID_REQUEST", "应用ID无效", "", externalTraceID(r))
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func requestRemoteAddress(r *http.Request) string {
|
||||
for _, header := range []string{"X-Forwarded-For", "X-Real-Ip"} {
|
||||
if value := strings.TrimSpace(strings.Split(r.Header.Get(header), ",")[0]); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func dataProducts() []DataProduct {
|
||||
return []DataProduct{
|
||||
{
|
||||
Code: "daily_hydrogen", Name: "单日用氢量",
|
||||
Description: "按车牌和自然日查询授权车辆的氢气消耗量。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: HydrogenQueryPath, Unit: "kg",
|
||||
},
|
||||
{
|
||||
Code: "daily_mileage", Name: "单日里程",
|
||||
Description: "按车牌和自然日查询日里程、累计里程、实际来源协议与数据时间,支持自定义协议优先级。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: MileageQueryPath, Unit: "km",
|
||||
},
|
||||
{
|
||||
Code: "mileage_range", Name: "区间日里程",
|
||||
Description: "按最长366天区间分页查询逐日里程,支持逐车逐日自定义协议优先级。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: MileageRangeQueryPath, Unit: "km",
|
||||
},
|
||||
{
|
||||
Code: "total_mileage_at_time", Name: "指定时刻总里程",
|
||||
Description: "按VIN和北京时间查询最近一条总里程、采集协议及记录时间差。",
|
||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||
Path: TotalMileageQueryPath, Unit: "km",
|
||||
},
|
||||
}
|
||||
}
|
||||
330
vehicle-data-platform/apps/api/internal/openplatform/model.go
Normal file
330
vehicle-data-platform/apps/api/internal/openplatform/model.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusNormal = "NORMAL"
|
||||
StatusNoData = "NO_DATA"
|
||||
)
|
||||
|
||||
type QueryRequest struct {
|
||||
PlateNumbers []string `json:"plateNumbers"`
|
||||
Date string `json:"date"`
|
||||
ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"`
|
||||
}
|
||||
|
||||
type MileageRangeRequest struct {
|
||||
StartDate string `json:"startDate"`
|
||||
EndDate string `json:"endDate"`
|
||||
PlateNumbers []string `json:"plateNumbers"`
|
||||
ProtocolPriority ProtocolPriority `json:"protocolPriority,omitempty"`
|
||||
Cursor string `json:"cursor"`
|
||||
PageSize int `json:"pageSize"`
|
||||
}
|
||||
|
||||
type ProtocolPriority struct {
|
||||
Values []string
|
||||
Present bool
|
||||
}
|
||||
|
||||
func (p *ProtocolPriority) UnmarshalJSON(data []byte) error {
|
||||
p.Present = true
|
||||
if string(data) == "null" {
|
||||
p.Values = []string{}
|
||||
return nil
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal(data, &values); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Values = values
|
||||
return nil
|
||||
}
|
||||
|
||||
type HydrogenResult struct {
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Date string `json:"date"`
|
||||
HydrogenConsumptionKg *float64 `json:"hydrogenConsumptionKg"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type MileageResult struct {
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Date string `json:"date"`
|
||||
DailyMileageKm *float64 `json:"dailyMileageKm"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
DataTime *string `json:"dataTime"`
|
||||
UpdatedAt *string `json:"updatedAt"`
|
||||
SourceProtocol *string `json:"sourceProtocol"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type MileageRangeResult struct {
|
||||
VIN string `json:"vin"`
|
||||
PlateNumber string `json:"plateNumber"`
|
||||
Date string `json:"date"`
|
||||
DailyMileageKm *float64 `json:"dailyMileageKm"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
DataTime *string `json:"dataTime"`
|
||||
UpdatedAt *string `json:"updatedAt"`
|
||||
SourceProtocol *string `json:"sourceProtocol"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type TotalMileageQueryRequest struct {
|
||||
VIN string `json:"vin"`
|
||||
Time string `json:"time"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
type TotalMileageResult struct {
|
||||
VIN string `json:"vin"`
|
||||
QueryTime string `json:"queryTime"`
|
||||
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
ProtocolInput string `json:"protocolInput,omitempty"`
|
||||
MileageMeaning string `json:"mileageMeaning,omitempty"`
|
||||
RecordTime string `json:"recordTime,omitempty"`
|
||||
TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"`
|
||||
SelectionPolicy string `json:"selectionPolicy"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type TotalMileagePoint struct {
|
||||
VIN string
|
||||
Protocol string
|
||||
ObservedAt time.Time
|
||||
TotalMileageKm float64
|
||||
}
|
||||
|
||||
type ExternalResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data,omitempty"`
|
||||
TraceID string `json:"traceId"`
|
||||
}
|
||||
|
||||
type MileageRangeResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data []MileageRangeResult `json:"data"`
|
||||
SnapshotID string `json:"snapshotId"`
|
||||
NextCursor *string `json:"nextCursor"`
|
||||
TraceID string `json:"traceId"`
|
||||
}
|
||||
|
||||
type App struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AppKeyPrefix string `json:"appKeyPrefix"`
|
||||
Status string `json:"status"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo *time.Time `json:"validTo,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type AppCreated struct {
|
||||
App
|
||||
AppKey string `json:"appKey"`
|
||||
}
|
||||
|
||||
type AppInput struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
ValidFrom string `json:"validFrom"`
|
||||
ValidTo string `json:"validTo"`
|
||||
}
|
||||
|
||||
type VehicleGrantInput struct {
|
||||
VIN string `json:"vin"`
|
||||
ValidFrom string `json:"validFrom"`
|
||||
ValidTo string `json:"validTo"`
|
||||
}
|
||||
|
||||
type VehicleGrantRequest struct {
|
||||
Vehicles []VehicleGrantInput `json:"vehicles"`
|
||||
}
|
||||
|
||||
type VehicleGrant struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo *time.Time `json:"validTo,omitempty"`
|
||||
GrantedBy string `json:"grantedBy"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type VehicleCatalogItem struct {
|
||||
VIN string `json:"vin"`
|
||||
Plate string `json:"plate"`
|
||||
OEM string `json:"oem"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type AuthorizedVehicle struct {
|
||||
VIN string
|
||||
Plate string
|
||||
}
|
||||
|
||||
type AppCredential struct {
|
||||
ID uint64
|
||||
Name string
|
||||
}
|
||||
|
||||
type DailyHydrogen struct {
|
||||
VIN string
|
||||
Date string
|
||||
ConsumptionKg float64
|
||||
SampleCount int
|
||||
QualityStatus string
|
||||
}
|
||||
|
||||
type DailyMileage struct {
|
||||
VIN string
|
||||
Date string
|
||||
Protocol string
|
||||
MileageKm float64
|
||||
TotalMileageKm float64
|
||||
DataTime string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type MileageSnapshot struct {
|
||||
ID string
|
||||
AppID uint64
|
||||
RequestHash []byte
|
||||
StartDate string
|
||||
EndDate string
|
||||
VehicleCount int
|
||||
ExpiresAt time.Time
|
||||
Vehicles []AuthorizedVehicle
|
||||
}
|
||||
|
||||
type HydrogenObservation struct {
|
||||
VIN string
|
||||
Source string
|
||||
ObservedAt time.Time
|
||||
MassKg float64
|
||||
TankCapacityLiter float64
|
||||
PressureMPa float64
|
||||
TemperatureC float64
|
||||
NoiseKg float64
|
||||
RefuelThresholdKg float64
|
||||
}
|
||||
|
||||
type HydrogenRateObservation struct {
|
||||
VIN string
|
||||
Source string
|
||||
ObservedAt time.Time
|
||||
Rate float64
|
||||
MileageKm float64
|
||||
}
|
||||
|
||||
type HydrogenRateDailyStat struct {
|
||||
VIN string
|
||||
Source string
|
||||
Date string
|
||||
ConsumptionKg float64
|
||||
SampleCount int
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
}
|
||||
|
||||
type HydrogenDailyStat struct {
|
||||
VIN string
|
||||
Source string
|
||||
Date string
|
||||
ConsumptionKg float64
|
||||
FirstMassKg float64
|
||||
LastMassKg float64
|
||||
SampleCount int
|
||||
RefuelCount int
|
||||
QualityStatus string
|
||||
QualityReason string
|
||||
}
|
||||
|
||||
type PortalUser struct {
|
||||
ID uint64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Status string `json:"status"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo *time.Time `json:"validTo,omitempty"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type PortalUserInput struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status"`
|
||||
ValidFrom string `json:"validFrom"`
|
||||
ValidTo string `json:"validTo"`
|
||||
}
|
||||
|
||||
type PortalUserAppInput struct {
|
||||
AppID uint64 `json:"appId"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type PortalUserAppRequest struct {
|
||||
Apps []PortalUserAppInput `json:"apps"`
|
||||
}
|
||||
|
||||
type PortalUserApp struct {
|
||||
AppID uint64 `json:"appId"`
|
||||
AppName string `json:"appName"`
|
||||
AppKeyPrefix string `json:"appKeyPrefix"`
|
||||
AppStatus string `json:"appStatus"`
|
||||
Role string `json:"role"`
|
||||
ValidFrom time.Time `json:"validFrom"`
|
||||
ValidTo *time.Time `json:"validTo,omitempty"`
|
||||
}
|
||||
|
||||
type PortalSession struct {
|
||||
UserID uint64 `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
UserType string `json:"userType"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type PortalLoginResponse struct {
|
||||
AccessToken string `json:"accessToken"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Session PortalSession `json:"session"`
|
||||
}
|
||||
|
||||
type PortalLoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type PortalAuditItem struct {
|
||||
TraceID string `json:"traceId"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Result string `json:"result"`
|
||||
VehicleCount int `json:"vehicleCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type DataProduct struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
611
vehicle-data-platform/apps/api/internal/openplatform/mysql.go
Normal file
611
vehicle-data-platform/apps/api/internal/openplatform/mysql.go
Normal file
@@ -0,0 +1,611 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type MySQLRepository struct {
|
||||
db *sql.DB
|
||||
tdengine *sql.DB
|
||||
tdDatabase string
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) WithTDengine(db *sql.DB, database string) *MySQLRepository {
|
||||
if db == nil {
|
||||
panic("open platform TDengine database is required")
|
||||
}
|
||||
if database == "" {
|
||||
database = "lingniu_vehicle_ts"
|
||||
}
|
||||
for _, char := range database {
|
||||
if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && char != '_' {
|
||||
panic("invalid TDengine database identifier")
|
||||
}
|
||||
}
|
||||
r.tdengine = db
|
||||
r.tdDatabase = database
|
||||
return r
|
||||
}
|
||||
|
||||
func NewMySQLRepository(db *sql.DB) *MySQLRepository {
|
||||
if db == nil {
|
||||
panic("open platform database is required")
|
||||
}
|
||||
return &MySQLRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) Authenticate(ctx context.Context, hash [sha256.Size]byte, now, dayStart, dayEnd time.Time) (AppCredential, error) {
|
||||
var app AppCredential
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id,name
|
||||
FROM vehicle_open_app
|
||||
WHERE app_key_hash=?
|
||||
AND status='enabled'
|
||||
AND valid_from<=?
|
||||
AND (valid_to IS NULL OR valid_to>?)
|
||||
AND valid_from<=?
|
||||
AND (valid_to IS NULL OR valid_to>=?)`,
|
||||
hash[:], now, now, dayStart, dayEnd,
|
||||
).Scan(&app.ID, &app.Name)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AppCredential{}, ErrUnauthorized
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) AuthorizedVehicles(ctx context.Context, appID uint64, plates []string, dayStart, dayEnd time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
args := make([]any, 0, len(plates)+3)
|
||||
args = append(args, appID, dayStart, dayEnd)
|
||||
query := `
|
||||
SELECT UPPER(b.plate),MIN(g.vin)
|
||||
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 TRIM(COALESCE(b.plate,''))<>''`
|
||||
if len(plates) > 0 {
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(plates)), ",")
|
||||
query += `
|
||||
AND UPPER(b.plate) IN (` + placeholders + `)`
|
||||
for _, plate := range plates {
|
||||
args = append(args, plate)
|
||||
}
|
||||
}
|
||||
query += `
|
||||
GROUP BY UPPER(b.plate)
|
||||
HAVING COUNT(DISTINCT g.vin)=1
|
||||
ORDER BY UPPER(b.plate)`
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]AuthorizedVehicle, len(plates))
|
||||
for rows.Next() {
|
||||
var vehicle AuthorizedVehicle
|
||||
if err := rows.Scan(&vehicle.Plate, &vehicle.VIN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[vehicle.Plate] = vehicle
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) AuthorizedVIN(ctx context.Context, appID uint64, vin string, at time.Time) (bool, error) {
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM vehicle_open_app_vehicle
|
||||
WHERE app_id=? AND BINARY vin=BINARY ?
|
||||
AND valid_from<=?
|
||||
AND (valid_to IS NULL OR valid_to>?)`, appID, vin, at, at).Scan(&count)
|
||||
return count == 1, err
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.Time, protocols []string) (*TotalMileagePoint, error) {
|
||||
if r.tdengine == nil || r.tdDatabase == "" {
|
||||
return nil, errors.New("TDengine is not configured for total mileage query")
|
||||
}
|
||||
table := r.tdDatabase + ".vehicle_locations"
|
||||
timeLiteral := strings.ReplaceAll(at.Format(time.RFC3339), "'", "''")
|
||||
for _, protocol := range protocols {
|
||||
query := `SELECT CAST(ts AS BIGINT),total_mileage_km,protocol FROM ` + table +
|
||||
` WHERE vin='` + vin + `' AND protocol='` + protocol + `'` +
|
||||
` AND ts<='` + timeLiteral + `' AND total_mileage_km IS NOT NULL AND total_mileage_km>=0` +
|
||||
` ORDER BY ts DESC LIMIT 1`
|
||||
var timestampMS int64
|
||||
var point TotalMileagePoint
|
||||
err := r.tdengine.QueryRowContext(ctx, query).Scan(×tampMS, &point.TotalMileageKm, &point.Protocol)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
point.VIN = vin
|
||||
point.ObservedAt = time.UnixMilli(timestampMS)
|
||||
return &point, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date string) (map[string]DailyHydrogen, error) {
|
||||
if len(vins) == 0 {
|
||||
return map[string]DailyHydrogen{}, nil
|
||||
}
|
||||
query, args := inQuery(`
|
||||
SELECT vin,DATE_FORMAT(stat_date,'%Y-%m-%d'),consumption_kg,sample_count,quality_status
|
||||
FROM vehicle_open_daily_energy
|
||||
WHERE energy_type='HYDROGEN' AND stat_date=? AND vin IN (%s)`, date, vins)
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]DailyHydrogen, len(vins))
|
||||
for rows.Next() {
|
||||
var value DailyHydrogen
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.ConsumptionKg, &value.SampleCount, &value.QualityStatus); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[value.VIN] = value
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) DailyMileage(ctx context.Context, vins []string, date string, protocols []string) (map[string]DailyMileage, error) {
|
||||
values, err := r.DailyMileageRange(ctx, vins, date, date, protocols)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]DailyMileage, len(values))
|
||||
for _, value := range values {
|
||||
out[value.VIN] = value
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) DailyMileageRange(ctx context.Context, vins []string, startDate, endDate string, protocols []string) (map[string]DailyMileage, error) {
|
||||
if len(vins) == 0 {
|
||||
return map[string]DailyMileage{}, nil
|
||||
}
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||
query := `
|
||||
SELECT
|
||||
m.vin,
|
||||
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
|
||||
m.protocol,
|
||||
m.daily_mileage_km,
|
||||
m.latest_total_mileage_km,
|
||||
COALESCE(DATE_FORMAT((
|
||||
SELECT MAX(selected.latest_event_time)
|
||||
FROM vehicle_daily_mileage_source selected
|
||||
WHERE selected.vin=m.vin
|
||||
AND selected.stat_date=m.stat_date
|
||||
AND selected.protocol=m.protocol
|
||||
AND selected.is_selected=1
|
||||
AND selected.latest_event_time IS NOT NULL
|
||||
),'%Y-%m-%dT%H:%i:%s+08:00'),''),
|
||||
DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00')
|
||||
FROM vehicle_daily_mileage m
|
||||
WHERE m.stat_date BETWEEN ? AND ?
|
||||
AND m.vin IN (` + placeholders + `)
|
||||
AND m.latest_total_mileage_km IS NOT NULL
|
||||
AND m.latest_total_mileage_km>=0
|
||||
AND m.daily_mileage_km>=0`
|
||||
args := make([]any, 0, len(vins)+2+len(protocols)*2)
|
||||
args = append(args, startDate, endDate)
|
||||
for _, vin := range vins {
|
||||
args = append(args, vin)
|
||||
}
|
||||
if len(protocols) > 0 {
|
||||
protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",")
|
||||
query += "\n AND m.protocol IN (" + protocolPlaceholders + ")\nORDER BY m.stat_date,m.vin,CASE m.protocol"
|
||||
for _, protocol := range protocols {
|
||||
args = append(args, protocol)
|
||||
}
|
||||
for index, protocol := range protocols {
|
||||
query += " WHEN ? THEN " + strconv.Itoa(index+1)
|
||||
args = append(args, protocol)
|
||||
}
|
||||
query += " ELSE 99 END,m.protocol"
|
||||
} else {
|
||||
query += `
|
||||
ORDER BY m.stat_date,m.vin,
|
||||
CASE WHEN m.daily_mileage_km>0 THEN 0 ELSE 1 END,
|
||||
CASE m.protocol WHEN 'GB32960' THEN 1 WHEN 'YUTONG_MQTT' THEN 2 WHEN 'JT808' THEN 3 ELSE 99 END,
|
||||
m.protocol`
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]DailyMileage, len(vins))
|
||||
for rows.Next() {
|
||||
var value DailyMileage
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := dailyMileageKey(value.VIN, value.Date)
|
||||
if _, exists := out[key]; !exists {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) LatestMileageBefore(ctx context.Context, vins []string, beforeDate string, protocols []string) (map[string]DailyMileage, error) {
|
||||
if len(vins) == 0 {
|
||||
return map[string]DailyMileage{}, nil
|
||||
}
|
||||
vinPlaceholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||
query := `
|
||||
SELECT
|
||||
m.vin,
|
||||
DATE_FORMAT(m.stat_date,'%Y-%m-%d'),
|
||||
m.protocol,
|
||||
m.daily_mileage_km,
|
||||
m.latest_total_mileage_km,
|
||||
COALESCE(DATE_FORMAT((
|
||||
SELECT MAX(selected.latest_event_time)
|
||||
FROM vehicle_daily_mileage_source selected
|
||||
WHERE selected.vin=m.vin
|
||||
AND selected.stat_date=m.stat_date
|
||||
AND selected.protocol=m.protocol
|
||||
AND selected.is_selected=1
|
||||
AND selected.latest_event_time IS NOT NULL
|
||||
),'%Y-%m-%dT%H:%i:%s+08:00'),''),
|
||||
DATE_FORMAT(m.updated_at,'%Y-%m-%dT%H:%i:%s+08:00')
|
||||
FROM vehicle_daily_mileage m
|
||||
JOIN (
|
||||
SELECT prior.vin,prior.protocol,MAX(prior.stat_date) AS stat_date
|
||||
FROM vehicle_daily_mileage prior
|
||||
WHERE prior.stat_date<?
|
||||
AND prior.vin IN (` + vinPlaceholders + `)
|
||||
AND prior.latest_total_mileage_km IS NOT NULL
|
||||
AND prior.latest_total_mileage_km>=0
|
||||
AND prior.daily_mileage_km>=0`
|
||||
args := make([]any, 0, len(vins)+1+len(protocols)*2)
|
||||
args = append(args, beforeDate)
|
||||
for _, vin := range vins {
|
||||
args = append(args, vin)
|
||||
}
|
||||
if len(protocols) > 0 {
|
||||
protocolPlaceholders := strings.TrimRight(strings.Repeat("?,", len(protocols)), ",")
|
||||
query += "\n AND prior.protocol IN (" + protocolPlaceholders + ")"
|
||||
for _, protocol := range protocols {
|
||||
args = append(args, protocol)
|
||||
}
|
||||
}
|
||||
query += `
|
||||
GROUP BY prior.vin,prior.protocol
|
||||
) latest
|
||||
ON latest.vin=m.vin
|
||||
AND latest.stat_date=m.stat_date
|
||||
AND latest.protocol=m.protocol`
|
||||
if len(protocols) > 0 {
|
||||
query += "\nORDER BY m.vin,CASE m.protocol"
|
||||
for index, protocol := range protocols {
|
||||
query += " WHEN ? THEN " + strconv.Itoa(index+1)
|
||||
args = append(args, protocol)
|
||||
}
|
||||
query += " ELSE 99 END,m.protocol,m.updated_at DESC"
|
||||
} else {
|
||||
query += `
|
||||
ORDER BY m.vin,
|
||||
CASE WHEN m.daily_mileage_km>0 THEN 0 ELSE 1 END,
|
||||
CASE m.protocol WHEN 'GB32960' THEN 1 WHEN 'YUTONG_MQTT' THEN 2 WHEN 'JT808' THEN 3 ELSE 99 END,
|
||||
m.protocol,
|
||||
m.updated_at DESC`
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]DailyMileage, len(vins))
|
||||
for rows.Next() {
|
||||
var value DailyMileage
|
||||
if err := rows.Scan(&value.VIN, &value.Date, &value.Protocol, &value.MileageKm, &value.TotalMileageKm, &value.DataTime, &value.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := out[value.VIN]; !exists {
|
||||
out[value.VIN] = value
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) CreateMileageSnapshot(ctx context.Context, snapshot MileageSnapshot) error {
|
||||
if len(snapshot.RequestHash) != sha256.Size || snapshot.ID == "" {
|
||||
return fmt.Errorf("%w: invalid snapshot", ErrInvalidRequest)
|
||||
}
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_mileage_snapshot WHERE expires_at<=CURRENT_TIMESTAMP(3)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_mileage_snapshot
|
||||
(snapshot_id,app_id,request_hash,start_date,end_date,vehicle_count,expires_at)
|
||||
VALUES(?,?,?,?,?,?,?)`,
|
||||
snapshot.ID, snapshot.AppID, snapshot.RequestHash, snapshot.StartDate, snapshot.EndDate, len(snapshot.Vehicles), snapshot.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
const batchSize = 500
|
||||
for start := 0; start < len(snapshot.Vehicles); start += batchSize {
|
||||
end := start + batchSize
|
||||
if end > len(snapshot.Vehicles) {
|
||||
end = len(snapshot.Vehicles)
|
||||
}
|
||||
var query strings.Builder
|
||||
query.WriteString(`INSERT INTO vehicle_open_mileage_snapshot_vehicle(snapshot_id,ordinal,vin,plate) VALUES `)
|
||||
args := make([]any, 0, (end-start)*4)
|
||||
for index := start; index < end; index++ {
|
||||
if index > start {
|
||||
query.WriteByte(',')
|
||||
}
|
||||
query.WriteString("(?,?,?,?)")
|
||||
vehicle := snapshot.Vehicles[index]
|
||||
args = append(args, snapshot.ID, index, vehicle.VIN, vehicle.Plate)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, query.String(), args...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) LoadMileageSnapshot(ctx context.Context, snapshotID string, appID uint64, now time.Time) (MileageSnapshot, error) {
|
||||
snapshot := MileageSnapshot{ID: snapshotID, AppID: appID}
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT request_hash,DATE_FORMAT(start_date,'%Y-%m-%d'),DATE_FORMAT(end_date,'%Y-%m-%d'),vehicle_count
|
||||
FROM vehicle_open_mileage_snapshot
|
||||
WHERE snapshot_id=? AND app_id=? AND expires_at>?`, snapshotID, appID, now).
|
||||
Scan(&snapshot.RequestHash, &snapshot.StartDate, &snapshot.EndDate, &snapshot.VehicleCount)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return MileageSnapshot{}, fmt.Errorf("%w: cursor expired or unavailable", ErrInvalidRequest)
|
||||
}
|
||||
if err != nil {
|
||||
return MileageSnapshot{}, err
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT vin,plate
|
||||
FROM vehicle_open_mileage_snapshot_vehicle
|
||||
WHERE snapshot_id=?
|
||||
ORDER BY ordinal`, snapshotID)
|
||||
if err != nil {
|
||||
return MileageSnapshot{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
snapshot.Vehicles = make([]AuthorizedVehicle, 0, snapshot.VehicleCount)
|
||||
for rows.Next() {
|
||||
var vehicle AuthorizedVehicle
|
||||
if err := rows.Scan(&vehicle.VIN, &vehicle.Plate); err != nil {
|
||||
return MileageSnapshot{}, err
|
||||
}
|
||||
snapshot.Vehicles = append(snapshot.Vehicles, vehicle)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return MileageSnapshot{}, err
|
||||
}
|
||||
if len(snapshot.Vehicles) != snapshot.VehicleCount {
|
||||
return MileageSnapshot{}, errors.New("mileage snapshot vehicle count mismatch")
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) Audit(ctx context.Context, appID uint64, endpoint, result, traceID string, requested int, detail string) error {
|
||||
var nullableApp any
|
||||
if appID > 0 {
|
||||
nullableApp = appID
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_api_audit(trace_id,app_id,endpoint,result,requested_vehicle_count,detail)
|
||||
VALUES(?,?,?,?,?,?)`, traceID, nullableApp, endpoint, result, requested, truncate(detail, 512))
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) CreateApp(ctx context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_app(name,app_key_hash,app_key_prefix,status,valid_from,valid_to,created_by,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, input.Name, hash[:], prefix, input.Status, from, nullableTime(to), actor, actor)
|
||||
if err != nil {
|
||||
return App{}, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return App{}, err
|
||||
}
|
||||
_ = r.adminAudit(ctx, uint64(id), actor, "create_app", map[string]any{"name": input.Name, "validFrom": from, "validTo": to})
|
||||
return r.app(ctx, uint64(id))
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) ListApps(ctx context.Context) ([]App, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id,name,app_key_prefix,status,valid_from,valid_to,created_by,created_at,updated_at
|
||||
FROM vehicle_open_app ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
apps := make([]App, 0)
|
||||
for rows.Next() {
|
||||
app, err := scanApp(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apps = append(apps, app)
|
||||
}
|
||||
return apps, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) UpdateApp(ctx context.Context, id uint64, input AppInput, from time.Time, to *time.Time, actor string) (App, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE vehicle_open_app SET name=?,status=?,valid_from=?,valid_to=?,updated_by=? WHERE id=?`,
|
||||
input.Name, input.Status, from, nullableTime(to), actor, id)
|
||||
if err != nil {
|
||||
return App{}, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return App{}, ErrNotFound
|
||||
}
|
||||
_ = r.adminAudit(ctx, id, actor, "update_app", map[string]any{"name": input.Name, "status": input.Status, "validFrom": from, "validTo": to})
|
||||
return r.app(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) RotateKey(ctx context.Context, id uint64, hash [sha256.Size]byte, prefix, actor string) (App, error) {
|
||||
result, err := r.db.ExecContext(ctx, `UPDATE vehicle_open_app SET app_key_hash=?,app_key_prefix=?,updated_by=? WHERE id=?`, hash[:], prefix, actor, id)
|
||||
if err != nil {
|
||||
return App{}, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return App{}, ErrNotFound
|
||||
}
|
||||
_ = r.adminAudit(ctx, id, actor, "rotate_key", map[string]any{"appKeyPrefix": prefix})
|
||||
return r.app(ctx, id)
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) ReplaceVehicleGrants(ctx context.Context, appID uint64, grants []parsedGrant, actor string) ([]VehicleGrant, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var exists int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=? FOR UPDATE`, appID).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if len(grants) > 0 {
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(grants)), ",")
|
||||
args := make([]any, 0, len(grants))
|
||||
for _, grant := range grants {
|
||||
args = append(args, grant.VIN)
|
||||
}
|
||||
var vinCount int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT vin) FROM vehicle_identity_binding WHERE vin IN (`+placeholders+`)`, args...).Scan(&vinCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if vinCount != len(grants) {
|
||||
return nil, fmt.Errorf("%w: one or more VINs do not exist", ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_app_vehicle WHERE app_id=?`, appID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, grant := range grants {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_app_vehicle(app_id,vin,valid_from,valid_to,granted_by)
|
||||
VALUES(?,?,?,?,?)`, appID, grant.VIN, grant.ValidFrom, nullableTime(grant.ValidTo), actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
detail, _ := json.Marshal(map[string]any{"count": len(grants)})
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_admin_audit(app_id,actor,action,detail_json)
|
||||
VALUES(?,?,?,?)`, appID, actor, "replace_vehicle_grants", string(detail)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.ListVehicleGrants(ctx, appID)
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) ListVehicleGrants(ctx context.Context, appID uint64) ([]VehicleGrant, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT g.vin,COALESCE(MAX(b.plate),''),g.valid_from,g.valid_to,g.granted_by,g.updated_at
|
||||
FROM vehicle_open_app_vehicle g
|
||||
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
|
||||
WHERE g.app_id=?
|
||||
GROUP BY g.app_id,g.vin,g.valid_from,g.valid_to,g.granted_by,g.updated_at
|
||||
ORDER BY g.vin`, appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
grants := make([]VehicleGrant, 0)
|
||||
for rows.Next() {
|
||||
var grant VehicleGrant
|
||||
var validTo sql.NullTime
|
||||
if err := rows.Scan(&grant.VIN, &grant.Plate, &grant.ValidFrom, &validTo, &grant.GrantedBy, &grant.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validTo.Valid {
|
||||
grant.ValidTo = &validTo.Time
|
||||
}
|
||||
grants = append(grants, grant)
|
||||
}
|
||||
return grants, rows.Err()
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) app(ctx context.Context, id uint64) (App, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT id,name,app_key_prefix,status,valid_from,valid_to,created_by,created_at,updated_at
|
||||
FROM vehicle_open_app WHERE id=?`, id)
|
||||
app, err := scanApp(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return App{}, ErrNotFound
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(...any) error
|
||||
}
|
||||
|
||||
func scanApp(row scanner) (App, error) {
|
||||
var app App
|
||||
var validTo sql.NullTime
|
||||
err := row.Scan(&app.ID, &app.Name, &app.AppKeyPrefix, &app.Status, &app.ValidFrom, &validTo, &app.CreatedBy, &app.CreatedAt, &app.UpdatedAt)
|
||||
if validTo.Valid {
|
||||
app.ValidTo = &validTo.Time
|
||||
}
|
||||
return app, err
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) adminAudit(ctx context.Context, appID uint64, actor, action string, detail any) error {
|
||||
encoded, _ := json.Marshal(detail)
|
||||
_, err := r.db.ExecContext(ctx, `INSERT INTO vehicle_open_admin_audit(app_id,actor,action,detail_json) VALUES(?,?,?,?)`, appID, actor, action, encoded)
|
||||
return err
|
||||
}
|
||||
|
||||
func inQuery(template, first string, values []string) (string, []any) {
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(values)), ",")
|
||||
args := make([]any, 0, len(values)+1)
|
||||
args = append(args, first)
|
||||
for _, value := range values {
|
||||
args = append(args, value)
|
||||
}
|
||||
return strings.Replace(template, "%s", placeholders, 1), args
|
||||
}
|
||||
|
||||
func nullableTime(value *time.Time) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func truncate(value string, size int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= size {
|
||||
return value
|
||||
}
|
||||
return string(runes[:size])
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestInQueryPreservesMySQLDateFormatPercentTokens(t *testing.T) {
|
||||
query, args := inQuery("SELECT DATE_FORMAT(stat_date,'%Y-%m-%d') FROM metrics WHERE stat_date=? AND vin IN (%s)", "2026-07-21", []string{"VIN1", "VIN2"})
|
||||
if strings.Contains(query, "MISSING") || !strings.Contains(query, "DATE_FORMAT(stat_date,'%Y-%m-%d')") {
|
||||
t.Fatalf("date format was corrupted: %s", query)
|
||||
}
|
||||
if !strings.Contains(query, "vin IN (?,?)") {
|
||||
t.Fatalf("VIN placeholders missing: %s", query)
|
||||
}
|
||||
if len(args) != 3 || args[0] != "2026-07-21" || args[1] != "VIN1" || args[2] != "VIN2" {
|
||||
t.Fatalf("unexpected args: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedVehiclesUsesBinaryVINJoin(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
start := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
|
||||
end := start.Add(24*time.Hour - time.Nanosecond)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin")).
|
||||
WithArgs(uint64(1), start, end, "辽A00001").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}).AddRow("辽A00001", "LTEST000000000001"))
|
||||
|
||||
repository := NewMySQLRepository(db)
|
||||
vehicles, err := repository.AuthorizedVehicles(context.Background(), 1, []string{"辽A00001"}, start, end)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if vehicles["辽A00001"].VIN != "LTEST000000000001" {
|
||||
t.Fatalf("unexpected vehicles: %#v", vehicles)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizedVehiclesWithoutPlateFilterReturnsAllGrantedVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
start := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
|
||||
end := start.Add(24 * time.Hour)
|
||||
mock.ExpectQuery("JOIN vehicle_identity_binding.*TRIM.*ORDER BY UPPER").
|
||||
WithArgs(uint64(7), start, end).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"plate", "vin"}).
|
||||
AddRow("粤A12345", "LTEST32960VIN0001").
|
||||
AddRow("粤B67890", "LTEST32960VIN0002"))
|
||||
|
||||
vehicles, err := NewMySQLRepository(db).AuthorizedVehicles(context.Background(), 7, nil, start, end)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(vehicles) != 2 || vehicles["粤A12345"].VIN != "LTEST32960VIN0001" || vehicles["粤B67890"].VIN != "LTEST32960VIN0002" {
|
||||
t.Fatalf("vehicles=%#v", vehicles)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyMileageReturnsDailyAndSameProtocolEndTotal(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*FROM vehicle_daily_mileage m\\s+WHERE m.stat_date BETWEEN \\? AND \\?.*m.latest_total_mileage_km>=0.*m.daily_mileage_km>=0").
|
||||
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "LTEST32960VIN0002").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 101.235, 12345.679, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00"))
|
||||
|
||||
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001", "LTEST32960VIN0002"}, "2026-07-21", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := values["LTEST32960VIN0001"]
|
||||
if first.MileageKm != 101.235 || first.TotalMileageKm != 12345.679 || first.DataTime != "2026-07-21T23:58:45+08:00" || first.UpdatedAt == "" {
|
||||
t.Fatalf("first=%#v", first)
|
||||
}
|
||||
if _, ok := values["LTEST32960VIN0002"]; ok {
|
||||
t.Fatalf("vehicle without a cumulative total must not be returned: %#v", values)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyMileageExplicitPriorityFiltersDisabledProtocolsAndKeepsZero(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("m.protocol IN \\(\\?,\\?\\).*ORDER BY m.stat_date,m.vin,CASE m.protocol WHEN \\? THEN 1 WHEN \\? THEN 2").
|
||||
WithArgs("2026-07-21", "2026-07-21", "LTEST32960VIN0001", "JT808", "GB32960", "JT808", "GB32960").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "JT808", 0.0, 12000.0, "2026-07-21T23:58:45+08:00", "2026-07-22T05:10:00+08:00").
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "GB32960", 12.0, 12012.0, "2026-07-21T23:59:00+08:00", "2026-07-22T05:10:00+08:00"))
|
||||
|
||||
values, err := NewMySQLRepository(db).DailyMileage(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-21", []string{"JT808", "GB32960"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected := values["LTEST32960VIN0001"]
|
||||
if selected.Protocol != "JT808" || selected.MileageKm != 0 {
|
||||
t.Fatalf("selected=%#v", selected)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestMileageBeforeUsesRequestedProtocolPriority(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT MAX\\(selected.latest_event_time\\).*SELECT prior.vin,prior.protocol,MAX\\(prior.stat_date\\).*FROM vehicle_daily_mileage prior\\s+WHERE prior.stat_date<\\?.*prior.protocol IN \\(\\?,\\?\\).*ORDER BY m.vin,CASE m.protocol").
|
||||
WithArgs("2026-07-22", "LTEST32960VIN0001", "JT808", "YUTONG_MQTT", "JT808", "YUTONG_MQTT").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "date", "protocol", "daily_mileage_km", "latest_total_mileage_km", "data_time", "updated_at"}).
|
||||
AddRow("LTEST32960VIN0001", "2026-07-20", "JT808", 18.5, 9008.5, "2026-07-20T22:00:00+08:00", "2026-07-21T01:00:00+08:00").
|
||||
AddRow("LTEST32960VIN0001", "2026-07-21", "YUTONG_MQTT", 20.0, 12020.0, "2026-07-21T23:00:00+08:00", "2026-07-22T01:00:00+08:00"))
|
||||
|
||||
values, err := NewMySQLRepository(db).LatestMileageBefore(context.Background(), []string{"LTEST32960VIN0001"}, "2026-07-22", []string{"JT808", "YUTONG_MQTT"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected := values["LTEST32960VIN0001"]
|
||||
if selected.Protocol != "JT808" ||
|
||||
selected.MileageKm != 18.5 ||
|
||||
selected.TotalMileageKm != 9008.5 ||
|
||||
selected.DataTime != "2026-07-20T22:00:00+08:00" ||
|
||||
selected.UpdatedAt != "2026-07-21T01:00:00+08:00" {
|
||||
t.Fatalf("selected=%#v", selected)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalMileageUsesProtocolPriorityAndLatestRecordAtOrBeforeTime(t *testing.T) {
|
||||
mysqlDB, _, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer mysqlDB.Close()
|
||||
tdDB, tdMock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tdDB.Close()
|
||||
at := time.Date(2026, 7, 21, 9, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||
tdMock.ExpectQuery("protocol='GB32960'.*ts<='2026-07-21T09:30:00\\+08:00'.*ORDER BY ts DESC LIMIT 1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"ts", "total_mileage_km", "protocol"}))
|
||||
tdMock.ExpectQuery("protocol='YUTONG_MQTT'.*ts<='2026-07-21T09:30:00\\+08:00'.*ORDER BY ts DESC LIMIT 1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"ts", "total_mileage_km", "protocol"}).AddRow(at.Add(-15*time.Second).UnixMilli(), 12345.678, "YUTONG_MQTT"))
|
||||
repository := NewMySQLRepository(mysqlDB).WithTDengine(tdDB, "lingniu_vehicle_ts")
|
||||
point, err := repository.TotalMileage(context.Background(), "LA9GG68L2PBAF4790", at, []string{"GB32960", "YUTONG_MQTT", "JT808"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if point == nil || point.Protocol != "YUTONG_MQTT" || point.TotalMileageKm != 12345.678 || !point.ObservedAt.Equal(at.Add(-15*time.Second)) {
|
||||
t.Fatalf("point=%#v", point)
|
||||
}
|
||||
if err := tdMock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListVehicleGrantsUsesBinaryVINJoin(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := time.Date(2026, 7, 20, 0, 0, 0, 0, time.Local)
|
||||
mock.ExpectQuery(regexp.QuoteMeta("LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin")).
|
||||
WithArgs(uint64(1)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "valid_from", "valid_to", "granted_by", "updated_at"}).
|
||||
AddRow("LTEST000000000001", "辽A00001", now, now.AddDate(1, 0, 0), "admin", now))
|
||||
|
||||
repository := NewMySQLRepository(db)
|
||||
grants, err := repository.ListVehicleGrants(context.Background(), 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(grants) != 1 || grants[0].Plate != "辽A00001" {
|
||||
t.Fatalf("unexpected grants: %#v", grants)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
675
vehicle-data-platform/apps/api/internal/openplatform/portal.go
Normal file
675
vehicle-data-platform/apps/api/internal/openplatform/portal.go
Normal file
@@ -0,0 +1,675 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
portalMaxLoginFailures = 5
|
||||
portalLoginLock = 15 * time.Minute
|
||||
)
|
||||
|
||||
var portalUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]{3,64}$`)
|
||||
|
||||
type PortalService struct {
|
||||
db *sql.DB
|
||||
sessionTTL time.Duration
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type portalCredential struct {
|
||||
PortalUser
|
||||
PasswordHash string
|
||||
FailedLoginCount int
|
||||
LockedUntil sql.NullTime
|
||||
UserType string
|
||||
}
|
||||
|
||||
func NewPortalService(db *sql.DB, sessionTTL time.Duration) *PortalService {
|
||||
if db == nil {
|
||||
panic("open platform portal database is required")
|
||||
}
|
||||
if sessionTTL <= 0 {
|
||||
sessionTTL = 12 * time.Hour
|
||||
}
|
||||
return &PortalService{db: db, sessionTTL: sessionTTL, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *PortalService) CreateUser(ctx context.Context, input PortalUserInput, actor string) (PortalUser, error) {
|
||||
from, to, err := validatePortalUserInput(&input, true)
|
||||
if err != nil {
|
||||
return PortalUser{}, err
|
||||
}
|
||||
if reserved, err := s.platformAdminUsername(ctx, input.Username); err != nil {
|
||||
return PortalUser{}, err
|
||||
} else if reserved {
|
||||
return PortalUser{}, fmt.Errorf("%w: username is reserved by a platform administrator", ErrInvalidRequest)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
if err != nil {
|
||||
return PortalUser{}, err
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_user(
|
||||
username,display_name,password_hash,status,valid_from,valid_to,created_by,updated_by
|
||||
) VALUES(?,?,?,?,?,?,?,?)`,
|
||||
input.Username, input.DisplayName, string(hash), input.Status, from, nullableTime(to), actor, actor)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
return PortalUser{}, fmt.Errorf("%w: username already exists", ErrInvalidRequest)
|
||||
}
|
||||
return PortalUser{}, err
|
||||
}
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return PortalUser{}, err
|
||||
}
|
||||
_ = s.userAudit(ctx, uint64(id), actor, "user.create", "success", map[string]any{"username": input.Username}, "")
|
||||
return s.user(ctx, uint64(id))
|
||||
}
|
||||
|
||||
func (s *PortalService) ListUsers(ctx context.Context) ([]PortalUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id,username,display_name,status,valid_from,valid_to,last_login_at,created_by,created_at,updated_at
|
||||
FROM vehicle_open_user ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
users := make([]PortalUser, 0)
|
||||
for rows.Next() {
|
||||
user, err := scanPortalUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) UpdateUser(ctx context.Context, id uint64, input PortalUserInput, actor string) (PortalUser, error) {
|
||||
from, to, err := validatePortalUserInput(&input, false)
|
||||
if err != nil {
|
||||
return PortalUser{}, err
|
||||
}
|
||||
if reserved, err := s.platformAdminUsername(ctx, input.Username); err != nil {
|
||||
return PortalUser{}, err
|
||||
} else if reserved {
|
||||
return PortalUser{}, fmt.Errorf("%w: username is reserved by a platform administrator", ErrInvalidRequest)
|
||||
}
|
||||
args := []any{input.Username, input.DisplayName, input.Status, from, nullableTime(to), actor}
|
||||
query := `UPDATE vehicle_open_user SET username=?,display_name=?,status=?,valid_from=?,valid_to=?,updated_by=?`
|
||||
if input.Password != "" {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 12)
|
||||
if err != nil {
|
||||
return PortalUser{}, err
|
||||
}
|
||||
query += `,password_hash=?,password_changed_at=NOW(3),failed_login_count=0,locked_until=NULL`
|
||||
args = append(args, string(hash))
|
||||
}
|
||||
query += ` WHERE id=?`
|
||||
args = append(args, id)
|
||||
result, err := s.db.ExecContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
|
||||
return PortalUser{}, fmt.Errorf("%w: username already exists", ErrInvalidRequest)
|
||||
}
|
||||
return PortalUser{}, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected == 0 {
|
||||
return PortalUser{}, ErrNotFound
|
||||
}
|
||||
if input.Status == "disabled" || input.Password != "" {
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, id)
|
||||
}
|
||||
_ = s.userAudit(ctx, id, actor, "user.update", "success", map[string]any{"status": input.Status, "passwordReset": input.Password != ""}, "")
|
||||
return s.user(ctx, id)
|
||||
}
|
||||
|
||||
func (s *PortalService) ReplaceUserApps(ctx context.Context, userID uint64, request PortalUserAppRequest, actor string) ([]PortalUserApp, error) {
|
||||
if len(request.Apps) > 100 {
|
||||
return nil, fmt.Errorf("%w: app memberships exceed 100", ErrInvalidRequest)
|
||||
}
|
||||
seen := map[uint64]bool{}
|
||||
for _, item := range request.Apps {
|
||||
item.Role = strings.ToLower(strings.TrimSpace(item.Role))
|
||||
if item.AppID == 0 || seen[item.AppID] || !validPortalRole(item.Role) {
|
||||
return nil, fmt.Errorf("%w: invalid or duplicate app membership", ErrInvalidRequest)
|
||||
}
|
||||
seen[item.AppID] = true
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var exists int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_user WHERE id=? FOR UPDATE`, userID).Scan(&exists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
for _, item := range request.Apps {
|
||||
var appExists int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=?`, item.AppID).Scan(&appExists); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if appExists == 0 {
|
||||
return nil, fmt.Errorf("%w: app %d", ErrInvalidRequest, item.AppID)
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_user_app WHERE user_id=?`, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range request.Apps {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_user_app(user_id,app_id,role,granted_by) VALUES(?,?,?,?)`,
|
||||
userID, item.AppID, strings.ToLower(strings.TrimSpace(item.Role)), actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.userAudit(ctx, userID, actor, "user.apps.replace", "success", map[string]any{"count": len(request.Apps)}, "")
|
||||
return s.ListUserApps(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *PortalService) ListUserApps(ctx context.Context, userID uint64) ([]PortalUserApp, error) {
|
||||
return s.portalApps(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *PortalService) Login(ctx context.Context, input PortalLoginRequest, remoteAddr, userAgent string) (PortalLoginResponse, error) {
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
if !portalUsernamePattern.MatchString(input.Username) || len(input.Password) > 128 {
|
||||
return PortalLoginResponse{}, ErrUnauthorized
|
||||
}
|
||||
credential, err := s.loginCredential(ctx, input.Username)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
_ = s.userAudit(ctx, 0, input.Username, "login", "denied", map[string]any{"reason": "invalid_credentials"}, remoteAddr)
|
||||
return PortalLoginResponse{}, ErrUnauthorized
|
||||
}
|
||||
return PortalLoginResponse{}, err
|
||||
}
|
||||
now := s.now()
|
||||
if credential.Status != "enabled" ||
|
||||
(credential.UserType != "admin" && (credential.ValidFrom.After(now) || (credential.ValidTo != nil && !credential.ValidTo.After(now)))) {
|
||||
_ = s.userAudit(ctx, auditPortalUserID(credential), input.Username, "login", "denied", map[string]any{"reason": "disabled_or_expired"}, remoteAddr)
|
||||
return PortalLoginResponse{}, ErrForbidden
|
||||
}
|
||||
if credential.LockedUntil.Valid && credential.LockedUntil.Time.After(now) {
|
||||
return PortalLoginResponse{}, fmt.Errorf("%w: account locked", ErrForbidden)
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(credential.PasswordHash), []byte(input.Password)) != nil {
|
||||
failures := credential.FailedLoginCount + 1
|
||||
var lockedUntil any
|
||||
if failures >= portalMaxLoginFailures {
|
||||
lockedUntil = now.Add(portalLoginLock)
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE `+credentialTable(credential.UserType)+` SET failed_login_count=?,locked_until=? WHERE id=?`, failures, lockedUntil, credential.ID)
|
||||
_ = s.userAudit(ctx, auditPortalUserID(credential), input.Username, "login", "denied", map[string]any{"reason": "invalid_credentials", "failures": failures}, remoteAddr)
|
||||
return PortalLoginResponse{}, ErrUnauthorized
|
||||
}
|
||||
rawToken, tokenHash, err := newPortalSessionToken()
|
||||
if err != nil {
|
||||
return PortalLoginResponse{}, err
|
||||
}
|
||||
sessionID, err := randomHexBytes(16)
|
||||
if err != nil {
|
||||
return PortalLoginResponse{}, err
|
||||
}
|
||||
expiresAt := now.Add(s.sessionTTL)
|
||||
var portalUserID, platformUserID any
|
||||
if credential.UserType == "admin" {
|
||||
platformUserID = credential.ID
|
||||
} else {
|
||||
portalUserID = credential.ID
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_user_session(
|
||||
id,user_id,platform_user_id,token_hash,issued_at,expires_at,last_seen_at,remote_addr,user_agent
|
||||
) VALUES(?,?,?,?,?,?,?,?,?)`,
|
||||
sessionID, portalUserID, platformUserID, tokenHash[:], now, expiresAt, now, truncate(remoteAddr, 96), truncate(userAgent, 255))
|
||||
if err != nil {
|
||||
return PortalLoginResponse{}, err
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE `+credentialTable(credential.UserType)+` SET failed_login_count=0,locked_until=NULL,last_login_at=? WHERE id=?`, now, credential.ID)
|
||||
_ = s.userAudit(ctx, auditPortalUserID(credential), credential.Username, "login", "success", map[string]any{"userType": credential.UserType}, remoteAddr)
|
||||
session := PortalSession{UserID: credential.ID, Username: credential.Username, DisplayName: credential.DisplayName, UserType: credential.UserType, ExpiresAt: expiresAt}
|
||||
return PortalLoginResponse{AccessToken: rawToken, ExpiresAt: expiresAt, Session: session}, nil
|
||||
}
|
||||
|
||||
func (s *PortalService) Authenticate(ctx context.Context, rawToken string) (PortalSession, error) {
|
||||
if len(rawToken) != 64 {
|
||||
return PortalSession{}, ErrUnauthorized
|
||||
}
|
||||
hash := sha256.Sum256([]byte(rawToken))
|
||||
var session PortalSession
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(u.id,p.id),COALESCE(u.username,p.username),COALESCE(u.display_name,p.display_name),
|
||||
CASE WHEN p.id IS NOT NULL THEN 'admin' ELSE 'partner' END,se.expires_at
|
||||
FROM vehicle_open_user_session se
|
||||
LEFT JOIN vehicle_open_user u ON u.id=se.user_id
|
||||
LEFT JOIN platform_user p ON p.id=se.platform_user_id
|
||||
WHERE se.token_hash=?
|
||||
AND se.revoked_at IS NULL
|
||||
AND se.expires_at>NOW(3)
|
||||
AND ((u.id IS NOT NULL AND u.status='enabled' AND u.valid_from<=NOW(3)
|
||||
AND (u.valid_to IS NULL OR u.valid_to>NOW(3)))
|
||||
OR (p.id IS NOT NULL AND p.user_type='admin' AND p.status='enabled'
|
||||
AND p.auth_provider='local'))`, hash[:]).
|
||||
Scan(&session.UserID, &session.Username, &session.DisplayName, &session.UserType, &session.ExpiresAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PortalSession{}, ErrUnauthorized
|
||||
}
|
||||
if err != nil {
|
||||
return PortalSession{}, err
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET last_seen_at=NOW(3) WHERE token_hash=?`, hash[:])
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (s *PortalService) Logout(ctx context.Context, rawToken string) {
|
||||
hash := sha256.Sum256([]byte(rawToken))
|
||||
_, _ = s.db.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE token_hash=? AND revoked_at IS NULL`, hash[:])
|
||||
}
|
||||
|
||||
func (s *PortalService) ChangePassword(ctx context.Context, session PortalSession, currentPassword, newPassword string) error {
|
||||
if session.UserType == "admin" {
|
||||
return fmt.Errorf("%w: platform administrators must change passwords on the internal platform", ErrInvalidRequest)
|
||||
}
|
||||
if err := validatePortalPassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
var currentHash string
|
||||
table := credentialTable(session.UserType)
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT password_hash FROM `+table+` WHERE id=? AND status='enabled'`, session.UserID).Scan(¤tHash); err != nil {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(currentPassword)) != nil {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(newPassword)) == nil {
|
||||
return fmt.Errorf("%w: password unchanged", ErrInvalidRequest)
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), 12)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET password_hash=?,password_changed_at=NOW(3),updated_by=? WHERE id=?`, string(hash), session.Username, session.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
identityColumn := "user_id"
|
||||
if session.UserType == "admin" {
|
||||
identityColumn = "platform_user_id"
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE platform_user_session SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, session.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE vehicle_open_user_session SET revoked_at=NOW(3) WHERE `+identityColumn+`=? AND revoked_at IS NULL`, session.UserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
auditUserID := session.UserID
|
||||
if session.UserType == "admin" {
|
||||
auditUserID = 0
|
||||
}
|
||||
_ = s.userAudit(ctx, auditUserID, session.Username, "password.change", "success", map[string]any{"userType": session.UserType}, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PortalService) Apps(ctx context.Context, session PortalSession) ([]PortalUserApp, error) {
|
||||
if session.UserType == "admin" {
|
||||
return s.adminApps(ctx)
|
||||
}
|
||||
return s.portalApps(ctx, session.UserID)
|
||||
}
|
||||
|
||||
func (s *PortalService) Vehicles(ctx context.Context, session PortalSession, appID uint64) ([]VehicleGrant, error) {
|
||||
if _, err := s.MembershipRole(ctx, session, appID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT g.vin,COALESCE(MAX(b.plate),''),g.valid_from,g.valid_to,g.granted_by,g.updated_at
|
||||
FROM vehicle_open_app_vehicle g
|
||||
LEFT JOIN vehicle_identity_binding b ON BINARY b.vin=BINARY g.vin
|
||||
WHERE g.app_id=?
|
||||
GROUP BY g.app_id,g.vin,g.valid_from,g.valid_to,g.granted_by,g.updated_at
|
||||
ORDER BY g.vin`, appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
grants := make([]VehicleGrant, 0)
|
||||
for rows.Next() {
|
||||
var grant VehicleGrant
|
||||
var validTo sql.NullTime
|
||||
if err := rows.Scan(&grant.VIN, &grant.Plate, &grant.ValidFrom, &validTo, &grant.GrantedBy, &grant.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validTo.Valid {
|
||||
grant.ValidTo = &validTo.Time
|
||||
}
|
||||
grants = append(grants, grant)
|
||||
}
|
||||
return grants, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) VehicleCatalog(ctx context.Context) ([]VehicleCatalogItem, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT b.vin,
|
||||
COALESCE(MAX(NULLIF(b.plate,'')),''),
|
||||
COALESCE(MAX(NULLIF(b.oem,'')),'')
|
||||
FROM vehicle_identity_binding b
|
||||
WHERE b.vin IS NOT NULL AND b.vin<>''
|
||||
GROUP BY b.vin
|
||||
ORDER BY COALESCE(MAX(NULLIF(b.plate,'')),''),b.vin`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]VehicleCatalogItem, 0)
|
||||
for rows.Next() {
|
||||
var item VehicleCatalogItem
|
||||
if err := rows.Scan(&item.VIN, &item.Plate, &item.OEM); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Status = "available"
|
||||
item.Source = "车辆主数据"
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) Audit(ctx context.Context, session PortalSession, appID uint64) ([]PortalAuditItem, error) {
|
||||
if _, err := s.MembershipRole(ctx, session, appID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT trace_id,endpoint,result,requested_vehicle_count,created_at
|
||||
FROM vehicle_open_api_audit WHERE app_id=? ORDER BY id DESC LIMIT 100`, appID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]PortalAuditItem, 0)
|
||||
for rows.Next() {
|
||||
var item PortalAuditItem
|
||||
if err := rows.Scan(&item.TraceID, &item.Endpoint, &item.Result, &item.VehicleCount, &item.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) CanRotateKey(ctx context.Context, session PortalSession, appID uint64) error {
|
||||
role, err := s.MembershipRole(ctx, session, appID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if role != "owner" {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PortalService) user(ctx context.Context, id uint64) (PortalUser, error) {
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT id,username,display_name,status,valid_from,valid_to,last_login_at,created_by,created_at,updated_at
|
||||
FROM vehicle_open_user WHERE id=?`, id)
|
||||
user, err := scanPortalUser(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return PortalUser{}, ErrNotFound
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (s *PortalService) credential(ctx context.Context, username string) (portalCredential, error) {
|
||||
var credential portalCredential
|
||||
var validTo, lastLogin, locked sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT id,username,display_name,password_hash,status,valid_from,valid_to,failed_login_count,locked_until,last_login_at,created_by,created_at,updated_at
|
||||
FROM vehicle_open_user WHERE username=?`, username).Scan(
|
||||
&credential.ID, &credential.Username, &credential.DisplayName, &credential.PasswordHash,
|
||||
&credential.Status, &credential.ValidFrom, &validTo, &credential.FailedLoginCount,
|
||||
&locked, &lastLogin, &credential.CreatedBy, &credential.CreatedAt, &credential.UpdatedAt,
|
||||
)
|
||||
if validTo.Valid {
|
||||
credential.ValidTo = &validTo.Time
|
||||
}
|
||||
if lastLogin.Valid {
|
||||
credential.LastLoginAt = &lastLogin.Time
|
||||
}
|
||||
credential.LockedUntil = locked
|
||||
credential.UserType = "partner"
|
||||
return credential, err
|
||||
}
|
||||
|
||||
func (s *PortalService) loginCredential(ctx context.Context, username string) (portalCredential, error) {
|
||||
credential, err := s.credential(ctx, username)
|
||||
if err == nil || !errors.Is(err, sql.ErrNoRows) {
|
||||
return credential, err
|
||||
}
|
||||
var admin portalCredential
|
||||
var lastLogin, locked sql.NullTime
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
SELECT id,username,display_name,password_hash,status,failed_login_count,locked_until,last_login_at,created_by,created_at,updated_at
|
||||
FROM platform_user
|
||||
WHERE username=? AND user_type='admin' AND auth_provider='local'`, username).Scan(
|
||||
&admin.ID, &admin.Username, &admin.DisplayName, &admin.PasswordHash,
|
||||
&admin.Status, &admin.FailedLoginCount, &locked, &lastLogin,
|
||||
&admin.CreatedBy, &admin.CreatedAt, &admin.UpdatedAt,
|
||||
)
|
||||
if lastLogin.Valid {
|
||||
admin.LastLoginAt = &lastLogin.Time
|
||||
}
|
||||
admin.LockedUntil = locked
|
||||
admin.UserType = "admin"
|
||||
return admin, err
|
||||
}
|
||||
|
||||
func (s *PortalService) platformAdminUsername(ctx context.Context, username string) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*) FROM platform_user
|
||||
WHERE username=? AND user_type='admin' AND auth_provider='local'`, username).Scan(&count)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (s *PortalService) portalApps(ctx context.Context, userID uint64) ([]PortalUserApp, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT a.id,a.name,a.app_key_prefix,a.status,ua.role,a.valid_from,a.valid_to
|
||||
FROM vehicle_open_user_app ua
|
||||
JOIN vehicle_open_app a ON a.id=ua.app_id
|
||||
WHERE ua.user_id=? ORDER BY a.id DESC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
apps := make([]PortalUserApp, 0)
|
||||
for rows.Next() {
|
||||
var app PortalUserApp
|
||||
var validTo sql.NullTime
|
||||
if err := rows.Scan(&app.AppID, &app.AppName, &app.AppKeyPrefix, &app.AppStatus, &app.Role, &app.ValidFrom, &validTo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validTo.Valid {
|
||||
app.ValidTo = &validTo.Time
|
||||
}
|
||||
apps = append(apps, app)
|
||||
}
|
||||
return apps, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) adminApps(ctx context.Context) ([]PortalUserApp, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id,name,app_key_prefix,status,'owner',valid_from,valid_to
|
||||
FROM vehicle_open_app ORDER BY id DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
apps := make([]PortalUserApp, 0)
|
||||
for rows.Next() {
|
||||
var app PortalUserApp
|
||||
var validTo sql.NullTime
|
||||
if err := rows.Scan(&app.AppID, &app.AppName, &app.AppKeyPrefix, &app.AppStatus, &app.Role, &app.ValidFrom, &validTo); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validTo.Valid {
|
||||
app.ValidTo = &validTo.Time
|
||||
}
|
||||
apps = append(apps, app)
|
||||
}
|
||||
return apps, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PortalService) membershipRole(ctx context.Context, userID, appID uint64) (string, error) {
|
||||
var role string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT role FROM vehicle_open_user_app WHERE user_id=? AND app_id=?`, userID, appID).Scan(&role)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", ErrForbidden
|
||||
}
|
||||
return role, err
|
||||
}
|
||||
|
||||
func (s *PortalService) MembershipRole(ctx context.Context, session PortalSession, appID uint64) (string, error) {
|
||||
if session.UserType == "admin" {
|
||||
var exists int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM vehicle_open_app WHERE id=?`, appID).Scan(&exists); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists == 0 {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "owner", nil
|
||||
}
|
||||
return s.membershipRole(ctx, session.UserID, appID)
|
||||
}
|
||||
|
||||
func (s *PortalService) userAudit(ctx context.Context, userID uint64, actor, action, result string, detail any, remoteAddr string) error {
|
||||
var nullableUser any
|
||||
if userID > 0 {
|
||||
nullableUser = userID
|
||||
}
|
||||
var encoded any
|
||||
if detail != nil {
|
||||
value, _ := json.Marshal(detail)
|
||||
encoded = string(value)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_user_audit(user_id,actor,action,result,detail_json,remote_addr)
|
||||
VALUES(?,?,?,?,?,?)`, nullableUser, truncate(actor, 96), action, result, encoded, truncate(remoteAddr, 96))
|
||||
return err
|
||||
}
|
||||
|
||||
func scanPortalUser(row scanner) (PortalUser, error) {
|
||||
var user PortalUser
|
||||
var validTo, lastLogin sql.NullTime
|
||||
err := row.Scan(&user.ID, &user.Username, &user.DisplayName, &user.Status, &user.ValidFrom, &validTo, &lastLogin, &user.CreatedBy, &user.CreatedAt, &user.UpdatedAt)
|
||||
if validTo.Valid {
|
||||
user.ValidTo = &validTo.Time
|
||||
}
|
||||
if lastLogin.Valid {
|
||||
user.LastLoginAt = &lastLogin.Time
|
||||
}
|
||||
return user, err
|
||||
}
|
||||
|
||||
func validatePortalUserInput(input *PortalUserInput, passwordRequired bool) (time.Time, *time.Time, error) {
|
||||
input.Username = strings.TrimSpace(input.Username)
|
||||
input.DisplayName = strings.TrimSpace(input.DisplayName)
|
||||
input.Status = strings.ToLower(strings.TrimSpace(input.Status))
|
||||
if input.Status == "" {
|
||||
input.Status = "enabled"
|
||||
}
|
||||
if !portalUsernamePattern.MatchString(input.Username) || input.DisplayName == "" || len([]rune(input.DisplayName)) > 96 {
|
||||
return time.Time{}, nil, fmt.Errorf("%w: invalid portal user", ErrInvalidRequest)
|
||||
}
|
||||
if input.Status != "enabled" && input.Status != "disabled" {
|
||||
return time.Time{}, nil, fmt.Errorf("%w: invalid user status", ErrInvalidRequest)
|
||||
}
|
||||
if passwordRequired || input.Password != "" {
|
||||
if err := validatePortalPassword(input.Password); err != nil {
|
||||
return time.Time{}, nil, err
|
||||
}
|
||||
}
|
||||
return parseInterval(input.ValidFrom, input.ValidTo)
|
||||
}
|
||||
|
||||
func validatePortalPassword(password string) error {
|
||||
if len(password) < 12 || len(password) > 128 {
|
||||
return fmt.Errorf("%w: password must be 12-128 characters", ErrInvalidRequest)
|
||||
}
|
||||
var lower, upper, digit bool
|
||||
for _, value := range password {
|
||||
switch {
|
||||
case value >= 'a' && value <= 'z':
|
||||
lower = true
|
||||
case value >= 'A' && value <= 'Z':
|
||||
upper = true
|
||||
case value >= '0' && value <= '9':
|
||||
digit = true
|
||||
}
|
||||
}
|
||||
if !lower || !upper || !digit {
|
||||
return fmt.Errorf("%w: password requires upper, lower and digit", ErrInvalidRequest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validPortalRole(role string) bool {
|
||||
return role == "owner" || role == "developer" || role == "viewer"
|
||||
}
|
||||
|
||||
func credentialTable(userType string) string {
|
||||
if userType == "admin" {
|
||||
return "platform_user"
|
||||
}
|
||||
return "vehicle_open_user"
|
||||
}
|
||||
|
||||
func auditPortalUserID(credential portalCredential) uint64 {
|
||||
if credential.UserType == "admin" {
|
||||
return 0
|
||||
}
|
||||
return credential.ID
|
||||
}
|
||||
|
||||
func newPortalSessionToken() (string, [sha256.Size]byte, error) {
|
||||
raw, err := randomHexBytes(32)
|
||||
if err != nil {
|
||||
return "", [sha256.Size]byte{}, err
|
||||
}
|
||||
return raw, sha256.Sum256([]byte(raw)), nil
|
||||
}
|
||||
|
||||
func randomHexBytes(size int) (string, error) {
|
||||
value := make([]byte, size)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(value), nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestPortalLoginAcceptsEnabledInternalPlatformAdmin(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := time.Date(2026, 7, 20, 16, 0, 0, 0, time.Local)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("AdminPass2026"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mock.ExpectQuery(regexp.QuoteMeta("FROM vehicle_open_user WHERE username=?")).
|
||||
WithArgs("admin").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
mock.ExpectQuery("FROM platform_user").
|
||||
WithArgs("admin").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "username", "display_name", "password_hash", "status",
|
||||
"failed_login_count", "locked_until", "last_login_at",
|
||||
"created_by", "created_at", "updated_at",
|
||||
}).AddRow(7, "admin", "平台管理员", string(hash), "enabled", 0, nil, nil, "bootstrap", now, now))
|
||||
mock.ExpectExec("INSERT INTO vehicle_open_user_session").
|
||||
WithArgs(sqlmock.AnyArg(), nil, uint64(7), sqlmock.AnyArg(), now, now.Add(time.Hour), now, "127.0.0.1", "test-agent").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectExec("UPDATE platform_user SET failed_login_count=0").
|
||||
WithArgs(now, uint64(7)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO vehicle_open_user_audit").
|
||||
WithArgs(nil, "admin", "login", "success", sqlmock.AnyArg(), "127.0.0.1").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
|
||||
service := NewPortalService(db, time.Hour)
|
||||
service.now = func() time.Time { return now }
|
||||
result, err := service.Login(context.Background(), PortalLoginRequest{
|
||||
Username: "admin",
|
||||
Password: "AdminPass2026",
|
||||
}, "127.0.0.1", "test-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("admin login failed: %v", err)
|
||||
}
|
||||
if result.Session.UserType != "admin" || result.Session.Username != "admin" || len(result.AccessToken) != 64 {
|
||||
t.Fatalf("unexpected admin session: %+v", result.Session)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortalAdminMembershipHasOwnerAccessToEveryApp(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery(regexp.QuoteMeta("SELECT COUNT(*) FROM vehicle_open_app WHERE id=?")).
|
||||
WithArgs(uint64(12)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
service := NewPortalService(db, time.Hour)
|
||||
role, err := service.MembershipRole(context.Background(), PortalSession{UserID: 7, UserType: "admin"}, 12)
|
||||
if err != nil || role != "owner" {
|
||||
t.Fatalf("role=%q err=%v", role, err)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortalVehicleCatalogListsBoundVehicles(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
mock.ExpectQuery("SELECT b.vin,").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"vin", "plate", "oem"}).
|
||||
AddRow("LNB00000000000001", "辽A00001", "羚牛").
|
||||
AddRow("LNB00000000000002", "", "羚牛"))
|
||||
|
||||
service := NewPortalService(db, time.Hour)
|
||||
items, err := service.VehicleCatalog(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 2 || items[0].Plate != "辽A00001" || items[1].Source != "车辆主数据" {
|
||||
t.Fatalf("unexpected vehicle catalog: %+v", items)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
732
vehicle-data-platform/apps/api/internal/openplatform/service.go
Normal file
732
vehicle-data-platform/apps/api/internal/openplatform/service.go
Normal file
@@ -0,0 +1,732 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"lingniu/vehicle-data-platform/apps/api/internal/vehicleprotocol"
|
||||
)
|
||||
|
||||
var appKeyPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
|
||||
|
||||
var (
|
||||
ErrUnauthorized = errors.New("open platform appKey unauthorized")
|
||||
ErrForbidden = errors.New("open platform vehicle forbidden")
|
||||
ErrInvalidRequest = errors.New("open platform invalid request")
|
||||
ErrNotFound = errors.New("open platform resource not found")
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error)
|
||||
AuthorizedVehicles(context.Context, uint64, []string, time.Time, time.Time) (map[string]AuthorizedVehicle, error)
|
||||
DailyHydrogen(context.Context, []string, string) (map[string]DailyHydrogen, error)
|
||||
DailyMileage(context.Context, []string, string, []string) (map[string]DailyMileage, error)
|
||||
DailyMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error)
|
||||
LatestMileageBefore(context.Context, []string, string, []string) (map[string]DailyMileage, error)
|
||||
CreateMileageSnapshot(context.Context, MileageSnapshot) error
|
||||
LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error)
|
||||
AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error)
|
||||
TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error)
|
||||
Audit(context.Context, uint64, string, string, string, int, string) error
|
||||
|
||||
CreateApp(context.Context, AppInput, [sha256.Size]byte, string, time.Time, *time.Time, string) (App, error)
|
||||
ListApps(context.Context) ([]App, error)
|
||||
UpdateApp(context.Context, uint64, AppInput, time.Time, *time.Time, string) (App, error)
|
||||
RotateKey(context.Context, uint64, [sha256.Size]byte, string, string) (App, error)
|
||||
ReplaceVehicleGrants(context.Context, uint64, []parsedGrant, string) ([]VehicleGrant, error)
|
||||
ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
now func() time.Time
|
||||
location *time.Location
|
||||
}
|
||||
|
||||
type parsedGrant struct {
|
||||
VIN string
|
||||
ValidFrom time.Time
|
||||
ValidTo *time.Time
|
||||
}
|
||||
|
||||
func NewService(repository Repository) *Service {
|
||||
return &Service{
|
||||
repository: repository,
|
||||
now: time.Now,
|
||||
location: time.FixedZone("Asia/Shanghai", 8*60*60),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, request QueryRequest) ([]HydrogenResult, error) {
|
||||
plates, date, start, end, err := s.validateQuery(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app, vehicles, err := s.authorize(ctx, appKey, plates, start, end)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "denied", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(vehicles)
|
||||
}
|
||||
vins := vehicleVINs(vehicles)
|
||||
values, err := s.repository.DailyHydrogen(ctx, vins, date)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
results := make([]HydrogenResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := HydrogenResult{PlateNumber: plate, Date: date, Status: StatusNoData}
|
||||
// Sampling sufficiency is decided by the producer and persisted in
|
||||
// quality_status. Imported refuelling-ledger rows can be authoritative
|
||||
// with one transaction, while pressure-derived rows require two samples.
|
||||
if value, ok := values[vehicle.VIN]; ok && strings.EqualFold(value.QualityStatus, "OK") {
|
||||
consumption := round3(value.ConsumptionKg)
|
||||
item.HydrogenConsumptionKg = &consumption
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, request QueryRequest) ([]MileageResult, error) {
|
||||
plates, date, start, end, err := s.validateQuery(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
protocols, err := normalizeProtocolPriority(request.ProtocolPriority)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app, vehicles, err := s.authorize(ctx, appKey, plates, start, end)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "denied", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(vehicles)
|
||||
}
|
||||
vins := vehicleVINs(vehicles)
|
||||
values, err := s.repository.DailyMileage(ctx, vins, date, protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
missingVINs := missingMileageVINs(vins, values)
|
||||
carried := map[string]DailyMileage{}
|
||||
if len(missingVINs) > 0 {
|
||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
results := make([]MileageResult, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
vehicle := vehicles[plate]
|
||||
item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
|
||||
if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
fillMileageResult(&item, value, value.MileageKm)
|
||||
} else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
fillMileageResult(&item, value, 0)
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, request MileageRangeRequest) (MileageRangeResponse, error) {
|
||||
request.Cursor = strings.TrimSpace(request.Cursor)
|
||||
plates, startDate, endDate, start, end, pageSize, protocols, err := s.validateMileageRange(request)
|
||||
if err != nil {
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
hash := mileageRangeRequestHash(startDate, endDate, plates, pageSize, protocols)
|
||||
var (
|
||||
app AppCredential
|
||||
snapshot MileageSnapshot
|
||||
offset int
|
||||
)
|
||||
if request.Cursor == "" {
|
||||
vehicles := map[string]AuthorizedVehicle{}
|
||||
app, vehicles, err = s.authorize(ctx, appKey, plates, start, end)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "denied", traceID, len(plates), err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
snapshotID, idErr := newSnapshotID()
|
||||
if idErr != nil {
|
||||
return MileageRangeResponse{}, idErr
|
||||
}
|
||||
snapshot = MileageSnapshot{
|
||||
ID: snapshotID,
|
||||
AppID: app.ID,
|
||||
RequestHash: hash[:],
|
||||
StartDate: startDate,
|
||||
EndDate: endDate,
|
||||
ExpiresAt: s.now().Add(2 * time.Hour),
|
||||
Vehicles: orderedVehicles(vehicles),
|
||||
}
|
||||
snapshot.VehicleCount = len(snapshot.Vehicles)
|
||||
if err = s.repository.CreateMileageSnapshot(ctx, snapshot); err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, len(snapshot.Vehicles), err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
} else {
|
||||
snapshotID, parsedOffset, cursorErr := parseMileageCursor(request.Cursor)
|
||||
if cursorErr != nil {
|
||||
return MileageRangeResponse{}, cursorErr
|
||||
}
|
||||
offset = parsedOffset
|
||||
if !appKeyPattern.MatchString(appKey) {
|
||||
return MileageRangeResponse{}, ErrUnauthorized
|
||||
}
|
||||
app, err = s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), s.now(), start, end)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, 0, "mileage_range_query", "denied", traceID, len(plates), ErrUnauthorized.Error())
|
||||
return MileageRangeResponse{}, ErrUnauthorized
|
||||
}
|
||||
snapshot, err = s.repository.LoadMileageSnapshot(ctx, snapshotID, app.ID, s.now())
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "denied", traceID, len(plates), err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
if !bytes.Equal(snapshot.RequestHash, hash[:]) || snapshot.StartDate != startDate || snapshot.EndDate != endDate {
|
||||
return MileageRangeResponse{}, fmt.Errorf("%w: cursor does not match request", ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
|
||||
dayCount := int(end.Sub(start).Hours() / 24)
|
||||
total := dayCount * snapshot.VehicleCount
|
||||
if offset < 0 || offset > total {
|
||||
return MileageRangeResponse{}, fmt.Errorf("%w: cursor offset out of range", ErrInvalidRequest)
|
||||
}
|
||||
pageEnd := offset + pageSize
|
||||
if pageEnd > total {
|
||||
pageEnd = total
|
||||
}
|
||||
positions := make([]mileageRangePosition, 0, pageEnd-offset)
|
||||
vinSet := make(map[string]struct{})
|
||||
var queryStart, queryEnd time.Time
|
||||
for index := offset; index < pageEnd; index++ {
|
||||
dayOffset := index / snapshot.VehicleCount
|
||||
vehicle := snapshot.Vehicles[index%snapshot.VehicleCount]
|
||||
date := start.AddDate(0, 0, dayOffset)
|
||||
if len(positions) == 0 {
|
||||
queryStart = date
|
||||
}
|
||||
queryEnd = date
|
||||
positions = append(positions, mileageRangePosition{vehicle: vehicle, date: date.Format("2006-01-02")})
|
||||
vinSet[vehicle.VIN] = struct{}{}
|
||||
}
|
||||
values := map[string]DailyMileage{}
|
||||
carried := map[string]DailyMileage{}
|
||||
if len(positions) > 0 {
|
||||
vins := make([]string, 0, len(vinSet))
|
||||
for vin := range vinSet {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
values, err = s.repository.DailyMileageRange(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
missingVINs := missingMileageRangeInitialVINs(positions, values)
|
||||
if len(missingVINs) > 0 {
|
||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
if carried == nil {
|
||||
carried = map[string]DailyMileage{}
|
||||
}
|
||||
}
|
||||
}
|
||||
results := make([]MileageRangeResult, 0, len(positions))
|
||||
for _, position := range positions {
|
||||
item := MileageRangeResult{
|
||||
VIN: position.vehicle.VIN,
|
||||
PlateNumber: position.vehicle.Plate,
|
||||
Date: position.date,
|
||||
Status: StatusNoData,
|
||||
}
|
||||
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) {
|
||||
fillMileageRangeResult(&item, value, value.MileageKm)
|
||||
carried[position.vehicle.VIN] = value
|
||||
} else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
fillMileageRangeResult(&item, value, 0)
|
||||
}
|
||||
results = append(results, item)
|
||||
}
|
||||
response := MileageRangeResponse{
|
||||
Code: "SUCCESS",
|
||||
Message: "success",
|
||||
Data: results,
|
||||
SnapshotID: snapshot.ID,
|
||||
TraceID: traceID,
|
||||
}
|
||||
if pageEnd < total {
|
||||
cursor := mileageCursor(snapshot.ID, pageEnd)
|
||||
response.NextCursor = &cursor
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "success", traceID, snapshot.VehicleCount, "")
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type mileageRangePosition struct {
|
||||
vehicle AuthorizedVehicle
|
||||
date string
|
||||
}
|
||||
|
||||
func (s *Service) QueryTotalMileage(ctx context.Context, appKey, traceID string, request TotalMileageQueryRequest) (TotalMileageResult, error) {
|
||||
vin, queryTime, protocols, protocolInput, err := s.validateTotalMileageQuery(request)
|
||||
if err != nil {
|
||||
return TotalMileageResult{}, err
|
||||
}
|
||||
result := TotalMileageResult{VIN: vin, QueryTime: queryTime.Format("2006-01-02 15:04:05"), ProtocolInput: protocolInput, SelectionPolicy: strings.Join(vehicleprotocol.MileagePriority(), " > "), Status: StatusNoData}
|
||||
if !appKeyPattern.MatchString(appKey) {
|
||||
return TotalMileageResult{}, ErrUnauthorized
|
||||
}
|
||||
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), s.now(), queryTime, queryTime)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, 0, "total_mileage_query", "denied", traceID, 1, ErrUnauthorized.Error())
|
||||
return TotalMileageResult{}, ErrUnauthorized
|
||||
}
|
||||
authorized, err := s.repository.AuthorizedVIN(ctx, app.ID, vin, queryTime)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "error", traceID, 1, err.Error())
|
||||
return TotalMileageResult{}, err
|
||||
}
|
||||
if !authorized {
|
||||
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "denied", traceID, 1, ErrForbidden.Error())
|
||||
return TotalMileageResult{}, ErrForbidden
|
||||
}
|
||||
point, err := s.repository.TotalMileage(ctx, vin, queryTime, protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "error", traceID, 1, err.Error())
|
||||
return TotalMileageResult{}, err
|
||||
}
|
||||
if point != nil {
|
||||
value := round3(point.TotalMileageKm)
|
||||
result.TotalMileageKm = &value
|
||||
result.Protocol = point.Protocol
|
||||
result.MileageMeaning = totalMileageMeaning(point.Protocol)
|
||||
result.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
||||
difference := int64(queryTime.Sub(point.ObservedAt.In(s.location)).Seconds())
|
||||
result.TimeDifferenceSeconds = &difference
|
||||
result.Status = StatusNormal
|
||||
}
|
||||
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "success", traceID, 1, "")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateTotalMileageQuery(request TotalMileageQueryRequest) (string, time.Time, []string, string, error) {
|
||||
vin := strings.ToUpper(strings.TrimSpace(request.VIN))
|
||||
if !regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`).MatchString(vin) {
|
||||
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid vin", ErrInvalidRequest)
|
||||
}
|
||||
queryTime, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.Time), s.location)
|
||||
if err != nil {
|
||||
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid datetime", ErrInvalidRequest)
|
||||
}
|
||||
input := strings.TrimSpace(request.Protocol)
|
||||
if input == "" {
|
||||
return vin, queryTime, vehicleprotocol.MileagePriority(), "", nil
|
||||
}
|
||||
canonical, ok := vehicleprotocol.Canonical(input)
|
||||
if !ok {
|
||||
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid protocol", ErrInvalidRequest)
|
||||
}
|
||||
return vin, queryTime, []string{canonical}, input, nil
|
||||
}
|
||||
|
||||
func totalMileageMeaning(protocol string) string {
|
||||
switch protocol {
|
||||
case vehicleprotocol.GB32960:
|
||||
return "车辆仪表盘累计总里程(GB/T 32960整车数据累计里程)"
|
||||
case vehicleprotocol.YutongMQTT:
|
||||
return "车辆仪表盘或车端控制器累计总里程(MQTT平台上报)"
|
||||
case vehicleprotocol.JT808:
|
||||
return "定位终端累计里程(GPS/终端侧计算,非车辆仪表盘里程)"
|
||||
default:
|
||||
return "车辆累计总里程"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) authorize(ctx context.Context, rawKey string, plates []string, start, end time.Time) (AppCredential, map[string]AuthorizedVehicle, error) {
|
||||
if !appKeyPattern.MatchString(rawKey) {
|
||||
return AppCredential{}, nil, ErrUnauthorized
|
||||
}
|
||||
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(rawKey))), s.now(), start, end)
|
||||
if err != nil {
|
||||
return AppCredential{}, nil, ErrUnauthorized
|
||||
}
|
||||
vehicles, err := s.repository.AuthorizedVehicles(ctx, app.ID, plates, start, end)
|
||||
if err != nil {
|
||||
return app, nil, err
|
||||
}
|
||||
if len(plates) > 0 && len(vehicles) != len(plates) {
|
||||
return app, nil, ErrForbidden
|
||||
}
|
||||
return app, vehicles, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateQuery(request QueryRequest) ([]string, string, time.Time, time.Time, error) {
|
||||
plates, err := normalizePlates(request.PlateNumbers, 200)
|
||||
if err != nil {
|
||||
return nil, "", time.Time{}, time.Time{}, err
|
||||
}
|
||||
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.Date), s.location)
|
||||
if err != nil {
|
||||
return nil, "", time.Time{}, time.Time{}, fmt.Errorf("%w: invalid date", ErrInvalidRequest)
|
||||
}
|
||||
return plates, start.Format("2006-01-02"), start, start.AddDate(0, 0, 1), nil
|
||||
}
|
||||
|
||||
func (s *Service) validateMileageRange(request MileageRangeRequest) ([]string, string, string, time.Time, time.Time, int, []string, error) {
|
||||
plates, err := normalizePlates(request.PlateNumbers, 5000)
|
||||
if err != nil {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, err
|
||||
}
|
||||
protocols, err := normalizeProtocolPriority(request.ProtocolPriority)
|
||||
if err != nil {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, err
|
||||
}
|
||||
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.StartDate), s.location)
|
||||
if err != nil {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: invalid startDate", ErrInvalidRequest)
|
||||
}
|
||||
endInclusive, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.EndDate), s.location)
|
||||
if err != nil {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: invalid endDate", ErrInvalidRequest)
|
||||
}
|
||||
if endInclusive.Before(start) {
|
||||
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)
|
||||
}
|
||||
pageSize := request.PageSize
|
||||
if pageSize == 0 {
|
||||
pageSize = 5000
|
||||
}
|
||||
if pageSize < 1 || pageSize > 5000 {
|
||||
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: pageSize must be between 1 and 5000", ErrInvalidRequest)
|
||||
}
|
||||
return plates, start.Format("2006-01-02"), endInclusive.Format("2006-01-02"), start, end, pageSize, protocols, nil
|
||||
}
|
||||
|
||||
func normalizeProtocolPriority(input ProtocolPriority) ([]string, error) {
|
||||
if !input.Present && input.Values == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if len(input.Values) == 0 {
|
||||
return nil, fmt.Errorf("%w: protocolPriority must not be empty", ErrInvalidRequest)
|
||||
}
|
||||
protocols := make([]string, 0, len(input.Values))
|
||||
seen := make(map[string]bool, len(input.Values))
|
||||
for _, protocol := range input.Values {
|
||||
if seen[protocol] {
|
||||
return nil, fmt.Errorf("%w: protocolPriority contains duplicate protocol", ErrInvalidRequest)
|
||||
}
|
||||
seen[protocol] = true
|
||||
switch protocol {
|
||||
case "GB32960", "JT808":
|
||||
protocols = append(protocols, protocol)
|
||||
case "MQTT":
|
||||
protocols = append(protocols, "YUTONG_MQTT")
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: protocolPriority contains unsupported protocol", ErrInvalidRequest)
|
||||
}
|
||||
}
|
||||
return protocols, nil
|
||||
}
|
||||
|
||||
func externalMileageProtocol(protocol string) string {
|
||||
if protocol == "YUTONG_MQTT" {
|
||||
return "MQTT"
|
||||
}
|
||||
return protocol
|
||||
}
|
||||
|
||||
func normalizePlates(input []string, maximum int) ([]string, error) {
|
||||
if len(input) > maximum {
|
||||
return nil, fmt.Errorf("%w: plate numbers exceed %d", ErrInvalidRequest, maximum)
|
||||
}
|
||||
plates := make([]string, 0, len(input))
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range input {
|
||||
plate := strings.ToUpper(strings.TrimSpace(raw))
|
||||
if plate == "" || len([]rune(plate)) > 32 {
|
||||
return nil, fmt.Errorf("%w: invalid plate", ErrInvalidRequest)
|
||||
}
|
||||
if seen[plate] {
|
||||
return nil, fmt.Errorf("%w: duplicate plate", ErrInvalidRequest)
|
||||
}
|
||||
seen[plate] = true
|
||||
plates = append(plates, plate)
|
||||
}
|
||||
return plates, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateApp(ctx context.Context, input AppInput, actor string) (AppCreated, error) {
|
||||
from, to, err := s.validateAppInput(&input)
|
||||
if err != nil {
|
||||
return AppCreated{}, err
|
||||
}
|
||||
key, hash, prefix, err := newAppKey()
|
||||
if err != nil {
|
||||
return AppCreated{}, err
|
||||
}
|
||||
app, err := s.repository.CreateApp(ctx, input, hash, prefix, from, to, actor)
|
||||
if err != nil {
|
||||
return AppCreated{}, err
|
||||
}
|
||||
return AppCreated{App: app, AppKey: key}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListApps(ctx context.Context) ([]App, error) {
|
||||
return s.repository.ListApps(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) UpdateApp(ctx context.Context, id uint64, input AppInput, actor string) (App, error) {
|
||||
from, to, err := s.validateAppInput(&input)
|
||||
if err != nil {
|
||||
return App{}, err
|
||||
}
|
||||
return s.repository.UpdateApp(ctx, id, input, from, to, actor)
|
||||
}
|
||||
|
||||
func (s *Service) RotateKey(ctx context.Context, id uint64, actor string) (AppCreated, error) {
|
||||
key, hash, prefix, err := newAppKey()
|
||||
if err != nil {
|
||||
return AppCreated{}, err
|
||||
}
|
||||
app, err := s.repository.RotateKey(ctx, id, hash, prefix, actor)
|
||||
if err != nil {
|
||||
return AppCreated{}, err
|
||||
}
|
||||
return AppCreated{App: app, AppKey: key}, nil
|
||||
}
|
||||
|
||||
func (s *Service) ReplaceVehicleGrants(ctx context.Context, id uint64, request VehicleGrantRequest, actor string) ([]VehicleGrant, error) {
|
||||
if len(request.Vehicles) > 5000 {
|
||||
return nil, fmt.Errorf("%w: grants exceed 5000", ErrInvalidRequest)
|
||||
}
|
||||
grants := make([]parsedGrant, 0, len(request.Vehicles))
|
||||
seen := map[string]bool{}
|
||||
for _, input := range request.Vehicles {
|
||||
vin := strings.ToUpper(strings.TrimSpace(input.VIN))
|
||||
if len(vin) != 17 || seen[vin] {
|
||||
return nil, fmt.Errorf("%w: invalid or duplicate VIN", ErrInvalidRequest)
|
||||
}
|
||||
seen[vin] = true
|
||||
from, to, err := parseInterval(input.ValidFrom, input.ValidTo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grants = append(grants, parsedGrant{VIN: vin, ValidFrom: from, ValidTo: to})
|
||||
}
|
||||
return s.repository.ReplaceVehicleGrants(ctx, id, grants, actor)
|
||||
}
|
||||
|
||||
func (s *Service) ListVehicleGrants(ctx context.Context, id uint64) ([]VehicleGrant, error) {
|
||||
return s.repository.ListVehicleGrants(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) validateAppInput(input *AppInput) (time.Time, *time.Time, error) {
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
input.Status = strings.ToLower(strings.TrimSpace(input.Status))
|
||||
if input.Status == "" {
|
||||
input.Status = "enabled"
|
||||
}
|
||||
if input.Name == "" || len([]rune(input.Name)) > 96 || (input.Status != "enabled" && input.Status != "disabled") {
|
||||
return time.Time{}, nil, fmt.Errorf("%w: invalid app", ErrInvalidRequest)
|
||||
}
|
||||
return parseInterval(input.ValidFrom, input.ValidTo)
|
||||
}
|
||||
|
||||
func parseInterval(rawFrom, rawTo string) (time.Time, *time.Time, error) {
|
||||
from, err := time.Parse(time.RFC3339, strings.TrimSpace(rawFrom))
|
||||
if err != nil {
|
||||
return time.Time{}, nil, fmt.Errorf("%w: validFrom must be RFC3339", ErrInvalidRequest)
|
||||
}
|
||||
var to *time.Time
|
||||
if strings.TrimSpace(rawTo) != "" {
|
||||
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(rawTo))
|
||||
if err != nil || !parsed.After(from) {
|
||||
return time.Time{}, nil, fmt.Errorf("%w: invalid validTo", ErrInvalidRequest)
|
||||
}
|
||||
to = &parsed
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
func newAppKey() (string, [sha256.Size]byte, string, error) {
|
||||
var bytes [16]byte
|
||||
if _, err := rand.Read(bytes[:]); err != nil {
|
||||
return "", [sha256.Size]byte{}, "", err
|
||||
}
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
key := hex.EncodeToString(bytes[:])
|
||||
return key, sha256.Sum256([]byte(key)), key[:8], nil
|
||||
}
|
||||
|
||||
func vehicleVINs(vehicles map[string]AuthorizedVehicle) []string {
|
||||
seen := map[string]bool{}
|
||||
vins := make([]string, 0, len(vehicles))
|
||||
for _, vehicle := range vehicles {
|
||||
if !seen[vehicle.VIN] {
|
||||
seen[vehicle.VIN] = true
|
||||
vins = append(vins, vehicle.VIN)
|
||||
}
|
||||
}
|
||||
sort.Strings(vins)
|
||||
return vins
|
||||
}
|
||||
|
||||
func vehiclePlates(vehicles map[string]AuthorizedVehicle) []string {
|
||||
plates := make([]string, 0, len(vehicles))
|
||||
for plate := range vehicles {
|
||||
plates = append(plates, plate)
|
||||
}
|
||||
sort.Strings(plates)
|
||||
return plates
|
||||
}
|
||||
|
||||
func orderedVehicles(vehicles map[string]AuthorizedVehicle) []AuthorizedVehicle {
|
||||
plates := vehiclePlates(vehicles)
|
||||
out := make([]AuthorizedVehicle, 0, len(plates))
|
||||
for _, plate := range plates {
|
||||
out = append(out, vehicles[plate])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func validDailyMileage(value DailyMileage) bool {
|
||||
return value.MileageKm >= 0 &&
|
||||
value.TotalMileageKm >= 0 &&
|
||||
(value.Protocol == "GB32960" || value.Protocol == "YUTONG_MQTT" || value.Protocol == "JT808") &&
|
||||
value.DataTime != "" &&
|
||||
value.UpdatedAt != ""
|
||||
}
|
||||
|
||||
func missingMileageVINs(vins []string, values map[string]DailyMileage) []string {
|
||||
missing := make([]string, 0)
|
||||
for _, vin := range vins {
|
||||
if value, ok := values[vin]; !ok || !validDailyMileage(value) {
|
||||
missing = append(missing, vin)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage) []string {
|
||||
seen := make(map[string]struct{}, len(positions))
|
||||
missing := make([]string, 0)
|
||||
for _, position := range positions {
|
||||
vin := position.vehicle.VIN
|
||||
if _, ok := seen[vin]; ok {
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
value, ok := values[dailyMileageKey(vin, position.date)]
|
||||
if !ok || !validDailyMileage(value) {
|
||||
missing = append(missing, vin)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
return missing
|
||||
}
|
||||
|
||||
func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage float64) {
|
||||
item.DailyMileageKm = &dailyMileage
|
||||
totalMileage := value.TotalMileageKm
|
||||
item.TotalMileageKm = &totalMileage
|
||||
dataTime := value.DataTime
|
||||
updatedAt := value.UpdatedAt
|
||||
item.DataTime = &dataTime
|
||||
item.UpdatedAt = &updatedAt
|
||||
sourceProtocol := externalMileageProtocol(value.Protocol)
|
||||
item.SourceProtocol = &sourceProtocol
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
|
||||
func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyMileage float64) {
|
||||
item.DailyMileageKm = &dailyMileage
|
||||
totalMileage := value.TotalMileageKm
|
||||
item.TotalMileageKm = &totalMileage
|
||||
dataTime := value.DataTime
|
||||
updatedAt := value.UpdatedAt
|
||||
item.DataTime = &dataTime
|
||||
item.UpdatedAt = &updatedAt
|
||||
sourceProtocol := externalMileageProtocol(value.Protocol)
|
||||
item.SourceProtocol = &sourceProtocol
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
|
||||
func dailyMileageKey(vin, date string) string {
|
||||
return vin + "\x00" + date
|
||||
}
|
||||
|
||||
func mileageRangeRequestHash(startDate, endDate string, plates []string, pageSize int, protocols []string) [sha256.Size]byte {
|
||||
normalized := append([]string(nil), plates...)
|
||||
sort.Strings(normalized)
|
||||
return sha256.Sum256([]byte(startDate + "\x00" + endDate + "\x00" + strconv.Itoa(pageSize) + "\x00" + strings.Join(normalized, "\x00") + "\x01" + strings.Join(protocols, "\x00")))
|
||||
}
|
||||
|
||||
func newSnapshotID() (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func mileageCursor(snapshotID string, offset int) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(snapshotID + ":" + strconv.Itoa(offset)))
|
||||
}
|
||||
|
||||
func parseMileageCursor(cursor string) (string, int, error) {
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(cursor))
|
||||
if err != nil {
|
||||
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
|
||||
}
|
||||
parts := strings.Split(string(decoded), ":")
|
||||
if len(parts) != 2 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(parts[0]) {
|
||||
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
|
||||
}
|
||||
offset, err := strconv.Atoi(parts[1])
|
||||
if err != nil || offset < 0 {
|
||||
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
|
||||
}
|
||||
return parts[0], offset, nil
|
||||
}
|
||||
|
||||
func round3(value float64) float64 {
|
||||
if value >= 0 {
|
||||
return float64(int64(value*1000+0.5)) / 1000
|
||||
}
|
||||
return float64(int64(value*1000-0.5)) / 1000
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeRepository struct {
|
||||
app AppCredential
|
||||
authErr error
|
||||
vehicles map[string]AuthorizedVehicle
|
||||
hydrogen map[string]DailyHydrogen
|
||||
mileage map[string]DailyMileage
|
||||
priorMileage map[string]DailyMileage
|
||||
authorizedVIN bool
|
||||
totalMileage *TotalMileagePoint
|
||||
audits []string
|
||||
createdHash [sha256.Size]byte
|
||||
createdPrefix string
|
||||
requestedPlates []string
|
||||
dailyVINs []string
|
||||
dailyProtocols []string
|
||||
rangeVINs []string
|
||||
priorVINs []string
|
||||
priorCalls int
|
||||
snapshot MileageSnapshot
|
||||
}
|
||||
|
||||
func (f *fakeRepository) Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error) {
|
||||
return f.app, f.authErr
|
||||
}
|
||||
func (f *fakeRepository) AuthorizedVehicles(_ context.Context, _ uint64, plates []string, _ time.Time, _ time.Time) (map[string]AuthorizedVehicle, error) {
|
||||
f.requestedPlates = append([]string(nil), plates...)
|
||||
return f.vehicles, nil
|
||||
}
|
||||
func (f *fakeRepository) DailyHydrogen(_ context.Context, vins []string, _ string) (map[string]DailyHydrogen, error) {
|
||||
f.dailyVINs = append([]string(nil), vins...)
|
||||
return f.hydrogen, nil
|
||||
}
|
||||
func (f *fakeRepository) DailyMileage(_ context.Context, vins []string, _ string, protocols []string) (map[string]DailyMileage, error) {
|
||||
f.dailyVINs = append([]string(nil), vins...)
|
||||
f.dailyProtocols = append([]string(nil), protocols...)
|
||||
return f.mileage, nil
|
||||
}
|
||||
func (f *fakeRepository) DailyMileageRange(_ context.Context, vins []string, _, _ string, protocols []string) (map[string]DailyMileage, error) {
|
||||
f.rangeVINs = append([]string(nil), vins...)
|
||||
f.dailyVINs = append([]string(nil), vins...)
|
||||
f.dailyProtocols = append([]string(nil), protocols...)
|
||||
return f.mileage, nil
|
||||
}
|
||||
func (f *fakeRepository) LatestMileageBefore(_ context.Context, vins []string, _ string, protocols []string) (map[string]DailyMileage, error) {
|
||||
f.priorCalls++
|
||||
f.priorVINs = append([]string(nil), vins...)
|
||||
f.dailyVINs = append([]string(nil), vins...)
|
||||
f.dailyProtocols = append([]string(nil), protocols...)
|
||||
return f.priorMileage, nil
|
||||
}
|
||||
func (f *fakeRepository) CreateMileageSnapshot(_ context.Context, snapshot MileageSnapshot) error {
|
||||
f.snapshot = snapshot
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepository) LoadMileageSnapshot(_ context.Context, id string, appID uint64, _ time.Time) (MileageSnapshot, error) {
|
||||
if f.snapshot.ID != id || f.snapshot.AppID != appID {
|
||||
return MileageSnapshot{}, ErrInvalidRequest
|
||||
}
|
||||
return f.snapshot, nil
|
||||
}
|
||||
func (f *fakeRepository) AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error) {
|
||||
return f.authorizedVIN, nil
|
||||
}
|
||||
func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) {
|
||||
return f.totalMileage, nil
|
||||
}
|
||||
func (f *fakeRepository) Audit(_ context.Context, _ uint64, endpoint, result, _ string, _ int, _ string) error {
|
||||
f.audits = append(f.audits, endpoint+":"+result)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeRepository) CreateApp(_ context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) {
|
||||
f.createdHash, f.createdPrefix = hash, prefix
|
||||
return App{ID: 1, Name: input.Name, AppKeyPrefix: prefix, Status: input.Status, ValidFrom: from, ValidTo: to, CreatedBy: actor}, nil
|
||||
}
|
||||
func (f *fakeRepository) ListApps(context.Context) ([]App, error) { return nil, nil }
|
||||
func (f *fakeRepository) UpdateApp(context.Context, uint64, AppInput, time.Time, *time.Time, string) (App, error) {
|
||||
return App{}, nil
|
||||
}
|
||||
func (f *fakeRepository) RotateKey(context.Context, uint64, [sha256.Size]byte, string, string) (App, error) {
|
||||
return App{}, nil
|
||||
}
|
||||
func (f *fakeRepository) ReplaceVehicleGrants(context.Context, uint64, []parsedGrant, string) ([]VehicleGrant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeRepository) ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestExternalHydrogenAndMileageQueriesPreserveRequestedVehicles(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7, Name: "partner"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
||||
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
||||
},
|
||||
hydrogen: map[string]DailyHydrogen{
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 12.3154, SampleCount: 1, QualityStatus: "OK"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
"LTEST32960VIN0001": {
|
||||
VIN: "LTEST32960VIN0001", Protocol: "GB32960", MileageKm: 101.235, TotalMileageKm: 12345.679,
|
||||
DataTime: "2026-07-01T23:59:00+08:00", UpdatedAt: "2026-07-02T00:01:00+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 7, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) }
|
||||
request := QueryRequest{PlateNumbers: []string{"粤A12345", "粤B67890"}, Date: "2026-07-01"}
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
hydrogen, err := service.QueryHydrogen(context.Background(), key, "trace-h", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hydrogen) != 2 || hydrogen[0].HydrogenConsumptionKg == nil || *hydrogen[0].HydrogenConsumptionKg != 12.315 || hydrogen[1].Status != StatusNoData || hydrogen[1].HydrogenConsumptionKg != nil {
|
||||
t.Fatalf("hydrogen = %#v", hydrogen)
|
||||
}
|
||||
mileage, err := service.QueryMileage(context.Background(), key, "trace-m", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mileage) != 2 || mileage[0].VIN != "LTEST32960VIN0001" || mileage[0].DailyMileageKm == nil || *mileage[0].DailyMileageKm != 101.235 || mileage[0].TotalMileageKm == nil || *mileage[0].TotalMileageKm != 12345.679 || mileage[0].DataTime == nil || mileage[0].UpdatedAt == nil || mileage[1].Status != StatusNoData || mileage[1].TotalMileageKm != nil || mileage[1].DataTime != nil {
|
||||
t.Fatalf("mileage = %#v", mileage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyQueriesWithoutPlatesReturnAllAuthorizedVehicles(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7, Name: "partner"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"粤B67890": {VIN: "LTEST32960VIN0002", Plate: "粤B67890"},
|
||||
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
||||
},
|
||||
hydrogen: map[string]DailyHydrogen{
|
||||
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", ConsumptionKg: 8.5, QualityStatus: "OK"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
"LTEST32960VIN0002": {
|
||||
VIN: "LTEST32960VIN0002", Protocol: "YUTONG_MQTT", MileageKm: 88.8, TotalMileageKm: 10088.8,
|
||||
DataTime: "2026-07-01T20:00:00+08:00", UpdatedAt: "2026-07-01T20:00:01+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
request := QueryRequest{Date: "2026-07-01"}
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
hydrogen, err := service.QueryHydrogen(context.Background(), key, "trace-h-all", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(repository.requestedPlates) != 0 || len(hydrogen) != 2 || hydrogen[0].PlateNumber != "粤A12345" || hydrogen[1].PlateNumber != "粤B67890" {
|
||||
t.Fatalf("requested=%v hydrogen=%#v", repository.requestedPlates, hydrogen)
|
||||
}
|
||||
if len(repository.dailyVINs) != 2 || repository.dailyVINs[0] != "LTEST32960VIN0001" || repository.dailyVINs[1] != "LTEST32960VIN0002" {
|
||||
t.Fatalf("daily VINs=%v", repository.dailyVINs)
|
||||
}
|
||||
|
||||
mileage, err := service.QueryMileage(context.Background(), key, "trace-m-all", QueryRequest{PlateNumbers: []string{}, Date: "2026-07-01"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mileage) != 2 || mileage[0].PlateNumber != "粤A12345" || mileage[1].PlateNumber != "粤B67890" || mileage[1].DailyMileageKm == nil {
|
||||
t.Fatalf("mileage=%#v", mileage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageProtocolPriorityValidationAndExternalSourceName(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
"LTEST32960VIN0001": {
|
||||
VIN: "LTEST32960VIN0001", Protocol: "YUTONG_MQTT", MileageKm: 0, TotalMileageKm: 12345,
|
||||
DataTime: "2026-07-01T20:00:00+08:00", UpdatedAt: "2026-07-01T20:00:01+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
priority := ProtocolPriority{Values: []string{"JT808", "GB32960", "MQTT"}}
|
||||
result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-priority", QueryRequest{
|
||||
PlateNumbers: []string{"粤A12345"}, Date: "2026-07-01", ProtocolPriority: priority,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(repository.dailyProtocols, ",") != "JT808,GB32960,YUTONG_MQTT" {
|
||||
t.Fatalf("internal protocols=%v", repository.dailyProtocols)
|
||||
}
|
||||
if len(result) != 1 || result[0].DailyMileageKm == nil || *result[0].DailyMileageKm != 0 || result[0].SourceProtocol == nil || *result[0].SourceProtocol != "MQTT" {
|
||||
t.Fatalf("result=%#v", result)
|
||||
}
|
||||
|
||||
for _, values := range [][]string{{}, {"YUTONG_MQTT"}, {"GB32960", "GB32960"}, {"mqtt"}} {
|
||||
invalid := ProtocolPriority{Values: values, Present: true}
|
||||
_, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-invalid", QueryRequest{
|
||||
Date: "2026-07-01", ProtocolPriority: invalid,
|
||||
})
|
||||
if !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("priority=%v error=%v", values, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageCarriesForwardPreviousTotalAndPreviousCalculationTime(t *testing.T) {
|
||||
previous := DailyMileage{
|
||||
VIN: "LTEST32960VIN0001", Date: "2026-07-20", Protocol: "YUTONG_MQTT",
|
||||
MileageKm: 35.5, TotalMileageKm: 8888.8,
|
||||
DataTime: "2026-07-20T23:58:00+08:00", UpdatedAt: "2026-07-21T01:15:00+08:00",
|
||||
}
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{},
|
||||
priorMileage: map[string]DailyMileage{"LTEST32960VIN0001": previous},
|
||||
}
|
||||
service := NewService(repository)
|
||||
result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-carry", QueryRequest{
|
||||
PlateNumbers: []string{"粤A12345"}, Date: "2026-07-21",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := result[0]
|
||||
if item.Status != StatusNormal || item.DailyMileageKm == nil || *item.DailyMileageKm != 0 ||
|
||||
item.TotalMileageKm == nil || *item.TotalMileageKm != 8888.8 ||
|
||||
item.SourceProtocol == nil || *item.SourceProtocol != "MQTT" ||
|
||||
item.DataTime == nil || *item.DataTime != previous.DataTime ||
|
||||
item.UpdatedAt == nil || *item.UpdatedAt != previous.UpdatedAt {
|
||||
t.Fatalf("carried result=%#v", item)
|
||||
}
|
||||
|
||||
rangeResult, err := service.QueryMileageRange(context.Background(), "0123456789abcdef0123456789abcdef", "trace-carry-range", MileageRangeRequest{
|
||||
StartDate: "2026-07-21", EndDate: "2026-07-22", PageSize: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rangeResult.Data) != 2 {
|
||||
t.Fatalf("range=%#v", rangeResult)
|
||||
}
|
||||
for _, row := range rangeResult.Data {
|
||||
if row.DailyMileageKm == nil || *row.DailyMileageKm != 0 ||
|
||||
row.TotalMileageKm == nil || *row.TotalMileageKm != previous.TotalMileageKm ||
|
||||
row.UpdatedAt == nil || *row.UpdatedAt != previous.UpdatedAt {
|
||||
t.Fatalf("carried range row=%#v", row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageRangeUsesStableSnapshotAndDistinguishesZeroFromNoData(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7, Name: "partner"},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"沪A00002": {VIN: "LTEST32960VIN0002", Plate: "沪A00002"},
|
||||
"沪A00001": {VIN: "LTEST32960VIN0001", Plate: "沪A00001"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
dailyMileageKey("LTEST32960VIN0001", "2026-07-01"): {
|
||||
VIN: "LTEST32960VIN0001", Date: "2026-07-01", Protocol: "GB32960", MileageKm: 0, TotalMileageKm: 12000,
|
||||
DataTime: "2026-07-01T23:58:45+08:00", UpdatedAt: "2026-07-02T05:10:00+08:00",
|
||||
},
|
||||
dailyMileageKey("LTEST32960VIN0001", "2026-07-02"): {
|
||||
VIN: "LTEST32960VIN0001", Date: "2026-07-02", Protocol: "GB32960", MileageKm: 10.25, TotalMileageKm: 12010.25,
|
||||
DataTime: "2026-07-02T20:00:00+08:00", UpdatedAt: "2026-07-02T20:00:01+08:00",
|
||||
},
|
||||
dailyMileageKey("LTEST32960VIN0002", "2026-07-02"): {
|
||||
VIN: "LTEST32960VIN0002", Date: "2026-07-02", Protocol: "JT808", MileageKm: 8.5, TotalMileageKm: 9008.5,
|
||||
DataTime: "2026-07-02T21:00:00+08:00", UpdatedAt: "2026-07-02T21:00:01+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 7, 23, 12, 0, 0, 0, time.FixedZone("CST", 8*3600)) }
|
||||
request := MileageRangeRequest{StartDate: "2026-07-01", EndDate: "2026-07-02", PageSize: 3}
|
||||
const key = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
first, err := service.QueryMileageRange(context.Background(), key, "trace-range-1", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(first.Data) != 3 || first.SnapshotID == "" || first.NextCursor == nil {
|
||||
t.Fatalf("first=%#v", first)
|
||||
}
|
||||
if first.Data[0].PlateNumber != "沪A00001" || first.Data[0].DailyMileageKm == nil || *first.Data[0].DailyMileageKm != 0 || first.Data[0].Status != StatusNormal {
|
||||
t.Fatalf("zero mileage row=%#v", first.Data[0])
|
||||
}
|
||||
if first.Data[1].PlateNumber != "沪A00002" || first.Data[1].Status != StatusNoData || first.Data[1].DailyMileageKm != nil || first.Data[1].DataTime != nil {
|
||||
t.Fatalf("no-data row=%#v", first.Data[1])
|
||||
}
|
||||
if first.Data[2].Date != "2026-07-02" || first.Data[2].PlateNumber != "沪A00001" {
|
||||
t.Fatalf("third row=%#v", first.Data[2])
|
||||
}
|
||||
|
||||
repository.vehicles = map[string]AuthorizedVehicle{}
|
||||
request.Cursor = *first.NextCursor
|
||||
changedPriority := ProtocolPriority{Values: []string{"JT808"}}
|
||||
changedRequest := request
|
||||
changedRequest.ProtocolPriority = changedPriority
|
||||
if _, err := service.QueryMileageRange(context.Background(), key, "trace-range-changed", changedRequest); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("changed protocol priority must invalidate cursor: %v", err)
|
||||
}
|
||||
second, err := service.QueryMileageRange(context.Background(), key, "trace-range-2", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.SnapshotID != first.SnapshotID || second.NextCursor != nil || len(second.Data) != 1 || second.Data[0].PlateNumber != "沪A00002" || second.Data[0].Date != "2026-07-02" {
|
||||
t.Fatalf("second=%#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageRangeOnlyLoadsPriorMileageForVINsMissingAtTheirFirstPageDate(t *testing.T) {
|
||||
const (
|
||||
firstVIN = "LTEST32960VIN0001"
|
||||
secondVIN = "LTEST32960VIN0002"
|
||||
)
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"沪A00001": {VIN: firstVIN, Plate: "沪A00001"},
|
||||
"沪A00002": {VIN: secondVIN, Plate: "沪A00002"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
dailyMileageKey(firstVIN, "2026-07-27"): {
|
||||
VIN: firstVIN, Date: "2026-07-27", Protocol: "GB32960",
|
||||
MileageKm: 12.5, TotalMileageKm: 12012.5,
|
||||
DataTime: "2026-07-27T12:00:00+08:00", UpdatedAt: "2026-07-27T12:00:01+08:00",
|
||||
},
|
||||
},
|
||||
priorMileage: map[string]DailyMileage{
|
||||
secondVIN: {
|
||||
VIN: secondVIN, Date: "2026-07-26", Protocol: "JT808",
|
||||
MileageKm: 8, TotalMileageKm: 9008,
|
||||
DataTime: "2026-07-26T23:00:00+08:00", UpdatedAt: "2026-07-27T01:00:00+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := NewService(repository).QueryMileageRange(
|
||||
context.Background(),
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"trace-range-missing-only",
|
||||
MileageRangeRequest{StartDate: "2026-07-27", EndDate: "2026-07-27", PageSize: 10},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repository.priorCalls != 1 || len(repository.priorVINs) != 1 || repository.priorVINs[0] != secondVIN {
|
||||
t.Fatalf("prior calls=%d vins=%v, want only %s", repository.priorCalls, repository.priorVINs, secondVIN)
|
||||
}
|
||||
if len(result.Data) != 2 || result.Data[0].DailyMileageKm == nil || *result.Data[0].DailyMileageKm != 12.5 ||
|
||||
result.Data[1].DailyMileageKm == nil || *result.Data[1].DailyMileageKm != 0 {
|
||||
t.Fatalf("result=%#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageRangeSkipsPriorMileageLookupWhenEveryVINHasInitialData(t *testing.T) {
|
||||
const vin = "LTEST32960VIN0001"
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7},
|
||||
vehicles: map[string]AuthorizedVehicle{
|
||||
"沪A00001": {VIN: vin, Plate: "沪A00001"},
|
||||
},
|
||||
mileage: map[string]DailyMileage{
|
||||
dailyMileageKey(vin, "2026-07-27"): {
|
||||
VIN: vin, Date: "2026-07-27", Protocol: "GB32960",
|
||||
MileageKm: 12.5, TotalMileageKm: 12012.5,
|
||||
DataTime: "2026-07-27T12:00:00+08:00", UpdatedAt: "2026-07-27T12:00:01+08:00",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := NewService(repository).QueryMileageRange(
|
||||
context.Background(),
|
||||
"0123456789abcdef0123456789abcdef",
|
||||
"trace-range-no-prior",
|
||||
MileageRangeRequest{StartDate: "2026-07-27", EndDate: "2026-07-27", PageSize: 10},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repository.priorCalls != 0 || len(repository.priorVINs) != 0 {
|
||||
t.Fatalf("prior calls=%d vins=%v, want no lookup", repository.priorCalls, repository.priorVINs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMileageRangeValidationLimitsWindowAndPageSize(t *testing.T) {
|
||||
service := NewService(&fakeRepository{})
|
||||
for _, request := range []MileageRangeRequest{
|
||||
{StartDate: "2025-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},
|
||||
} {
|
||||
if _, _, _, _, _, _, _, err := service.validateMileageRange(request); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("request=%#v error=%v", request, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalMileageReturnsRecordTimeDifferenceAndProtocolMeaning(t *testing.T) {
|
||||
location := time.FixedZone("CST", 8*3600)
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7}, authorizedVIN: true,
|
||||
totalMileage: &TotalMileagePoint{VIN: "LA9GG68L2PBAF4790", Protocol: "GB32960", ObservedAt: time.Date(2026, 7, 21, 9, 29, 45, 0, location), TotalMileageKm: 12345.6784},
|
||||
}
|
||||
service := NewService(repository)
|
||||
service.now = func() time.Time { return time.Date(2026, 7, 21, 12, 0, 0, 0, location) }
|
||||
result, err := service.QueryTotalMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-total", TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.TotalMileageKm == nil || *result.TotalMileageKm != 12345.678 || result.Protocol != "GB32960" || result.RecordTime != "2026-07-21 09:29:45" || result.TimeDifferenceSeconds == nil || *result.TimeDifferenceSeconds != 15 || result.Status != StatusNormal || !strings.Contains(result.MileageMeaning, "仪表盘") {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalMileageCanonicalProtocolsAndStrictTime(t *testing.T) {
|
||||
service := NewService(&fakeRepository{})
|
||||
for _, input := range []string{"GB32960", "YUTONG_MQTT", "JT808"} {
|
||||
_, _, protocols, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00", Protocol: input})
|
||||
if err != nil || len(protocols) != 1 || protocols[0] != input {
|
||||
t.Fatalf("%s: protocols=%v err=%v", input, protocols, err)
|
||||
}
|
||||
}
|
||||
for _, alias := range []string{"32960", "mqtt", "808"} {
|
||||
if _, _, _, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026-07-21 09:30:00", Protocol: alias}); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("alias %s should be rejected: %v", alias, err)
|
||||
}
|
||||
}
|
||||
if _, _, _, _, err := service.validateTotalMileageQuery(TotalMileageQueryRequest{VIN: "LA9GG68L2PBAF4790", Time: "2026/07/21 09:30"}); !errors.Is(err, ErrInvalidRequest) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFailsClosedWhenAnyVehicleIsNotGranted(t *testing.T) {
|
||||
repository := &fakeRepository{
|
||||
app: AppCredential{ID: 7},
|
||||
vehicles: map[string]AuthorizedVehicle{"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"}},
|
||||
}
|
||||
_, err := NewService(repository).QueryHydrogen(context.Background(), "0123456789abcdef0123456789abcdef", "trace", QueryRequest{
|
||||
PlateNumbers: []string{"粤A12345", "粤B67890"}, Date: "2026-07-01",
|
||||
})
|
||||
if !errors.Is(err, ErrForbidden) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAppReturns32CharacterKeyOnlyOnce(t *testing.T) {
|
||||
repository := &fakeRepository{}
|
||||
service := NewService(repository)
|
||||
created, err := service.CreateApp(context.Background(), AppInput{
|
||||
Name: "partner", ValidFrom: "2026-07-01T00:00:00+08:00", ValidTo: "2027-07-01T00:00:00+08:00",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !appKeyPattern.MatchString(created.AppKey) || len(created.AppKey) != 32 || created.AppKeyPrefix != created.AppKey[:8] {
|
||||
t.Fatalf("created = %#v", created)
|
||||
}
|
||||
if created.AppKey[12] != '4' || !strings.ContainsRune("89ab", rune(created.AppKey[16])) {
|
||||
t.Fatalf("appKey is not UUID v4: %s", created.AppKey)
|
||||
}
|
||||
if repository.createdHash != sha256.Sum256([]byte(created.AppKey)) || repository.createdPrefix != created.AppKey[:8] {
|
||||
t.Fatal("repository did not receive the appKey hash and prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
|
||||
repository := &fakeRepository{app: AppCredential{ID: 1}, vehicles: map[string]AuthorizedVehicle{
|
||||
"粤A12345": {VIN: "LTEST32960VIN0001", Plate: "粤A12345"},
|
||||
}, hydrogen: map[string]DailyHydrogen{}}
|
||||
handler := NewHandler(NewService(repository))
|
||||
request := httptest.NewRequest(http.MethodPost, HydrogenQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345"],"date":"2026-07-01"}`))
|
||||
request.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
var body ExternalResponse
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body.Code != "SUCCESS" || body.Message != "success" || len(body.TraceID) != 32 {
|
||||
t.Fatalf("body = %#v", body)
|
||||
}
|
||||
|
||||
allRequest := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01"}`))
|
||||
allRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
allResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(allResponse, allRequest)
|
||||
if allResponse.Code != http.StatusOK || !strings.Contains(allResponse.Body.String(), `"plateNumber":"粤A12345"`) {
|
||||
t.Fatalf("status=%d body=%s", allResponse.Code, allResponse.Body.String())
|
||||
}
|
||||
|
||||
rangeRequest := httptest.NewRequest(http.MethodPost, MileageRangeQueryPath, strings.NewReader(`{"startDate":"2026-07-01","endDate":"2026-07-01","pageSize":10}`))
|
||||
rangeRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
rangeResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rangeResponse, rangeRequest)
|
||||
if rangeResponse.Code != http.StatusOK || !strings.Contains(rangeResponse.Body.String(), `"snapshotId"`) || !strings.Contains(rangeResponse.Body.String(), `"nextCursor":null`) {
|
||||
t.Fatalf("status=%d body=%s", rangeResponse.Code, rangeResponse.Body.String())
|
||||
}
|
||||
|
||||
badDate := httptest.NewRequest(http.MethodPost, HydrogenQueryPath, strings.NewReader(`{"plateNumbers":["粤A12345"],"date":"2026/07/01"}`))
|
||||
badDate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
badResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(badResponse, badDate)
|
||||
if badResponse.Code != http.StatusBadRequest || !strings.Contains(badResponse.Body.String(), `"code":"INVALID_DATE_FORMAT"`) {
|
||||
t.Fatalf("status=%d body=%s", badResponse.Code, badResponse.Body.String())
|
||||
}
|
||||
|
||||
badProtocol := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01","protocolPriority":["GB32960","GB32960"]}`))
|
||||
badProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
badProtocolResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(badProtocolResponse, badProtocol)
|
||||
if badProtocolResponse.Code != http.StatusBadRequest ||
|
||||
!strings.Contains(badProtocolResponse.Body.String(), `"message":"protocolPriority不能包含重复协议"`) ||
|
||||
!strings.Contains(badProtocolResponse.Body.String(), `"traceId":"`) {
|
||||
t.Fatalf("status=%d body=%s", badProtocolResponse.Code, badProtocolResponse.Body.String())
|
||||
}
|
||||
|
||||
nullProtocol := httptest.NewRequest(http.MethodPost, MileageQueryPath, strings.NewReader(`{"date":"2026-07-01","protocolPriority":null}`))
|
||||
nullProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
|
||||
nullProtocolResponse := httptest.NewRecorder()
|
||||
handler.ServeHTTP(nullProtocolResponse, nullProtocol)
|
||||
if nullProtocolResponse.Code != http.StatusBadRequest || !strings.Contains(nullProtocolResponse.Body.String(), `"message":"protocolPriority不能为空"`) {
|
||||
t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type StandaloneConfig struct {
|
||||
StaticDir string
|
||||
SessionTTL time.Duration
|
||||
RequestTimeout time.Duration
|
||||
Release string
|
||||
TDengine *sql.DB
|
||||
TDengineDatabase string
|
||||
}
|
||||
|
||||
func NewStandaloneServer(db *sql.DB, cfg StandaloneConfig) http.Handler {
|
||||
repository := NewMySQLRepository(db)
|
||||
if cfg.TDengine != nil {
|
||||
repository.WithTDengine(cfg.TDengine, cfg.TDengineDatabase)
|
||||
}
|
||||
service := NewService(repository)
|
||||
portal := NewPortalService(db, cfg.SessionTTL)
|
||||
external := NewExternalHandler(service, portal)
|
||||
|
||||
api := http.NewServeMux()
|
||||
api.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeStandaloneJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"service": "open-platform-api",
|
||||
"release": strings.TrimSpace(cfg.Release),
|
||||
})
|
||||
})
|
||||
api.Handle("/", external)
|
||||
|
||||
handler := standaloneStatic(cfg.StaticDir, api)
|
||||
handler = WithDocs(handler)
|
||||
handler = standaloneSecurityHeaders(handler)
|
||||
timeout := cfg.RequestTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return http.TimeoutHandler(handler, timeout, `{"error":{"code":"REQUEST_TIMEOUT","message":"请求处理超时"}}`)
|
||||
}
|
||||
|
||||
func standaloneStatic(dir string, fallback http.Handler) http.Handler {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return fallback
|
||||
}
|
||||
files := http.FileServer(http.Dir(dir))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if isStandaloneAPIRoute(r.URL.Path) {
|
||||
fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
clean := filepath.Clean("/" + r.URL.Path)
|
||||
path := filepath.Join(dir, clean)
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
if clean == "/index.html" {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
} else if strings.HasPrefix(clean, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, filepath.Join(dir, "index.html"))
|
||||
})
|
||||
}
|
||||
|
||||
func isStandaloneAPIRoute(path string) bool {
|
||||
return path == "/healthz" ||
|
||||
strings.HasPrefix(path, "/api/") ||
|
||||
strings.HasPrefix(path, "/portal-api/") ||
|
||||
strings.HasPrefix(path, "/open-api/")
|
||||
}
|
||||
|
||||
func standaloneSecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func writeStandaloneJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
)
|
||||
|
||||
func TestPortalDecodeDetail(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "empty", err: io.EOF, want: "请求内容不能为空"},
|
||||
{name: "syntax", err: &json.SyntaxError{Offset: 2}, want: "请求内容不是有效的 JSON"},
|
||||
{name: "type", err: &json.UnmarshalTypeError{Field: "validFrom"}, want: "字段 validFrom 的数据类型不正确"},
|
||||
{name: "unknown", err: errors.New(`json: unknown field "appId"`), want: `请求包含未支持的字段 "appId"`},
|
||||
{name: "other", err: errors.New("unexpected"), want: "请求内容无法解析"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := portalDecodeDetail(test.err); got != test.want {
|
||||
t.Fatalf("portalDecodeDetail() = %q, want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandaloneServerServesPortalDocsAndCatalog(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
staticDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(staticDir, "index.html"), []byte(`<div id="root"></div>`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
handler := NewStandaloneServer(db, StandaloneConfig{
|
||||
StaticDir: staticDir,
|
||||
SessionTTL: time.Hour,
|
||||
RequestTimeout: time.Second,
|
||||
Release: "test-release",
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
path string
|
||||
content string
|
||||
contentType string
|
||||
}{
|
||||
{"/", `<div id="root"></div>`, "text/html"},
|
||||
{"/healthz", `"service":"open-platform-api"`, "application/json"},
|
||||
{"/portal-api/catalog", `"daily_hydrogen"`, "application/json"},
|
||||
{"/open-api/openapi.yaml", "openapi: 3.0.3", "application/yaml"},
|
||||
}
|
||||
for _, item := range tests {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, item.path, nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), item.content) {
|
||||
t.Fatalf("path=%s status=%d body=%s", item.path, recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if value := recorder.Header().Get("Content-Type"); !strings.Contains(value, item.contentType) {
|
||||
t.Fatalf("path=%s content-type=%s", item.path, value)
|
||||
}
|
||||
if recorder.Header().Get("X-Frame-Options") != "DENY" {
|
||||
t.Fatalf("path=%s missing security headers", item.path)
|
||||
}
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandalonePortalSessionRequiresBearerToken(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
handler := NewStandaloneServer(db, StandaloneConfig{RequestTimeout: time.Second})
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/portal-api/session", nil))
|
||||
if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), "UNAUTHORIZED") {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortalCredentialValidation(t *testing.T) {
|
||||
input := PortalUserInput{
|
||||
Username: "partner.dev", DisplayName: "合作伙伴开发者",
|
||||
Password: "StrongPass2026", Status: "enabled",
|
||||
ValidFrom: "2026-07-01T00:00:00+08:00", ValidTo: "2027-07-01T00:00:00+08:00",
|
||||
}
|
||||
if _, _, err := validatePortalUserInput(&input, true); err != nil {
|
||||
t.Fatalf("valid portal user rejected: %v", err)
|
||||
}
|
||||
if err := validatePortalPassword("weak-password"); err == nil {
|
||||
t.Fatal("weak password should be rejected")
|
||||
}
|
||||
if !validPortalRole("owner") || !validPortalRole("developer") || !validPortalRole("viewer") || validPortalRole("admin") {
|
||||
t.Fatal("unexpected portal role validation")
|
||||
}
|
||||
raw, hash, err := newPortalSessionToken()
|
||||
if err != nil || len(raw) != 64 || hash == [32]byte{} {
|
||||
t.Fatalf("invalid session token raw=%d err=%v", len(raw), err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var hydrogenMassFields = []string{
|
||||
"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg",
|
||||
"gb32960.gd_fc_vehicle.hydrogen_mass_kg",
|
||||
"gb32960.gd_fc_vehicle_info.gd_fc_vehicle_hydrogen_mass_kg",
|
||||
"gd_fc_vehicle_hydrogen_mass_kg",
|
||||
}
|
||||
|
||||
func BuildHydrogenDailyStats(observations []HydrogenObservation, date string, noiseKg, maxDropKg float64) []HydrogenDailyStat {
|
||||
if noiseKg <= 0 {
|
||||
noiseKg = 0.05
|
||||
}
|
||||
if maxDropKg <= noiseKg {
|
||||
maxDropKg = 20
|
||||
}
|
||||
grouped := map[string]map[string][]HydrogenObservation{}
|
||||
for _, observation := range observations {
|
||||
observation.VIN = strings.ToUpper(strings.TrimSpace(observation.VIN))
|
||||
observation.Source = strings.TrimSpace(observation.Source)
|
||||
if len(observation.VIN) != 17 || math.IsNaN(observation.MassKg) || math.IsInf(observation.MassKg, 0) || observation.MassKg < 0 || observation.MassKg > 200 {
|
||||
continue
|
||||
}
|
||||
if grouped[observation.VIN] == nil {
|
||||
grouped[observation.VIN] = map[string][]HydrogenObservation{}
|
||||
}
|
||||
grouped[observation.VIN][observation.Source] = append(grouped[observation.VIN][observation.Source], observation)
|
||||
}
|
||||
vins := make([]string, 0, len(grouped))
|
||||
for vin := range grouped {
|
||||
vins = append(vins, vin)
|
||||
}
|
||||
sort.Strings(vins)
|
||||
stats := make([]HydrogenDailyStat, 0, len(vins))
|
||||
for _, vin := range vins {
|
||||
var selected *HydrogenDailyStat
|
||||
for source, values := range grouped[vin] {
|
||||
candidate := buildHydrogenDailyStat(vin, source, date, values, noiseKg, maxDropKg)
|
||||
if selected == nil || betterHydrogenStat(candidate, *selected) {
|
||||
copy := candidate
|
||||
selected = ©
|
||||
}
|
||||
}
|
||||
if selected != nil {
|
||||
stats = append(stats, *selected)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
func buildHydrogenDailyStat(vin, source, date string, values []HydrogenObservation, noiseKg, maxDropKg float64) HydrogenDailyStat {
|
||||
sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) })
|
||||
stat := HydrogenDailyStat{
|
||||
VIN: vin, Source: source, Date: date,
|
||||
FirstMassKg: values[0].MassKg, LastMassKg: values[len(values)-1].MassKg,
|
||||
SampleCount: len(values), QualityStatus: "OK",
|
||||
}
|
||||
abnormalDrops := 0
|
||||
cycleMinimum := values[0].MassKg
|
||||
for index := 1; index < len(values); index++ {
|
||||
value := values[index]
|
||||
sampleNoise := noiseKg
|
||||
if value.NoiseKg > sampleNoise {
|
||||
sampleNoise = value.NoiseKg
|
||||
}
|
||||
refuelThreshold := math.Max(1, cycleMinimum*0.05)
|
||||
if value.RefuelThresholdKg > 0 {
|
||||
refuelThreshold = value.RefuelThresholdKg
|
||||
}
|
||||
delta := cycleMinimum - value.MassKg
|
||||
switch {
|
||||
case value.MassKg-cycleMinimum > refuelThreshold:
|
||||
stat.RefuelCount++
|
||||
cycleMinimum = value.MassKg
|
||||
case delta > sampleNoise && delta <= maxDropKg:
|
||||
stat.ConsumptionKg += delta
|
||||
cycleMinimum = value.MassKg
|
||||
case delta > maxDropKg:
|
||||
abnormalDrops++
|
||||
}
|
||||
}
|
||||
stat.ConsumptionKg = round3(stat.ConsumptionKg)
|
||||
if stat.SampleCount < 2 {
|
||||
stat.QualityStatus = "NO_DATA"
|
||||
stat.QualityReason = "有效车载氢量样本不足2条"
|
||||
} else if abnormalDrops > 0 {
|
||||
stat.QualityStatus = "SUSPECT"
|
||||
stat.QualityReason = fmt.Sprintf("过滤%d次超过%.3fkg的异常下降", abnormalDrops, maxDropKg)
|
||||
}
|
||||
return stat
|
||||
}
|
||||
|
||||
func betterHydrogenStat(candidate, current HydrogenDailyStat) bool {
|
||||
rank := func(status string) int {
|
||||
switch status {
|
||||
case "OK":
|
||||
return 0
|
||||
case "SUSPECT":
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
if rank(candidate.QualityStatus) != rank(current.QualityStatus) {
|
||||
return rank(candidate.QualityStatus) < rank(current.QualityStatus)
|
||||
}
|
||||
if candidate.SampleCount != current.SampleCount {
|
||||
return candidate.SampleCount > current.SampleCount
|
||||
}
|
||||
return candidate.Source < current.Source
|
||||
}
|
||||
|
||||
func ExtractHydrogenMass(parsedJSON string) (float64, bool) {
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
for _, key := range hydrogenMassFields {
|
||||
if value, ok := numericValue(fields[key]); ok && value >= 0 && value <= 200 {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func ExtractHydrogenRateAndMileage(parsedJSON string) (float64, float64, bool) {
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
rate, rateOK := numericValue(fields["gb32960.fuel_cell.hydrogen_consumption_kg_per_100km"])
|
||||
mileage, mileageOK := numericValue(fields["gb32960.vehicle.total_mileage_km"])
|
||||
return rate, mileage, rateOK && mileageOK && rate >= 0 && rate <= 50 && mileage > 0
|
||||
}
|
||||
|
||||
func BuildHydrogenRateDailyStats(observations []HydrogenRateObservation, date string, maxDeltaKm float64) []HydrogenRateDailyStat {
|
||||
if maxDeltaKm <= 0 {
|
||||
maxDeltaKm = 10
|
||||
}
|
||||
grouped := map[string]map[string][]HydrogenRateObservation{}
|
||||
for _, value := range observations {
|
||||
value.VIN = strings.ToUpper(strings.TrimSpace(value.VIN))
|
||||
if len(value.VIN) != 17 || value.Rate < 0 || value.Rate > 50 || value.MileageKm <= 0 {
|
||||
continue
|
||||
}
|
||||
if grouped[value.VIN] == nil {
|
||||
grouped[value.VIN] = map[string][]HydrogenRateObservation{}
|
||||
}
|
||||
grouped[value.VIN][strings.TrimSpace(value.Source)] = append(grouped[value.VIN][strings.TrimSpace(value.Source)], value)
|
||||
}
|
||||
result := make([]HydrogenRateDailyStat, 0, len(grouped))
|
||||
for vin, sources := range grouped {
|
||||
var best *HydrogenRateDailyStat
|
||||
for source, values := range sources {
|
||||
sort.SliceStable(values, func(i, j int) bool { return values[i].ObservedAt.Before(values[j].ObservedAt) })
|
||||
stat := HydrogenRateDailyStat{VIN: vin, Source: source, Date: date, SampleCount: len(values), QualityStatus: "NO_DATA", QualityReason: "尚无有效行驶里程区间"}
|
||||
movement, abnormal := 0, 0
|
||||
for i := 1; i < len(values); i++ {
|
||||
delta := values[i].MileageKm - values[i-1].MileageKm
|
||||
if delta > 0 && delta <= maxDeltaKm {
|
||||
stat.ConsumptionKg += delta * (values[i-1].Rate + values[i].Rate) / 200
|
||||
movement++
|
||||
} else if delta < 0 || delta > maxDeltaKm {
|
||||
abnormal++
|
||||
}
|
||||
}
|
||||
stat.ConsumptionKg = round3(stat.ConsumptionKg)
|
||||
if abnormal > 0 {
|
||||
stat.QualityStatus, stat.QualityReason = "SUSPECT", "存在异常里程跳变"
|
||||
} else if movement > 0 {
|
||||
stat.QualityStatus, stat.QualityReason = "OK", "按里程区间积分百公里氢耗"
|
||||
}
|
||||
if best == nil || (stat.QualityStatus == "OK" && best.QualityStatus != "OK") || (stat.QualityStatus == best.QualityStatus && stat.SampleCount > best.SampleCount) {
|
||||
copy := stat
|
||||
best = ©
|
||||
}
|
||||
}
|
||||
if best != nil {
|
||||
result = append(result, *best)
|
||||
}
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].VIN < result[j].VIN })
|
||||
return result
|
||||
}
|
||||
|
||||
func LoadHydrogenCapacities(ctx context.Context, db *sql.DB) (map[string]float64, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT UPPER(TRIM(vin)),tank_capacity_l FROM vehicle_hydrogen_tank_capacity WHERE active=1 AND tank_capacity_l>0`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
capacities := map[string]float64{}
|
||||
for rows.Next() {
|
||||
var vin string
|
||||
var capacity float64
|
||||
if err := rows.Scan(&vin, &capacity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(vin) == 17 && capacity > 0 && capacity <= 10000 {
|
||||
capacities[vin] = capacity
|
||||
}
|
||||
}
|
||||
return capacities, rows.Err()
|
||||
}
|
||||
|
||||
func LoadHydrogenObservations(ctx context.Context, tdengine *sql.DB, database string, start, end time.Time, capacities map[string]float64) ([]HydrogenObservation, error) {
|
||||
database = strings.TrimSpace(database)
|
||||
if database == "" {
|
||||
database = "lingniu_vehicle_ts"
|
||||
}
|
||||
query := `SELECT vin,source_endpoint,CAST(ts AS BIGINT),parsed_json
|
||||
FROM ` + database + `.raw_frames
|
||||
WHERE protocol='GB32960'
|
||||
AND ts>='` + quoteTDTime(start) + `'
|
||||
AND ts<'` + quoteTDTime(end) + `'
|
||||
AND parse_status='OK'
|
||||
ORDER BY vin,source_endpoint,ts`
|
||||
rows, err := tdengine.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
observations := make([]HydrogenObservation, 0)
|
||||
for rows.Next() {
|
||||
var vin, parsed string
|
||||
var source sql.NullString
|
||||
var unixMS int64
|
||||
if err := rows.Scan(&vin, &source, &unixMS, &parsed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vin = strings.ToUpper(strings.TrimSpace(vin))
|
||||
capacity, capacityOK := capacities[vin]
|
||||
pressure, temperature, ok := ExtractHydrogenPressureTemperature(parsed)
|
||||
if !capacityOK || !ok {
|
||||
continue
|
||||
}
|
||||
mass, ok := PressureHydrogenMassKg(pressure, temperature, capacity)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stepMass, _ := PressureHydrogenMassKg(math.Max(0, pressure-0.2), temperature, capacity)
|
||||
noise := math.Min(1, math.Max(0.05, mass-stepMass))
|
||||
observations = append(observations, HydrogenObservation{
|
||||
VIN: vin, Source: source.String, ObservedAt: time.UnixMilli(unixMS), MassKg: mass,
|
||||
TankCapacityLiter: capacity, PressureMPa: pressure, TemperatureC: temperature,
|
||||
NoiseKg: noise, RefuelThresholdKg: math.Max(1, mass*0.05),
|
||||
})
|
||||
}
|
||||
return observations, rows.Err()
|
||||
}
|
||||
|
||||
func ExtractHydrogenPressureTemperature(parsedJSON string) (float64, float64, bool) {
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal([]byte(parsedJSON), &fields); err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
pressure, pressureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_pressure_mpa"])
|
||||
temperature, temperatureOK := numericValue(fields["gb32960.fuel_cell.max_hydrogen_temperature_c"])
|
||||
return pressure, temperature, pressureOK && temperatureOK
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func ReplaceHydrogenDailyStats(ctx context.Context, db *sql.DB, date string, stats []HydrogenDailyStat) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
// A pressure-based rebuild is authoritative for the whole day. Delete every
|
||||
// previous hydrogen row first so legacy rate/direct-mass results cannot remain
|
||||
// for vehicles without valid pressure observations in this run.
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM vehicle_open_daily_energy WHERE stat_date=? AND energy_type='HYDROGEN'`, date); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stat := range stats {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO vehicle_open_daily_energy(
|
||||
vin,stat_date,energy_type,source_endpoint,consumption_kg,unit,first_mass_kg,last_mass_kg,
|
||||
sample_count,refuel_count,quality_status,quality_reason,calculated_at
|
||||
) VALUES(?,?,'HYDROGEN',?,?,'kg',?,?,?,?,?,?,NOW(3))`,
|
||||
stat.VIN, stat.Date, stat.Source, stat.ConsumptionKg, stat.FirstMassKg, stat.LastMassKg,
|
||||
stat.SampleCount, stat.RefuelCount, stat.QualityStatus, stat.QualityReason,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func numericValue(value any) (float64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed, true
|
||||
case json.Number:
|
||||
parsed, err := typed.Float64()
|
||||
return parsed, err == nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func quoteTDTime(value time.Time) string {
|
||||
return strings.ReplaceAll(value.Format(time.RFC3339Nano), "'", "''")
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package openplatform
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBuildHydrogenDailyStatsSumsDropsAndIgnoresRefuelAndNoise(t *testing.T) {
|
||||
base := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(3 * time.Hour), MassKg: 10.00},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 10.50},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Hour), MassKg: 10.30},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(2 * time.Hour), MassKg: 11.90},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(4 * time.Hour), MassKg: 9.98},
|
||||
}, "2026-07-01", 0.05, 5)
|
||||
if len(stats) != 1 {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
stat := stats[0]
|
||||
if stat.ConsumptionKg != 2.1 || stat.RefuelCount != 1 || stat.SampleCount != 5 || stat.QualityStatus != "OK" {
|
||||
t.Fatalf("stat = %#v", stat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHydrogenDailyStatsMarksLargeDropSuspect(t *testing.T) {
|
||||
base := time.Now()
|
||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base, MassKg: 20},
|
||||
{VIN: "LTEST32960VIN0001", ObservedAt: base.Add(time.Minute), MassKg: 1},
|
||||
}, "2026-07-01", 0.05, 5)
|
||||
if len(stats) != 1 || stats[0].QualityStatus != "SUSPECT" || stats[0].ConsumptionKg != 0 {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHydrogenDailyStatsDoesNotMixSources(t *testing.T) {
|
||||
base := time.Now()
|
||||
stats := BuildHydrogenDailyStats([]HydrogenObservation{
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base, MassKg: 10},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(2 * time.Minute), MassKg: 9.8},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-a", ObservedAt: base.Add(4 * time.Minute), MassKg: 9.6},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(time.Minute), MassKg: 20},
|
||||
{VIN: "LTEST32960VIN0001", Source: "source-b", ObservedAt: base.Add(3 * time.Minute), MassKg: 19.9},
|
||||
}, "2026-07-01", 0.05, 5)
|
||||
if len(stats) != 1 {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
if stats[0].Source != "source-a" || stats[0].ConsumptionKg != 0.4 || stats[0].SampleCount != 3 {
|
||||
t.Fatalf("stat = %#v", stats[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHydrogenMassSupportsCanonicalStringAndNumber(t *testing.T) {
|
||||
for _, encoded := range []string{
|
||||
`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":"12.3"}`,
|
||||
`{"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":12.3}`,
|
||||
} {
|
||||
value, ok := ExtractHydrogenMass(encoded)
|
||||
if !ok || value != 12.3 {
|
||||
t.Fatalf("value=%v ok=%v for %s", value, ok, encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPressureHydrogenMassMatchesGuangdongVehicle(t *testing.T) {
|
||||
mass, ok := PressureHydrogenMassKg(16.4, 34, 380)
|
||||
if !ok || mass < 4.47 || mass > 4.49 {
|
||||
t.Fatalf("mass=%.3f ok=%v", mass, ok)
|
||||
}
|
||||
pressure, temperature, ok := ExtractHydrogenPressureTemperature(`{
|
||||
"gb32960.fuel_cell.max_hydrogen_pressure_mpa":"16.4",
|
||||
"gb32960.fuel_cell.max_hydrogen_temperature_c":34,
|
||||
"gb32960.gd_fc_vehicle_info.hydrogen_mass_kg":99.9
|
||||
}`)
|
||||
if !ok || pressure != 16.4 || temperature != 34 {
|
||||
t.Fatalf("pressure=%v temperature=%v ok=%v", pressure, temperature, ok)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user