diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html
index 3ff47126..5a15285c 100644
--- a/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html
+++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/docs.html
@@ -45,6 +45,10 @@ Content-Type: application/json
@@ -100,6 +104,15 @@ Content-Type: application/json
}'
下一页保持原请求参数不变,并传入上一页 nextCursor;同一次分页查询的 snapshotId 保持不变。
+ 实时车辆与全部加氢站
+ 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 '{}'
+
响应示例
车辆单日里程
{
diff --git a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml
index da20ba74..acf59ccf 100644
--- a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml
+++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml
@@ -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,67 @@ 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秒视为在线;在线且速度大于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 +491,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 +701,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 }
+ 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]
diff --git a/vehicle-data-platform/apps/api/internal/openplatform/handler.go b/vehicle-data-platform/apps/api/internal/openplatform/handler.go
index ce89543f..a8e48eb1 100644
--- a/vehicle-data-platform/apps/api/internal/openplatform/handler.go
+++ b/vehicle-data-platform/apps/api/internal/openplatform/handler.go
@@ -17,10 +17,12 @@ import (
)
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"
+ 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"
+ 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: "座",
+ },
}
}
diff --git a/vehicle-data-platform/apps/api/internal/openplatform/model.go b/vehicle-data-platform/apps/api/internal/openplatform/model.go
index a78486eb..955ba9da 100644
--- a/vehicle-data-platform/apps/api/internal/openplatform/model.go
+++ b/vehicle-data-platform/apps/api/internal/openplatform/model.go
@@ -101,6 +101,55 @@ 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
+}
+
+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"`
diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go
index e7ebba3f..799238a8 100644
--- a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go
+++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go
@@ -137,6 +137,90 @@ 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()
+ 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
+ }
+ if _, exists := out[point.VIN]; !exists {
+ out[point.VIN] = point
+ }
+ }
+ 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
diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service.go b/vehicle-data-platform/apps/api/internal/openplatform/service.go
index 0890b6d3..90d1f410 100644
--- a/vehicle-data-platform/apps/api/internal/openplatform/service.go
+++ b/vehicle-data-platform/apps/api/internal/openplatform/service.go
@@ -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 = difference <= 60
+ 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
diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go
index accfadb5..5936d106 100644
--- a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go
+++ b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go
@@ -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,52 @@ 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)},
+ },
+ 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 (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
diff --git a/vehicle-data-platform/apps/open-portal/src/catalog.ts b/vehicle-data-platform/apps/open-portal/src/catalog.ts
index 8ae26741..a0d11535 100644
--- a/vehicle-data-platform/apps/open-portal/src/catalog.ts
+++ b/vehicle-data-platform/apps/open-portal/src/catalog.ts
@@ -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' \\