Compare commits
2 Commits
go
...
codex/vehi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22f3904ad7 | ||
|
|
8539af13e6 |
@@ -45,6 +45,10 @@ Content-Type: application/json</pre>
|
|||||||
<p class="note">查询日没有有效里程但此前存在有效累计里程时,日里程补 0,累计总里程、来源协议和数据时间沿用最近有效统计;updatedAt 显示上一个统计周期的计算时间。</p>
|
<p class="note">查询日没有有效里程但此前存在有效累计里程时,日里程补 0,累计总里程、来源协议和数据时间沿用最近有效统计;updatedAt 显示上一个统计周期的计算时间。</p>
|
||||||
<h3><span class="method">POST</span>/api/v1/vehicles/total-mileage/query</h3>
|
<h3><span class="method">POST</span>/api/v1/vehicles/total-mileage/query</h3>
|
||||||
<p>按 VIN 和北京时间查询不晚于指定时刻的最近一条总里程,返回实际采集协议、记录时间和时间差秒数。</p>
|
<p>按 VIN 和北京时间查询不晚于指定时刻的最近一条总里程,返回实际采集协议、记录时间和时间差秒数。</p>
|
||||||
|
<h3><span class="method">POST</span>/api/v1/vehicles/realtime/query</h3>
|
||||||
|
<p>查询当前有效授权车辆的最新位置、在线及运动状态、速度、累计里程、实际协议和记录时间。请求体传 <code>{}</code> 返回全部授权车辆。</p>
|
||||||
|
<h3><span class="method">POST</span>/api/v1/hydrogen-stations/query</h3>
|
||||||
|
<p>只读查询资产管理库中有有效坐标的加氢站;可按 <code>province</code>、<code>city</code>、<code>cooperateOnly</code> 筛选。</p>
|
||||||
|
|
||||||
<h2>总里程协议口径</h2>
|
<h2>总里程协议口径</h2>
|
||||||
<table>
|
<table>
|
||||||
@@ -100,6 +104,15 @@ Content-Type: application/json</pre>
|
|||||||
}'</pre>
|
}'</pre>
|
||||||
<p class="note">下一页保持原请求参数不变,并传入上一页 nextCursor;同一次分页查询的 snapshotId 保持不变。</p>
|
<p class="note">下一页保持原请求参数不变,并传入上一页 nextCursor;同一次分页查询的 snapshotId 保持不变。</p>
|
||||||
|
|
||||||
|
<h3>实时车辆与全部加氢站</h3>
|
||||||
|
<pre>curl -X POST 'https://your-host/api/v1/vehicles/realtime/query' \
|
||||||
|
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||||
|
-H 'Content-Type: application/json' -d '{}'
|
||||||
|
|
||||||
|
curl -X POST 'https://your-host/api/v1/hydrogen-stations/query' \
|
||||||
|
-H 'Authorization: Bearer YOUR_32_CHARACTER_APP_KEY' \
|
||||||
|
-H 'Content-Type: application/json' -d '{}'</pre>
|
||||||
|
|
||||||
<h2>响应示例</h2>
|
<h2>响应示例</h2>
|
||||||
<h3>车辆单日里程</h3>
|
<h3>车辆单日里程</h3>
|
||||||
<pre>{
|
<pre>{
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: 车辆数据开放平台 API
|
title: 车辆数据开放平台 API
|
||||||
version: 1.5.0
|
version: 1.6.0
|
||||||
license:
|
license:
|
||||||
name: Proprietary
|
name: Proprietary
|
||||||
description: |
|
description: |
|
||||||
向授权合作方开放车辆单日用氢量、单日里程、区间日里程和指定时刻总里程。
|
向授权合作方开放车辆单日用氢量、单日里程、区间日里程、指定时刻总里程、实时位置状态及加氢站地图点位。
|
||||||
appKey 和逐车授权必须完整覆盖查询自然日。
|
appKey 和逐车授权必须完整覆盖查询自然日。
|
||||||
servers:
|
servers:
|
||||||
- url: /
|
- url: /
|
||||||
@@ -160,6 +160,68 @@ paths:
|
|||||||
$ref: '#/components/responses/Forbidden'
|
$ref: '#/components/responses/Forbidden'
|
||||||
'500':
|
'500':
|
||||||
$ref: '#/components/responses/InternalError'
|
$ref: '#/components/responses/InternalError'
|
||||||
|
/api/v1/vehicles/realtime/query:
|
||||||
|
post:
|
||||||
|
tags: [合作方数据接口]
|
||||||
|
summary: 查询授权车辆实时位置与状态
|
||||||
|
description: |
|
||||||
|
plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。
|
||||||
|
实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。
|
||||||
|
任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度和记录时间仍按上述来源优先级选择。
|
||||||
|
在线且所选来源速度大于3km/h为行驶中,否则为静止中。
|
||||||
|
operationId: queryRealtimeVehicles
|
||||||
|
security:
|
||||||
|
- AppKeyAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RealtimeVehicleQuery'
|
||||||
|
example: {}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 查询成功;没有实时记录的授权车辆以 NO_DATA 返回
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RealtimeVehicleQueryResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalError'
|
||||||
|
/api/v1/hydrogen-stations/query:
|
||||||
|
post:
|
||||||
|
tags: [合作方数据接口]
|
||||||
|
summary: 查询加氢站地图点位
|
||||||
|
description: 只读返回资产管理数据库内具有有效经纬度的加氢站,可按省、市和合作状态筛选;无需车辆授权但要求有效 appKey。
|
||||||
|
operationId: queryHydrogenStations
|
||||||
|
security:
|
||||||
|
- AppKeyAuth: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HydrogenStationQuery'
|
||||||
|
example: {}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 查询成功
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HydrogenStationQueryResponse'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalError'
|
||||||
/api/v2/open-platform/apps:
|
/api/v2/open-platform/apps:
|
||||||
get:
|
get:
|
||||||
tags: [开放平台管理]
|
tags: [开放平台管理]
|
||||||
@@ -430,6 +492,32 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [GB32960, YUTONG_MQTT, JT808]
|
enum: [GB32960, YUTONG_MQTT, JT808]
|
||||||
description: 可选;只接受平台统一协议标识;不传时按 GB32960 > YUTONG_MQTT > JT808
|
description: 可选;只接受平台统一协议标识;不传时按 GB32960 > YUTONG_MQTT > JT808
|
||||||
|
RealtimeVehicleQuery:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
plateNumbers:
|
||||||
|
type: array
|
||||||
|
maxItems: 2000
|
||||||
|
uniqueItems: true
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 32
|
||||||
|
description: 可选;省略或传空数组时查询当前有效授权的全部车辆
|
||||||
|
HydrogenStationQuery:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
province:
|
||||||
|
type: string
|
||||||
|
maxLength: 32
|
||||||
|
city:
|
||||||
|
type: string
|
||||||
|
maxLength: 32
|
||||||
|
cooperateOnly:
|
||||||
|
type: boolean
|
||||||
|
description: true仅合作站,false仅外部站,省略则返回全部
|
||||||
HydrogenResult:
|
HydrogenResult:
|
||||||
type: object
|
type: object
|
||||||
required: [plateNumber, date, hydrogenConsumptionKg, status]
|
required: [plateNumber, date, hydrogenConsumptionKg, status]
|
||||||
@@ -614,6 +702,55 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
$ref: '#/components/schemas/TotalMileageResult'
|
$ref: '#/components/schemas/TotalMileageResult'
|
||||||
|
RealtimeVehicleResult:
|
||||||
|
type: object
|
||||||
|
required: [vin, plateNumber, online, motionStatus, locationAvailable, status]
|
||||||
|
properties:
|
||||||
|
vin: { type: string }
|
||||||
|
plateNumber: { type: string }
|
||||||
|
protocol: { type: string, enum: [GB32960, YUTONG_MQTT, JT808] }
|
||||||
|
longitude: { type: number, format: double, nullable: true }
|
||||||
|
latitude: { type: number, format: double, nullable: true }
|
||||||
|
speedKmh: { type: number, format: double, nullable: true }
|
||||||
|
totalMileageKm: { type: number, format: double, nullable: true }
|
||||||
|
recordTime: { type: string }
|
||||||
|
timeDifferenceSeconds: { type: integer, format: int64, minimum: 0 }
|
||||||
|
online: { type: boolean, description: 任一采集协议是否在最近60秒内上报 }
|
||||||
|
motionStatus: { type: string, enum: [driving, idle, offline] }
|
||||||
|
locationAvailable: { type: boolean }
|
||||||
|
status: { $ref: '#/components/schemas/DataStatus' }
|
||||||
|
RealtimeVehicleQueryResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||||
|
- type: object
|
||||||
|
required: [data]
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/RealtimeVehicleResult' }
|
||||||
|
HydrogenStation:
|
||||||
|
type: object
|
||||||
|
required: [id, name, longitude, latitude, cooperative]
|
||||||
|
properties:
|
||||||
|
id: { type: string, description: 站点ID使用字符串避免JavaScript整数精度损失 }
|
||||||
|
name: { type: string }
|
||||||
|
shortName: { type: string }
|
||||||
|
address: { type: string }
|
||||||
|
longitude: { type: number, format: double }
|
||||||
|
latitude: { type: number, format: double }
|
||||||
|
province: { type: string }
|
||||||
|
city: { type: string }
|
||||||
|
district: { type: string }
|
||||||
|
cooperative: { type: boolean }
|
||||||
|
HydrogenStationQueryResponse:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/SuccessEnvelope'
|
||||||
|
- type: object
|
||||||
|
required: [data]
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/HydrogenStation' }
|
||||||
SuccessEnvelope:
|
SuccessEnvelope:
|
||||||
type: object
|
type: object
|
||||||
required: [code, message, traceId]
|
required: [code, message, traceId]
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ const (
|
|||||||
MileageQueryPath = "/api/v1/vehicles/mileage/query"
|
MileageQueryPath = "/api/v1/vehicles/mileage/query"
|
||||||
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
|
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
|
||||||
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
|
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
|
||||||
|
RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query"
|
||||||
|
HydrogenStationQueryPath = "/api/v1/hydrogen-stations/query"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
@@ -59,6 +61,8 @@ func (h *Handler) registerExternalDataRoutes() {
|
|||||||
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
|
||||||
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
|
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
|
||||||
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
|
h.mux.HandleFunc("POST "+TotalMileageQueryPath, h.totalMileage)
|
||||||
|
h.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, h.realtimeVehicles)
|
||||||
|
h.mux.HandleFunc("POST "+HydrogenStationQueryPath, h.hydrogenStations)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) registerAdminAppRoutes() {
|
func (h *Handler) registerAdminAppRoutes() {
|
||||||
@@ -106,6 +110,8 @@ func NewDataHandler(service *Service) *Handler {
|
|||||||
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
|
||||||
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
|
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
|
||||||
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
|
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
|
||||||
|
handler.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, handler.realtimeVehicles)
|
||||||
|
handler.mux.HandleFunc("POST "+HydrogenStationQueryPath, handler.hydrogenStations)
|
||||||
return handler
|
return handler
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +120,35 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func IsPublicPath(path string) bool {
|
func IsPublicPath(path string) bool {
|
||||||
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath
|
return path == HydrogenQueryPath || path == MileageQueryPath || path == MileageRangeQueryPath || path == TotalMileageQueryPath || path == RealtimeVehicleQueryPath || path == HydrogenStationQueryPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) realtimeVehicles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
traceID := externalTraceID(r)
|
||||||
|
var request RealtimeVehicleRequest
|
||||||
|
if !decodeExternalBody(w, r, traceID, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.service.QueryRealtimeVehicles(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) hydrogenStations(w http.ResponseWriter, r *http.Request) {
|
||||||
|
traceID := externalTraceID(r)
|
||||||
|
var request HydrogenStationRequest
|
||||||
|
if !decodeExternalBody(w, r, traceID, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := h.service.QueryHydrogenStations(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) hydrogen(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) hydrogen(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -725,5 +759,17 @@ func dataProducts() []DataProduct {
|
|||||||
Version: "v1", Status: "available", Method: http.MethodPost,
|
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||||
Path: TotalMileageQueryPath, Unit: "km",
|
Path: TotalMileageQueryPath, Unit: "km",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Code: "realtime_vehicle", Name: "车辆实时位置与状态",
|
||||||
|
Description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
|
||||||
|
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||||
|
Path: RealtimeVehicleQueryPath, Unit: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Code: "hydrogen_station", Name: "加氢站地图点位",
|
||||||
|
Description: "只读查询资产管理库中的加氢站名称、经纬度、行政区划和合作状态。",
|
||||||
|
Version: "v1", Status: "available", Method: http.MethodPost,
|
||||||
|
Path: HydrogenStationQueryPath, Unit: "座",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,56 @@ type TotalMileagePoint struct {
|
|||||||
TotalMileageKm float64
|
TotalMileageKm float64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RealtimeVehicleRequest struct {
|
||||||
|
PlateNumbers []string `json:"plateNumbers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RealtimeVehiclePoint struct {
|
||||||
|
VIN string
|
||||||
|
Protocol string
|
||||||
|
Longitude float64
|
||||||
|
Latitude float64
|
||||||
|
SpeedKmh float64
|
||||||
|
TotalMileageKm float64
|
||||||
|
ObservedAt time.Time
|
||||||
|
Online bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type RealtimeVehicleResult struct {
|
||||||
|
VIN string `json:"vin"`
|
||||||
|
PlateNumber string `json:"plateNumber"`
|
||||||
|
Protocol string `json:"protocol,omitempty"`
|
||||||
|
Longitude *float64 `json:"longitude"`
|
||||||
|
Latitude *float64 `json:"latitude"`
|
||||||
|
SpeedKmh *float64 `json:"speedKmh"`
|
||||||
|
TotalMileageKm *float64 `json:"totalMileageKm"`
|
||||||
|
RecordTime string `json:"recordTime,omitempty"`
|
||||||
|
TimeDifferenceSeconds *int64 `json:"timeDifferenceSeconds,omitempty"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
MotionStatus string `json:"motionStatus"`
|
||||||
|
LocationAvailable bool `json:"locationAvailable"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HydrogenStationRequest struct {
|
||||||
|
Province string `json:"province,omitempty"`
|
||||||
|
City string `json:"city,omitempty"`
|
||||||
|
CooperateOnly *bool `json:"cooperateOnly,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HydrogenStation struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ShortName string `json:"shortName,omitempty"`
|
||||||
|
Address string `json:"address,omitempty"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Province string `json:"province,omitempty"`
|
||||||
|
City string `json:"city,omitempty"`
|
||||||
|
District string `json:"district,omitempty"`
|
||||||
|
Cooperative bool `json:"cooperative"`
|
||||||
|
}
|
||||||
|
|
||||||
type ExternalResponse struct {
|
type ExternalResponse struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
|||||||
@@ -137,6 +137,97 @@ func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *MySQLRepository) RealtimeVehicles(ctx context.Context, vins []string, now time.Time) (map[string]RealtimeVehiclePoint, error) {
|
||||||
|
out := make(map[string]RealtimeVehiclePoint, len(vins))
|
||||||
|
if len(vins) == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
placeholders := strings.TrimRight(strings.Repeat("?,", len(vins)), ",")
|
||||||
|
args := make([]any, 0, len(vins)+1)
|
||||||
|
for _, vin := range vins {
|
||||||
|
args = append(args, vin)
|
||||||
|
}
|
||||||
|
args = append(args, now.Add(-10*time.Minute))
|
||||||
|
query := `
|
||||||
|
SELECT l.vin,l.protocol,COALESCE(l.longitude,0),COALESCE(l.latitude,0),
|
||||||
|
COALESCE(l.speed_kmh,0),COALESCE(l.total_mileage_km,0),l.updated_at
|
||||||
|
FROM vehicle_realtime_location l
|
||||||
|
WHERE BINARY l.vin IN (` + placeholders + `)
|
||||||
|
ORDER BY l.vin,
|
||||||
|
CASE WHEN l.updated_at>=? THEN 0 ELSE 1 END,
|
||||||
|
CASE l.protocol WHEN 'GB32960' THEN 10 WHEN 'YUTONG_MQTT' THEN 20 WHEN 'JT808' THEN 30 ELSE 100 END,
|
||||||
|
l.updated_at DESC,l.protocol ASC`
|
||||||
|
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
onlineThreshold := now.Add(-time.Minute)
|
||||||
|
for rows.Next() {
|
||||||
|
var point RealtimeVehiclePoint
|
||||||
|
if err := rows.Scan(&point.VIN, &point.Protocol, &point.Longitude, &point.Latitude, &point.SpeedKmh, &point.TotalMileageKm, &point.ObservedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
point.Online = !point.ObservedAt.Before(onlineThreshold)
|
||||||
|
if selected, exists := out[point.VIN]; !exists {
|
||||||
|
out[point.VIN] = point
|
||||||
|
} else if point.Online && !selected.Online {
|
||||||
|
// The selected source still follows the documented protocol priority, but
|
||||||
|
// online means that any source for this VIN reported in the last minute.
|
||||||
|
selected.Online = true
|
||||||
|
out[point.VIN] = selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MySQLRepository) HydrogenStations(ctx context.Context, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||||
|
where := []string{
|
||||||
|
"s.longitude BETWEEN -180 AND 180",
|
||||||
|
"s.latitude BETWEEN -90 AND 90",
|
||||||
|
"NOT (s.longitude=0 AND s.latitude=0)",
|
||||||
|
}
|
||||||
|
args := make([]any, 0, 3)
|
||||||
|
if request.Province != "" {
|
||||||
|
where = append(where, "s.province=?")
|
||||||
|
args = append(args, request.Province)
|
||||||
|
}
|
||||||
|
if request.City != "" {
|
||||||
|
where = append(where, "s.city=?")
|
||||||
|
args = append(args, request.City)
|
||||||
|
}
|
||||||
|
if request.CooperateOnly != nil {
|
||||||
|
if *request.CooperateOnly {
|
||||||
|
where = append(where, "s.inner_site_id IS NOT NULL")
|
||||||
|
} else {
|
||||||
|
where = append(where, "s.inner_site_id IS NULL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT CAST(s.id AS CHAR),COALESCE(NULLIF(s.fixed_station_name,''),NULLIF(s.station_name,''),''),
|
||||||
|
COALESCE(h.station_short_name,''),COALESCE(s.station_address,''),
|
||||||
|
s.longitude,s.latitude,COALESCE(s.province,''),COALESCE(s.city,''),COALESCE(s.district,''),
|
||||||
|
CASE WHEN s.inner_site_id IS NULL THEN 0 ELSE 1 END
|
||||||
|
FROM ln_asset_management.tab_outside_hydrogen_site s
|
||||||
|
LEFT JOIN ln_asset_management.hydrogen_station h ON h.id=s.inner_site_id AND h.del_flag='0'
|
||||||
|
WHERE `+strings.Join(where, " AND ")+`
|
||||||
|
ORDER BY s.province,s.city,s.fixed_station_name,s.id
|
||||||
|
LIMIT 2000`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
stations := make([]HydrogenStation, 0, 512)
|
||||||
|
for rows.Next() {
|
||||||
|
var station HydrogenStation
|
||||||
|
if err := rows.Scan(&station.ID, &station.Name, &station.ShortName, &station.Address, &station.Longitude, &station.Latitude, &station.Province, &station.City, &station.District, &station.Cooperative); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stations = append(stations, station)
|
||||||
|
}
|
||||||
|
return stations, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date string) (map[string]DailyHydrogen, error) {
|
func (r *MySQLRepository) DailyHydrogen(ctx context.Context, vins []string, date string) (map[string]DailyHydrogen, error) {
|
||||||
if len(vins) == 0 {
|
if len(vins) == 0 {
|
||||||
return map[string]DailyHydrogen{}, nil
|
return map[string]DailyHydrogen{}, nil
|
||||||
|
|||||||
@@ -185,6 +185,33 @@ func TestTotalMileageUsesProtocolPriorityAndLatestRecordAtOrBeforeTime(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRealtimeVehiclesAnyFreshProtocolKeepsSelectedSourceOnline(t *testing.T) {
|
||||||
|
db, mock, err := sqlmock.New()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
now := time.Date(2026, 8, 3, 21, 37, 30, 0, time.Local)
|
||||||
|
vin := "LTEST32960VIN0001"
|
||||||
|
mock.ExpectQuery("SELECT l.vin,l.protocol.*FROM vehicle_realtime_location").
|
||||||
|
WithArgs(vin, now.Add(-10*time.Minute)).
|
||||||
|
WillReturnRows(sqlmock.NewRows([]string{"vin", "protocol", "longitude", "latitude", "speed_kmh", "total_mileage_km", "updated_at"}).
|
||||||
|
AddRow(vin, "GB32960", 120.1, 30.2, 0, 1000, now.Add(-2*time.Minute)).
|
||||||
|
AddRow(vin, "JT808", 120.2, 30.3, 10, 0, now.Add(-20*time.Second)))
|
||||||
|
|
||||||
|
points, err := NewMySQLRepository(db).RealtimeVehicles(context.Background(), []string{vin}, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
point := points[vin]
|
||||||
|
if point.Protocol != "GB32960" || !point.Online || !point.ObservedAt.Equal(now.Add(-2*time.Minute)) {
|
||||||
|
t.Fatalf("selected source and aggregate online state mismatch: %#v", point)
|
||||||
|
}
|
||||||
|
if err := mock.ExpectationsWereMet(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListVehicleGrantsUsesBinaryVINJoin(t *testing.T) {
|
func TestListVehicleGrantsUsesBinaryVINJoin(t *testing.T) {
|
||||||
db, mock, err := sqlmock.New()
|
db, mock, err := sqlmock.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ type Repository interface {
|
|||||||
LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error)
|
LoadMileageSnapshot(context.Context, string, uint64, time.Time) (MileageSnapshot, error)
|
||||||
AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error)
|
AuthorizedVIN(context.Context, uint64, string, time.Time) (bool, error)
|
||||||
TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error)
|
TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error)
|
||||||
|
RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error)
|
||||||
|
HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error)
|
||||||
Audit(context.Context, uint64, string, string, string, int, string) 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)
|
CreateApp(context.Context, AppInput, [sha256.Size]byte, string, time.Time, *time.Time, string) (App, error)
|
||||||
@@ -48,6 +50,87 @@ type Repository interface {
|
|||||||
ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error)
|
ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID string, request RealtimeVehicleRequest) ([]RealtimeVehicleResult, error) {
|
||||||
|
now := s.now().In(s.location)
|
||||||
|
plates, err := normalizePlates(request.PlateNumbers, 2000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
app, vehicles, err := s.authorize(ctx, appKey, plates, now, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "denied", traceID, len(plates), err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(plates) == 0 {
|
||||||
|
plates = vehiclePlates(vehicles)
|
||||||
|
}
|
||||||
|
points, err := s.repository.RealtimeVehicles(ctx, vehicleVINs(vehicles), now)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "error", traceID, len(plates), err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results := make([]RealtimeVehicleResult, 0, len(plates))
|
||||||
|
for _, plate := range plates {
|
||||||
|
vehicle := vehicles[plate]
|
||||||
|
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData}
|
||||||
|
if point, ok := points[vehicle.VIN]; ok {
|
||||||
|
difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds())
|
||||||
|
if difference < 0 {
|
||||||
|
difference = 0
|
||||||
|
}
|
||||||
|
item.Protocol = point.Protocol
|
||||||
|
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
||||||
|
item.TimeDifferenceSeconds = &difference
|
||||||
|
item.Online = point.Online
|
||||||
|
item.MotionStatus = "offline"
|
||||||
|
if item.Online && point.SpeedKmh > 3 {
|
||||||
|
item.MotionStatus = "driving"
|
||||||
|
} else if item.Online {
|
||||||
|
item.MotionStatus = "idle"
|
||||||
|
}
|
||||||
|
speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
|
||||||
|
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
|
||||||
|
if validCoordinate(point.Longitude, point.Latitude) {
|
||||||
|
longitude, latitude := point.Longitude, point.Latitude
|
||||||
|
item.Longitude, item.Latitude = &longitude, &latitude
|
||||||
|
item.LocationAvailable = true
|
||||||
|
}
|
||||||
|
item.Status = StatusNormal
|
||||||
|
}
|
||||||
|
results = append(results, item)
|
||||||
|
}
|
||||||
|
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) QueryHydrogenStations(ctx context.Context, appKey, traceID string, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||||
|
now := s.now().In(s.location)
|
||||||
|
if !appKeyPattern.MatchString(appKey) {
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
request.Province = strings.TrimSpace(request.Province)
|
||||||
|
request.City = strings.TrimSpace(request.City)
|
||||||
|
if len([]rune(request.Province)) > 32 || len([]rune(request.City)) > 32 {
|
||||||
|
return nil, fmt.Errorf("%w: province or city too long", ErrInvalidRequest)
|
||||||
|
}
|
||||||
|
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), now, now, now)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.repository.Audit(ctx, 0, "hydrogen_station_query", "denied", traceID, 0, ErrUnauthorized.Error())
|
||||||
|
return nil, ErrUnauthorized
|
||||||
|
}
|
||||||
|
stations, err := s.repository.HydrogenStations(ctx, request)
|
||||||
|
if err != nil {
|
||||||
|
_ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "error", traceID, 0, err.Error())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "success", traceID, 0, "")
|
||||||
|
return stations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validCoordinate(longitude, latitude float64) bool {
|
||||||
|
return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && !(longitude == 0 && latitude == 0)
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
repository Repository
|
repository Repository
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ type fakeRepository struct {
|
|||||||
priorMileage map[string]DailyMileage
|
priorMileage map[string]DailyMileage
|
||||||
authorizedVIN bool
|
authorizedVIN bool
|
||||||
totalMileage *TotalMileagePoint
|
totalMileage *TotalMileagePoint
|
||||||
|
realtime map[string]RealtimeVehiclePoint
|
||||||
|
stations []HydrogenStation
|
||||||
audits []string
|
audits []string
|
||||||
createdHash [sha256.Size]byte
|
createdHash [sha256.Size]byte
|
||||||
createdPrefix string
|
createdPrefix string
|
||||||
@@ -78,10 +80,79 @@ func (f *fakeRepository) AuthorizedVIN(context.Context, uint64, string, time.Tim
|
|||||||
func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) {
|
func (f *fakeRepository) TotalMileage(context.Context, string, time.Time, []string) (*TotalMileagePoint, error) {
|
||||||
return f.totalMileage, nil
|
return f.totalMileage, nil
|
||||||
}
|
}
|
||||||
|
func (f *fakeRepository) RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error) {
|
||||||
|
return f.realtime, nil
|
||||||
|
}
|
||||||
|
func (f *fakeRepository) HydrogenStations(context.Context, HydrogenStationRequest) ([]HydrogenStation, error) {
|
||||||
|
return f.stations, nil
|
||||||
|
}
|
||||||
func (f *fakeRepository) Audit(_ context.Context, _ uint64, endpoint, result, _ string, _ int, _ string) error {
|
func (f *fakeRepository) Audit(_ context.Context, _ uint64, endpoint, result, _ string, _ int, _ string) error {
|
||||||
f.audits = append(f.audits, endpoint+":"+result)
|
f.audits = append(f.audits, endpoint+":"+result)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||||
|
repository := &fakeRepository{
|
||||||
|
app: AppCredential{ID: 9, Name: "vehicle-map"},
|
||||||
|
vehicles: map[string]AuthorizedVehicle{
|
||||||
|
"浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"},
|
||||||
|
"浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"},
|
||||||
|
},
|
||||||
|
realtime: map[string]RealtimeVehiclePoint{
|
||||||
|
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, SpeedKmh: 42.5, TotalMileageKm: 12345.6, ObservedAt: now.Add(-30 * time.Second), Online: true},
|
||||||
|
},
|
||||||
|
stations: []HydrogenStation{{ID: "1", Name: "测试加氢站", Longitude: 120.2, Latitude: 30.3}},
|
||||||
|
}
|
||||||
|
service := NewService(repository)
|
||||||
|
service.now = func() time.Time { return now }
|
||||||
|
const key = "0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
vehicles, err := service.QueryRealtimeVehicles(context.Background(), key, "trace-v", RealtimeVehicleRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(vehicles) != 2 || !vehicles[0].Online || vehicles[0].MotionStatus != "driving" || !vehicles[0].LocationAvailable {
|
||||||
|
t.Fatalf("unexpected realtime vehicles: %#v", vehicles)
|
||||||
|
}
|
||||||
|
if vehicles[1].Status != StatusNoData || vehicles[1].MotionStatus != "offline" {
|
||||||
|
t.Fatalf("missing realtime row should remain explicit: %#v", vehicles[1])
|
||||||
|
}
|
||||||
|
stations, err := service.QueryHydrogenStations(context.Background(), key, "trace-s", HydrogenStationRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(stations) != 1 || stations[0].Name != "测试加氢站" {
|
||||||
|
t.Fatalf("unexpected stations: %#v", stations)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealtimeVehicleOnlineUsesAnyFreshProtocol(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
|
||||||
|
repository := &fakeRepository{
|
||||||
|
app: AppCredential{ID: 9, Name: "vehicle-map"},
|
||||||
|
vehicles: map[string]AuthorizedVehicle{
|
||||||
|
"浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"},
|
||||||
|
},
|
||||||
|
realtime: map[string]RealtimeVehiclePoint{
|
||||||
|
// The selected GB32960 point can be older while JT808/MQTT keeps the VIN online.
|
||||||
|
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, ObservedAt: now.Add(-2 * time.Minute), Online: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
service := NewService(repository)
|
||||||
|
service.now = func() time.Time { return now }
|
||||||
|
|
||||||
|
vehicles, err := service.QueryRealtimeVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace-any-source", RealtimeVehicleRequest{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(vehicles) != 1 || !vehicles[0].Online || vehicles[0].MotionStatus != "idle" {
|
||||||
|
t.Fatalf("fresh alternate protocol should keep vehicle online: %#v", vehicles)
|
||||||
|
}
|
||||||
|
if vehicles[0].TimeDifferenceSeconds == nil || *vehicles[0].TimeDifferenceSeconds != 120 {
|
||||||
|
t.Fatalf("selected source record time must remain auditable: %#v", vehicles[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
func (f *fakeRepository) CreateApp(_ context.Context, input AppInput, hash [sha256.Size]byte, prefix string, from time.Time, to *time.Time, actor string) (App, error) {
|
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
|
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
|
return App{ID: 1, Name: input.Name, AppKeyPrefix: prefix, Status: input.Status, ValidFrom: from, ValidTo: to, CreatedBy: actor}, nil
|
||||||
|
|||||||
@@ -40,10 +40,40 @@ export const products: Product[] = [
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
path: "/api/v1/vehicles/total-mileage/query",
|
path: "/api/v1/vehicles/total-mileage/query",
|
||||||
unit: "km"
|
unit: "km"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "realtime_vehicle",
|
||||||
|
name: "车辆实时位置与状态",
|
||||||
|
description: "查询应用授权车辆的最新位置、在线状态、速度、总里程和采集协议。",
|
||||||
|
version: "v1",
|
||||||
|
status: "available",
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/v1/vehicles/realtime/query",
|
||||||
|
unit: ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "hydrogen_station",
|
||||||
|
name: "加氢站地图点位",
|
||||||
|
description: "只读查询资产管理库加氢站的名称、经纬度、行政区划和合作状态。",
|
||||||
|
version: "v1",
|
||||||
|
status: "available",
|
||||||
|
method: "POST",
|
||||||
|
path: "/api/v1/hydrogen-stations/query",
|
||||||
|
unit: "座"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export const curlExample = (product: Product) => {
|
export const curlExample = (product: Product) => {
|
||||||
|
if (product.code === "realtime_vehicle") return `curl --request POST \\
|
||||||
|
--url https://open.d.lnoneos.com${product.path} \\
|
||||||
|
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data '{}'`;
|
||||||
|
if (product.code === "hydrogen_station") return `curl --request POST \\
|
||||||
|
--url https://open.d.lnoneos.com${product.path} \\
|
||||||
|
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||||
|
--header 'Content-Type: application/json' \\
|
||||||
|
--data '{"province":"浙江省"}'`;
|
||||||
if (product.code === "total_mileage_at_time") return `curl --request POST \\
|
if (product.code === "total_mileage_at_time") return `curl --request POST \\
|
||||||
--url https://open.d.lnoneos.com${product.path} \\
|
--url https://open.d.lnoneos.com${product.path} \\
|
||||||
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
--header 'Authorization: Bearer YOUR_APP_KEY' \\
|
||||||
|
|||||||
Reference in New Issue
Block a user