2 Commits

Author SHA1 Message Date
lingniu
22f3904ad7 fix(open-platform): align realtime online status across protocols 2026-08-03 21:49:33 +08:00
lingniu
8539af13e6 feat: expose vehicle map data APIs 2026-08-03 19:55:38 +08:00
9 changed files with 555 additions and 7 deletions

View File

@@ -45,6 +45,10 @@ Content-Type: application/json</pre>
<p class="note">查询日没有有效里程但此前存在有效累计里程时,日里程补 0累计总里程、来源协议和数据时间沿用最近有效统计updatedAt 显示上一个统计周期的计算时间。</p>
<h3><span class="method">POST</span>/api/v1/vehicles/total-mileage/query</h3>
<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>
<table>
@@ -100,6 +104,15 @@ Content-Type: application/json</pre>
}'</pre>
<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>
<h3>车辆单日里程</h3>
<pre>{

View File

@@ -1,11 +1,11 @@
openapi: 3.0.3
info:
title: 车辆数据开放平台 API
version: 1.5.0
version: 1.6.0
license:
name: Proprietary
description: |
向授权合作方开放车辆单日用氢量、单日里程、区间日里程指定时刻总里程。
向授权合作方开放车辆单日用氢量、单日里程、区间日里程指定时刻总里程、实时位置状态及加氢站地图点位
appKey 和逐车授权必须完整覆盖查询自然日。
servers:
- url: /
@@ -160,6 +160,68 @@ paths:
$ref: '#/components/responses/Forbidden'
'500':
$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:
get:
tags: [开放平台管理]
@@ -430,6 +492,32 @@ components:
type: string
enum: [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:
type: object
required: [plateNumber, date, hydrogenConsumptionKg, status]
@@ -614,6 +702,55 @@ components:
properties:
data:
$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:
type: object
required: [code, message, traceId]

View File

@@ -21,6 +21,8 @@ const (
MileageQueryPath = "/api/v1/vehicles/mileage/query"
MileageRangeQueryPath = "/api/v1/vehicles/mileage/range/query"
TotalMileageQueryPath = "/api/v1/vehicles/total-mileage/query"
RealtimeVehicleQueryPath = "/api/v1/vehicles/realtime/query"
HydrogenStationQueryPath = "/api/v1/hydrogen-stations/query"
)
type Handler struct {
@@ -59,6 +61,8 @@ func (h *Handler) registerExternalDataRoutes() {
h.mux.HandleFunc("POST "+MileageQueryPath, h.mileage)
h.mux.HandleFunc("POST "+MileageRangeQueryPath, h.mileageRange)
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() {
@@ -106,6 +110,8 @@ func NewDataHandler(service *Service) *Handler {
handler.mux.HandleFunc("POST "+MileageQueryPath, handler.mileage)
handler.mux.HandleFunc("POST "+MileageRangeQueryPath, handler.mileageRange)
handler.mux.HandleFunc("POST "+TotalMileageQueryPath, handler.totalMileage)
handler.mux.HandleFunc("POST "+RealtimeVehicleQueryPath, handler.realtimeVehicles)
handler.mux.HandleFunc("POST "+HydrogenStationQueryPath, handler.hydrogenStations)
return handler
}
@@ -114,7 +120,35 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
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) {
@@ -725,5 +759,17 @@ func dataProducts() []DataProduct {
Version: "v1", Status: "available", Method: http.MethodPost,
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: "座",
},
}
}

View File

@@ -101,6 +101,56 @@ type TotalMileagePoint struct {
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 {
Code string `json:"code"`
Message string `json:"message"`

View File

@@ -137,6 +137,97 @@ func (r *MySQLRepository) TotalMileage(ctx context.Context, vin string, at time.
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) {
if len(vins) == 0 {
return map[string]DailyHydrogen{}, nil

View File

@@ -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) {
db, mock, err := sqlmock.New()
if err != nil {

View File

@@ -38,6 +38,8 @@ type Repository interface {
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)
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
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)
}
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 {
repository Repository
now func() time.Time

View File

@@ -21,6 +21,8 @@ type fakeRepository struct {
priorMileage map[string]DailyMileage
authorizedVIN bool
totalMileage *TotalMileagePoint
realtime map[string]RealtimeVehiclePoint
stations []HydrogenStation
audits []string
createdHash [sha256.Size]byte
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) {
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 {
f.audits = append(f.audits, endpoint+":"+result)
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) {
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

View File

@@ -40,10 +40,40 @@ export const products: Product[] = [
method: "POST",
path: "/api/v1/vehicles/total-mileage/query",
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) => {
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 \\
--url https://open.d.lnoneos.com${product.path} \\
--header 'Authorization: Bearer YOUR_APP_KEY' \\