From 8539af13e6248dceb78b80e2330b681b53d50d7d Mon Sep 17 00:00:00 2001 From: lingniu Date: Mon, 3 Aug 2026 19:55:38 +0800 Subject: [PATCH] feat: expose vehicle map data APIs --- .../internal/openplatform/assets/docs.html | 13 ++ .../internal/openplatform/assets/openapi.yaml | 140 +++++++++++++++++- .../apps/api/internal/openplatform/handler.go | 56 ++++++- .../apps/api/internal/openplatform/model.go | 49 ++++++ .../apps/api/internal/openplatform/mysql.go | 84 +++++++++++ .../apps/api/internal/openplatform/service.go | 83 +++++++++++ .../api/internal/openplatform/service_test.go | 44 ++++++ .../apps/open-portal/src/catalog.ts | 30 ++++ 8 files changed, 492 insertions(+), 7 deletions(-) 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

查询日没有有效里程但此前存在有效累计里程时,日里程补 0,累计总里程、来源协议和数据时间沿用最近有效统计;updatedAt 显示上一个统计周期的计算时间。

POST/api/v1/vehicles/total-mileage/query

按 VIN 和北京时间查询不晚于指定时刻的最近一条总里程,返回实际采集协议、记录时间和时间差秒数。

+

POST/api/v1/vehicles/realtime/query

+

查询当前有效授权车辆的最新位置、在线及运动状态、速度、累计里程、实际协议和记录时间。请求体传 {} 返回全部授权车辆。

+

POST/api/v1/hydrogen-stations/query

+

只读查询资产管理库中有有效坐标的加氢站;可按 provincecitycooperateOnly 筛选。

总里程协议口径

@@ -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' \\