fix(open-platform): align realtime online status across protocols

This commit is contained in:
lingniu
2026-08-03 21:49:33 +08:00
parent 8539af13e6
commit 22f3904ad7
6 changed files with 68 additions and 5 deletions

View File

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

View File

@@ -113,6 +113,7 @@ type RealtimeVehiclePoint struct {
SpeedKmh float64
TotalMileageKm float64
ObservedAt time.Time
Online bool
}
type RealtimeVehicleResult struct {

View File

@@ -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()

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

@@ -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"

View File

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