功能:扩展开放平台氢耗溯源与合作站数据

This commit is contained in:
lingniu
2026-09-02 14:05:34 +08:00
parent 8759668d39
commit c6875fbb98
33 changed files with 5692 additions and 475 deletions
@@ -12,27 +12,32 @@ import (
"time"
)
func float64Pointer(value float64) *float64 { return &value }
type fakeRepository struct {
app AppCredential
authErr error
vehicles map[string]AuthorizedVehicle
hydrogen map[string]DailyHydrogen
mileage map[string]DailyMileage
priorMileage map[string]DailyMileage
authorizedVIN bool
totalMileage *TotalMileagePoint
realtime map[string]RealtimeVehiclePoint
stations []HydrogenStation
audits []string
createdHash [sha256.Size]byte
createdPrefix string
requestedPlates []string
dailyVINs []string
dailyProtocols []string
rangeVINs []string
priorVINs []string
priorCalls int
snapshot MileageSnapshot
app AppCredential
authErr error
vehicles map[string]AuthorizedVehicle
hydrogen map[string]DailyHydrogen
mileage map[string]DailyMileage
priorMileage map[string]DailyMileage
rollbacks map[string]bool
authorizedVIN bool
totalMileage *TotalMileagePoint
realtime map[string]RealtimeVehiclePoint
stationary []StationaryLocationPoint
stations []HydrogenStation
audits []string
createdHash [sha256.Size]byte
createdPrefix string
requestedPlates []string
dailyVINs []string
dailyProtocols []string
rangeVINs []string
priorVINs []string
priorCalls int
snapshot MileageSnapshot
stationaryRadius float64
}
func (f *fakeRepository) Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error) {
@@ -64,6 +69,9 @@ func (f *fakeRepository) LatestMileageBefore(_ context.Context, vins []string, _
f.dailyProtocols = append([]string(nil), protocols...)
return f.priorMileage, nil
}
func (f *fakeRepository) MileageRollbacks(context.Context, []string, string, string, []string) (map[string]bool, error) {
return f.rollbacks, nil
}
func (f *fakeRepository) CreateMileageSnapshot(_ context.Context, snapshot MileageSnapshot) error {
f.snapshot = snapshot
return nil
@@ -80,6 +88,10 @@ 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) StationaryLocationPoints(_ context.Context, _ []string, _ time.Time, _ time.Time, _ float64, _ float64, radiusMeters float64, _ float64) ([]StationaryLocationPoint, error) {
f.stationaryRadius = radiusMeters
return f.stationary, nil
}
func (f *fakeRepository) RealtimeVehicles(context.Context, []string, time.Time) (map[string]RealtimeVehiclePoint, error) {
return f.realtime, nil
}
@@ -100,7 +112,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), Online: true},
"LTEST32960VIN0001": {VIN: "LTEST32960VIN0001", Protocol: "GB32960", Longitude: 120.1, Latitude: 30.2, SpeedKmh: 42.5, SOCPercent: float64Pointer(78.5), TotalMileageKm: 12345.6, ObservedAt: now.Add(-30 * time.Second), Online: true},
},
stations: []HydrogenStation{{ID: "1", Name: "测试加氢站", Longitude: 120.2, Latitude: 30.3}},
}
@@ -112,7 +124,7 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(vehicles) != 2 || !vehicles[0].Online || vehicles[0].MotionStatus != "driving" || !vehicles[0].LocationAvailable {
if len(vehicles) != 2 || !vehicles[0].Online || vehicles[0].MotionStatus != "driving" || !vehicles[0].LocationAvailable || vehicles[0].Protocol != "GB32960" || vehicles[0].SOCPercent == nil || *vehicles[0].SOCPercent != 78.5 {
t.Fatalf("unexpected realtime vehicles: %#v", vehicles)
}
if vehicles[1].Status != StatusNoData || vehicles[1].MotionStatus != "offline" {
@@ -127,6 +139,76 @@ func TestRealtimeVehicleAndHydrogenStationQueries(t *testing.T) {
}
}
func TestStationaryVehicleQueryBuildsAndRanksStayMatches(t *testing.T) {
location := time.FixedZone("CST", 8*3600)
start := time.Date(2026, 8, 6, 10, 0, 0, 0, location)
repository := &fakeRepository{
app: AppCredential{ID: 9, Name: "refuel-check"},
vehicles: map[string]AuthorizedVehicle{
"浙A12345": {VIN: "LTEST32960VIN0001", Plate: "浙A12345"},
"浙B67890": {VIN: "LTEST32960VIN0002", Plate: "浙B67890"},
},
stationary: []StationaryLocationPoint{
{VIN: "LTEST32960VIN0002", Protocol: "JT808", ObservedAt: start.Add(10 * time.Minute), Longitude: 120.00001, Latitude: 30.00001, SpeedKmh: 0},
{VIN: "LTEST32960VIN0002", Protocol: "JT808", ObservedAt: start.Add(13 * time.Minute), Longitude: 120.00001, Latitude: 30.00001, SpeedKmh: 0.1},
{VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: start.Add(time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0},
{VIN: "LTEST32960VIN0001", Protocol: "GB32960", ObservedAt: start.Add(5 * time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0},
{VIN: "LTEST32960VIN0001", Protocol: "YUTONG_MQTT", ObservedAt: start.Add(6 * time.Minute), Longitude: 120.000002, Latitude: 30.000002, SpeedKmh: 0},
},
}
service := NewService(repository)
service.now = func() time.Time { return start }
radiusMeters := 8.0
values, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace-stationary", StationaryVehicleQueryRequest{
StartTime: start.Format("2006-01-02 15:04:05"), EndTime: start.Add(time.Hour).Format("2006-01-02 15:04:05"), Longitude: 120, Latitude: 30, RadiusMeters: &radiusMeters,
})
if err != nil {
t.Fatal(err)
}
if len(values) != 2 || values[0].VIN != "LTEST32960VIN0001" || values[0].StayDurationSeconds != 300 || values[0].MatchedSamples != 3 || values[0].SourceProtocols[0] != "GB32960" || values[0].SourceProtocols[1] != "MQTT" || values[0].MatchScore <= values[1].MatchScore {
t.Fatalf("stationary matches=%#v", values)
}
if repository.stationaryRadius != radiusMeters {
t.Fatalf("stationary radius=%v, want %v", repository.stationaryRadius, radiusMeters)
}
}
func TestStationaryVehicleQueryRejectsInvalidWindowAndCoordinate(t *testing.T) {
service := NewService(&fakeRepository{})
if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-07 10:00:01", Longitude: 120, Latitude: 30}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("window error=%v", err)
}
if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-06 10:10:00", Longitude: 0, Latitude: 0}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("coordinate error=%v", err)
}
overRadius := 101.0
if _, err := service.QueryStationaryVehicles(context.Background(), "0123456789abcdef0123456789abcdef", "trace", StationaryVehicleQueryRequest{StartTime: "2026-08-06 10:00:00", EndTime: "2026-08-06 10:10:00", Longitude: 120, Latitude: 30, RadiusMeters: &overRadius}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("radius error=%v", err)
}
}
func TestStationaryVehicleQueryConvertsGCJ02Coordinate(t *testing.T) {
service := NewService(&fakeRepository{})
_, _, _, longitude, latitude, _, err := service.validateStationaryVehicleQuery(StationaryVehicleQueryRequest{
StartTime: "2026-08-06 17:53:01",
EndTime: "2026-08-06 17:55:01",
Longitude: 121.045455,
Latitude: 30.640433,
CoordinateSystem: "gcj02",
})
if err != nil {
t.Fatal(err)
}
if longitude < 121.0409 || longitude > 121.0411 || latitude < 30.6425 || latitude > 30.6428 {
t.Fatalf("converted coordinate=%f,%f", longitude, latitude)
}
if _, _, _, _, _, _, err := service.validateStationaryVehicleQuery(StationaryVehicleQueryRequest{
StartTime: "2026-08-06 17:53:01", EndTime: "2026-08-06 17:55:01", Longitude: 121.045455, Latitude: 30.640433, CoordinateSystem: "BD09",
}); !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("coordinateSystem error=%v", err)
}
}
func TestRealtimeVehicleOnlineUsesAnyFreshProtocol(t *testing.T) {
now := time.Date(2026, 8, 3, 19, 30, 0, 0, time.FixedZone("CST", 8*3600))
repository := &fakeRepository{
@@ -337,6 +419,28 @@ func TestMileageCarriesForwardPreviousTotalAndPreviousCalculationTime(t *testing
}
}
func TestMileageRollbackIsNotExposedAsNormalCarriedData(t *testing.T) {
const vin = "LNXNEGRR1SR321395"
repository := &fakeRepository{
app: AppCredential{ID: 7},
vehicles: map[string]AuthorizedVehicle{"粤A08190F": {VIN: vin, Plate: "粤A08190F"}},
priorMileage: map[string]DailyMileage{
vin: {VIN: vin, Date: "2026-08-05", Protocol: "GB32960", TotalMileageKm: 48233.3, DataTime: "2026-08-05T23:00:00+08:00", UpdatedAt: "2026-08-05T23:00:01+08:00"},
},
rollbacks: map[string]bool{dailyMileageKey(vin, "2026-08-06"): true},
}
service := NewService(repository)
result, err := service.QueryMileage(context.Background(), "0123456789abcdef0123456789abcdef", "trace-rollback", QueryRequest{
PlateNumbers: []string{"粤A08190F"}, Date: "2026-08-06",
})
if err != nil {
t.Fatal(err)
}
if len(result) != 1 || result[0].Status != StatusDataAnomaly || result[0].DataQuality == nil || *result[0].DataQuality != mileageTotalRollbackQuality || result[0].TotalMileageKm != nil || repository.priorCalls != 0 {
t.Fatalf("rollback result=%#v priorCalls=%d", result, repository.priorCalls)
}
}
func TestMileageRangeUsesStableSnapshotAndDistinguishesZeroFromNoData(t *testing.T) {
repository := &fakeRepository{
app: AppCredential{ID: 7, Name: "partner"},
@@ -577,7 +681,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
allRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
allResponse := httptest.NewRecorder()
handler.ServeHTTP(allResponse, allRequest)
if allResponse.Code != http.StatusOK || !strings.Contains(allResponse.Body.String(), `"plateNumber":"粤A12345"`) {
if allResponse.Code != http.StatusOK || !strings.Contains(allResponse.Body.String(), `"plateNumber": "粤A12345"`) {
t.Fatalf("status=%d body=%s", allResponse.Code, allResponse.Body.String())
}
@@ -585,7 +689,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
rangeRequest.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
rangeResponse := httptest.NewRecorder()
handler.ServeHTTP(rangeResponse, rangeRequest)
if rangeResponse.Code != http.StatusOK || !strings.Contains(rangeResponse.Body.String(), `"snapshotId"`) || !strings.Contains(rangeResponse.Body.String(), `"nextCursor":null`) {
if rangeResponse.Code != http.StatusOK || !strings.Contains(rangeResponse.Body.String(), `"snapshotId"`) || !strings.Contains(rangeResponse.Body.String(), `"nextCursor": null`) {
t.Fatalf("status=%d body=%s", rangeResponse.Code, rangeResponse.Body.String())
}
@@ -593,7 +697,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
badDate.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
badResponse := httptest.NewRecorder()
handler.ServeHTTP(badResponse, badDate)
if badResponse.Code != http.StatusBadRequest || !strings.Contains(badResponse.Body.String(), `"code":"INVALID_DATE_FORMAT"`) {
if badResponse.Code != http.StatusBadRequest || !strings.Contains(badResponse.Body.String(), `"code": "INVALID_DATE_FORMAT"`) {
t.Fatalf("status=%d body=%s", badResponse.Code, badResponse.Body.String())
}
@@ -602,8 +706,8 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
badProtocolResponse := httptest.NewRecorder()
handler.ServeHTTP(badProtocolResponse, badProtocol)
if badProtocolResponse.Code != http.StatusBadRequest ||
!strings.Contains(badProtocolResponse.Body.String(), `"message":"protocolPriority不能包含重复协议"`) ||
!strings.Contains(badProtocolResponse.Body.String(), `"traceId":"`) {
!strings.Contains(badProtocolResponse.Body.String(), `"message": "protocolPriority不能包含重复协议"`) ||
!strings.Contains(badProtocolResponse.Body.String(), `"traceId": "`) {
t.Fatalf("status=%d body=%s", badProtocolResponse.Code, badProtocolResponse.Body.String())
}
@@ -611,7 +715,7 @@ func TestExternalHandlerUsesDocumentEnvelopeAndErrors(t *testing.T) {
nullProtocol.Header.Set("Authorization", "Bearer 0123456789abcdef0123456789abcdef")
nullProtocolResponse := httptest.NewRecorder()
handler.ServeHTTP(nullProtocolResponse, nullProtocol)
if nullProtocolResponse.Code != http.StatusBadRequest || !strings.Contains(nullProtocolResponse.Body.String(), `"message":"protocolPriority不能为空"`) {
if nullProtocolResponse.Code != http.StatusBadRequest || !strings.Contains(nullProtocolResponse.Body.String(), `"message": "protocolPriority不能为空"`) {
t.Fatalf("status=%d body=%s", nullProtocolResponse.Code, nullProtocolResponse.Body.String())
}
}