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 acf59ccf..52413691 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml +++ b/vehicle-data-platform/apps/api/internal/openplatform/assets/openapi.yaml @@ -167,7 +167,8 @@ paths: description: | plateNumbers 省略或传空数组时返回应用当前有效授权的全部车辆。 实时来源优先级为 GB32960 > YUTONG_MQTT > JT808;所有来源超过10分钟时改按最新记录选择。 - 记录时间距当前不超过60秒视为在线;在线且速度大于3km/h为行驶中,否则为静止中。 + 任一采集协议在最近60秒内上报即视为在线;protocol、位置、速度和记录时间仍按上述来源优先级选择。 + 在线且所选来源速度大于3km/h为行驶中,否则为静止中。 operationId: queryRealtimeVehicles security: - AppKeyAuth: [] @@ -714,7 +715,7 @@ components: totalMileageKm: { type: number, format: double, nullable: true } recordTime: { type: string } timeDifferenceSeconds: { type: integer, format: int64, minimum: 0 } - online: { type: boolean } + online: { type: boolean, description: 任一采集协议是否在最近60秒内上报 } motionStatus: { type: string, enum: [driving, idle, offline] } locationAvailable: { type: boolean } status: { $ref: '#/components/schemas/DataStatus' } diff --git a/vehicle-data-platform/apps/api/internal/openplatform/model.go b/vehicle-data-platform/apps/api/internal/openplatform/model.go index 955ba9da..98848d9a 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/model.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/model.go @@ -113,6 +113,7 @@ type RealtimeVehiclePoint struct { SpeedKmh float64 TotalMileageKm float64 ObservedAt time.Time + Online bool } type RealtimeVehicleResult struct { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go index 799238a8..6bbe9cfb 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/mysql.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql.go @@ -162,13 +162,20 @@ ORDER BY l.vin, 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 } - if _, exists := out[point.VIN]; !exists { + 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() diff --git a/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go b/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go index f53f7793..9d979085 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/mysql_test.go @@ -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 { diff --git a/vehicle-data-platform/apps/api/internal/openplatform/service.go b/vehicle-data-platform/apps/api/internal/openplatform/service.go index 90d1f410..7e39a123 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service.go @@ -81,7 +81,7 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str 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.Online = point.Online item.MotionStatus = "offline" if item.Online && point.SpeedKmh > 3 { item.MotionStatus = "driving" 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 5936d106..11ad5744 100644 --- a/vehicle-data-platform/apps/api/internal/openplatform/service_test.go +++ b/vehicle-data-platform/apps/api/internal/openplatform/service_test.go @@ -100,7 +100,7 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) { "浙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)}, + "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}}, } @@ -126,6 +126,33 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) { 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