功能:扩展开放平台氢耗溯源与合作站数据
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -34,10 +35,12 @@ type Repository interface {
|
||||
DailyMileage(context.Context, []string, string, []string) (map[string]DailyMileage, error)
|
||||
DailyMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error)
|
||||
LatestMileageBefore(context.Context, []string, string, []string) (map[string]DailyMileage, error)
|
||||
MileageRollbacks(context.Context, []string, string, string, []string) (map[string]bool, error)
|
||||
CreateMileageSnapshot(context.Context, MileageSnapshot) error
|
||||
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)
|
||||
StationaryLocationPoints(context.Context, []string, time.Time, time.Time, float64, float64, float64, float64) ([]StationaryLocationPoint, 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
|
||||
@@ -50,6 +53,215 @@ type Repository interface {
|
||||
ListVehicleGrants(context.Context, uint64) ([]VehicleGrant, error)
|
||||
}
|
||||
|
||||
const (
|
||||
stationaryCoordinateWGS84 = "WGS84"
|
||||
stationaryCoordinateGCJ02 = "GCJ02"
|
||||
stationaryDefaultRadiusMeters = 5.0
|
||||
stationaryMinimumRadiusMeters = 1.0
|
||||
stationaryMaximumRadiusMeters = 100.0
|
||||
stationaryMaxSpeedKmh = 3.0
|
||||
stationaryMinimumStay = time.Minute
|
||||
stationarySegmentGap = 10 * time.Minute
|
||||
stationaryQueryMaximumWindow = 24 * time.Hour
|
||||
)
|
||||
|
||||
func (s *Service) QueryStationaryVehicles(ctx context.Context, appKey, traceID string, request StationaryVehicleQueryRequest) ([]StationaryVehicleResult, error) {
|
||||
plates, start, end, longitude, latitude, radiusMeters, err := s.validateStationaryVehicleQuery(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
app, vehicles, err := s.authorize(ctx, appKey, plates, start, end)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "denied", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if len(plates) == 0 {
|
||||
plates = vehiclePlates(vehicles)
|
||||
}
|
||||
points, err := s.repository.StationaryLocationPoints(ctx, vehicleVINs(vehicles), start, end, longitude, latitude, radiusMeters, stationaryMaxSpeedKmh)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
plateByVIN := make(map[string]string, len(vehicles))
|
||||
for plate, vehicle := range vehicles {
|
||||
plateByVIN[vehicle.VIN] = plate
|
||||
}
|
||||
results := stationaryMatches(points, plateByVIN, longitude, latitude, radiusMeters)
|
||||
_ = s.repository.Audit(ctx, app.ID, "stationary_vehicle_query", "success", traceID, len(results), "")
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateStationaryVehicleQuery(request StationaryVehicleQueryRequest) ([]string, time.Time, time.Time, float64, float64, float64, error) {
|
||||
plates, err := normalizePlates(request.PlateNumbers, 2000)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, time.Time{}, 0, 0, 0, err
|
||||
}
|
||||
start, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.StartTime), s.location)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid startTime", ErrInvalidRequest)
|
||||
}
|
||||
end, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.EndTime), s.location)
|
||||
if err != nil || !end.After(start) {
|
||||
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid endTime", ErrInvalidRequest)
|
||||
}
|
||||
if end.Sub(start) > stationaryQueryMaximumWindow {
|
||||
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: time window exceeds 24 hours", ErrInvalidRequest)
|
||||
}
|
||||
longitude, latitude := request.Longitude, request.Latitude
|
||||
if !validCoordinate(longitude, latitude) {
|
||||
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: invalid coordinate", ErrInvalidRequest)
|
||||
}
|
||||
coordinateSystem := strings.ToUpper(strings.TrimSpace(request.CoordinateSystem))
|
||||
if coordinateSystem == "" {
|
||||
coordinateSystem = stationaryCoordinateWGS84
|
||||
}
|
||||
switch coordinateSystem {
|
||||
case stationaryCoordinateWGS84:
|
||||
case stationaryCoordinateGCJ02:
|
||||
longitude, latitude = gcj02ToWGS84(longitude, latitude)
|
||||
default:
|
||||
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: coordinateSystem must be WGS84 or GCJ02", ErrInvalidRequest)
|
||||
}
|
||||
radiusMeters := stationaryDefaultRadiusMeters
|
||||
if request.RadiusMeters != nil {
|
||||
radiusMeters = *request.RadiusMeters
|
||||
}
|
||||
if math.IsNaN(radiusMeters) || math.IsInf(radiusMeters, 0) || radiusMeters < stationaryMinimumRadiusMeters || radiusMeters > stationaryMaximumRadiusMeters {
|
||||
return nil, time.Time{}, time.Time{}, 0, 0, 0, fmt.Errorf("%w: radiusMeters must be between 1 and 100", ErrInvalidRequest)
|
||||
}
|
||||
return plates, start, end, longitude, latitude, radiusMeters, nil
|
||||
}
|
||||
|
||||
func gcj02ToWGS84(longitude, latitude float64) (float64, float64) {
|
||||
// GCJ-02 only applies within mainland China's obfuscation area. Returning
|
||||
// the supplied value elsewhere keeps the transformation safe for outlying
|
||||
// stations and callers that supply valid geographic coordinates.
|
||||
if longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271 {
|
||||
return longitude, latitude
|
||||
}
|
||||
const semiMajorAxis = 6378245.0
|
||||
const eccentricitySquared = 0.006693421622965943
|
||||
longitudeOffset := transformGCJLongitude(longitude-105, latitude-35)
|
||||
latitudeOffset := transformGCJLatitude(longitude-105, latitude-35)
|
||||
radianLatitude := latitude / 180 * math.Pi
|
||||
magic := 1 - eccentricitySquared*math.Pow(math.Sin(radianLatitude), 2)
|
||||
squareRootMagic := math.Sqrt(magic)
|
||||
convertedLatitude := latitude - latitudeOffset*180/((semiMajorAxis*(1-eccentricitySquared))/(magic*squareRootMagic)*math.Pi)
|
||||
convertedLongitude := longitude - longitudeOffset*180/(semiMajorAxis/squareRootMagic*math.Cos(radianLatitude)*math.Pi)
|
||||
return convertedLongitude, convertedLatitude
|
||||
}
|
||||
|
||||
func transformGCJLatitude(longitude, latitude float64) float64 {
|
||||
value := -100 + 2*longitude + 3*latitude + 0.2*latitude*latitude + 0.1*longitude*latitude + 0.2*math.Sqrt(math.Abs(longitude))
|
||||
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
|
||||
value += (20*math.Sin(latitude*math.Pi) + 40*math.Sin(latitude/3*math.Pi)) * 2 / 3
|
||||
value += (160*math.Sin(latitude/12*math.Pi) + 320*math.Sin(latitude*math.Pi/30)) * 2 / 3
|
||||
return value
|
||||
}
|
||||
|
||||
func transformGCJLongitude(longitude, latitude float64) float64 {
|
||||
value := 300 + longitude + 2*latitude + 0.1*longitude*longitude + 0.1*longitude*latitude + 0.1*math.Sqrt(math.Abs(longitude))
|
||||
value += (20*math.Sin(6*longitude*math.Pi) + 20*math.Sin(2*longitude*math.Pi)) * 2 / 3
|
||||
value += (20*math.Sin(longitude*math.Pi) + 40*math.Sin(longitude/3*math.Pi)) * 2 / 3
|
||||
value += (150*math.Sin(longitude/12*math.Pi) + 300*math.Sin(longitude/30*math.Pi)) * 2 / 3
|
||||
return value
|
||||
}
|
||||
|
||||
func stationaryMatches(points []StationaryLocationPoint, plateByVIN map[string]string, longitude, latitude, radiusMeters float64) []StationaryVehicleResult {
|
||||
if len(points) == 0 {
|
||||
return []StationaryVehicleResult{}
|
||||
}
|
||||
sort.Slice(points, func(i, j int) bool {
|
||||
if points[i].VIN != points[j].VIN {
|
||||
return points[i].VIN < points[j].VIN
|
||||
}
|
||||
return points[i].ObservedAt.Before(points[j].ObservedAt)
|
||||
})
|
||||
results := make([]StationaryVehicleResult, 0)
|
||||
for from := 0; from < len(points); {
|
||||
to := from + 1
|
||||
for to < len(points) && points[to].VIN == points[from].VIN {
|
||||
to++
|
||||
}
|
||||
for segmentStart := from; segmentStart < to; {
|
||||
segmentEnd := segmentStart + 1
|
||||
for segmentEnd < to && points[segmentEnd].ObservedAt.Sub(points[segmentEnd-1].ObservedAt) <= stationarySegmentGap {
|
||||
segmentEnd++
|
||||
}
|
||||
if result, ok := stationaryMatch(points[segmentStart:segmentEnd], plateByVIN[points[from].VIN], longitude, latitude, radiusMeters); ok {
|
||||
results = append(results, result)
|
||||
}
|
||||
segmentStart = segmentEnd
|
||||
}
|
||||
from = to
|
||||
}
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
if results[i].MatchScore != results[j].MatchScore {
|
||||
return results[i].MatchScore > results[j].MatchScore
|
||||
}
|
||||
if results[i].StayDurationSeconds != results[j].StayDurationSeconds {
|
||||
return results[i].StayDurationSeconds > results[j].StayDurationSeconds
|
||||
}
|
||||
return results[i].VIN < results[j].VIN
|
||||
})
|
||||
return results
|
||||
}
|
||||
|
||||
func stationaryMatch(points []StationaryLocationPoint, plate string, longitude, latitude, radiusMeters float64) (StationaryVehicleResult, bool) {
|
||||
if len(points) < 2 {
|
||||
return StationaryVehicleResult{}, false
|
||||
}
|
||||
duration := points[len(points)-1].ObservedAt.Sub(points[0].ObservedAt)
|
||||
if duration < stationaryMinimumStay {
|
||||
return StationaryVehicleResult{}, false
|
||||
}
|
||||
var totalDistance, totalSpeed, maxDistance, maxSpeed float64
|
||||
protocols := map[string]struct{}{}
|
||||
for _, point := range points {
|
||||
distance := haversineMeters(latitude, longitude, point.Latitude, point.Longitude)
|
||||
if distance > radiusMeters || point.SpeedKmh < 0 || point.SpeedKmh > stationaryMaxSpeedKmh {
|
||||
return StationaryVehicleResult{}, false
|
||||
}
|
||||
totalDistance += distance
|
||||
totalSpeed += point.SpeedKmh
|
||||
maxDistance = math.Max(maxDistance, distance)
|
||||
maxSpeed = math.Max(maxSpeed, point.SpeedKmh)
|
||||
protocols[point.Protocol] = struct{}{}
|
||||
}
|
||||
protocolList := make([]string, 0, len(protocols))
|
||||
for protocol := range protocols {
|
||||
protocolList = append(protocolList, externalMileageProtocol(protocol))
|
||||
}
|
||||
sort.Strings(protocolList)
|
||||
averageDistance := totalDistance / float64(len(points))
|
||||
averageSpeed := totalSpeed / float64(len(points))
|
||||
score := 40*(1-averageDistance/radiusMeters) + 25*(1-maxSpeed/stationaryMaxSpeedKmh) + 20*math.Min(1, duration.Seconds()/900) + 15*math.Min(1, float64(len(points))/4)
|
||||
return StationaryVehicleResult{
|
||||
VIN: points[0].VIN,
|
||||
PlateNumber: plate,
|
||||
StayStartTime: points[0].ObservedAt.Format("2006-01-02 15:04:05"),
|
||||
StayEndTime: points[len(points)-1].ObservedAt.Format("2006-01-02 15:04:05"),
|
||||
StayDurationSeconds: int64(duration.Seconds()),
|
||||
StayDurationMinutes: round3(duration.Minutes()),
|
||||
MatchScore: round3(math.Max(0, math.Min(100, score))),
|
||||
AverageDistanceM: round3(averageDistance),
|
||||
MaxDistanceM: round3(maxDistance),
|
||||
AverageSpeedKmh: round3(averageSpeed),
|
||||
MaxSpeedKmh: round3(maxSpeed),
|
||||
MatchedSamples: len(points),
|
||||
SourceProtocols: protocolList,
|
||||
}, true
|
||||
}
|
||||
|
||||
func haversineMeters(latitude1, longitude1, latitude2, longitude2 float64) float64 {
|
||||
const earthRadiusMeters = 6371000.0
|
||||
lat1, lat2 := latitude1*math.Pi/180, latitude2*math.Pi/180
|
||||
dLat, dLon := (latitude2-latitude1)*math.Pi/180, (longitude2-longitude1)*math.Pi/180
|
||||
a := math.Sin(dLat/2)*math.Sin(dLat/2) + math.Cos(lat1)*math.Cos(lat2)*math.Sin(dLon/2)*math.Sin(dLon/2)
|
||||
return earthRadiusMeters * 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -78,10 +290,11 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
||||
if difference < 0 {
|
||||
difference = 0
|
||||
}
|
||||
item.Protocol = point.Protocol
|
||||
item.Protocol = externalMileageProtocol(point.Protocol)
|
||||
item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
||||
item.TimeDifferenceSeconds = &difference
|
||||
item.Online = point.Online
|
||||
item.ActiveToday = point.ActiveToday
|
||||
item.MotionStatus = "offline"
|
||||
if item.Online && point.SpeedKmh > 3 {
|
||||
item.MotionStatus = "driving"
|
||||
@@ -90,6 +303,7 @@ func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID str
|
||||
}
|
||||
speed, mileage := round3(point.SpeedKmh), round3(point.TotalMileageKm)
|
||||
item.SpeedKmh, item.TotalMileageKm = &speed, &mileage
|
||||
item.SOCPercent = point.SOCPercent
|
||||
if validCoordinate(point.Longitude, point.Latitude) {
|
||||
longitude, latitude := point.Longitude, point.Latitude
|
||||
item.Longitude, item.Latitude = &longitude, &latitude
|
||||
@@ -211,7 +425,12 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
missingVINs := missingMileageVINs(vins, values)
|
||||
rollbacks, err := s.repository.MileageRollbacks(ctx, vins, date, date, protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
||||
return nil, err
|
||||
}
|
||||
missingVINs := missingMileageVINs(vins, values, rollbacks, date)
|
||||
carried := map[string]DailyMileage{}
|
||||
if len(missingVINs) > 0 {
|
||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, date, protocols)
|
||||
@@ -226,6 +445,8 @@ func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, requ
|
||||
item := MileageResult{VIN: vehicle.VIN, PlateNumber: plate, Date: date, Status: StatusNoData}
|
||||
if value, ok := values[vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
fillMileageResult(&item, value, value.MileageKm)
|
||||
} else if rollbacks[dailyMileageKey(vehicle.VIN, date)] {
|
||||
fillMileageAnomaly(&item)
|
||||
} else if value, ok := carried[vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
fillMileageResult(&item, value, 0)
|
||||
}
|
||||
@@ -321,6 +542,7 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
|
||||
}
|
||||
values := map[string]DailyMileage{}
|
||||
carried := map[string]DailyMileage{}
|
||||
rollbacks := map[string]bool{}
|
||||
if len(positions) > 0 {
|
||||
vins := make([]string, 0, len(vinSet))
|
||||
for vin := range vinSet {
|
||||
@@ -332,7 +554,12 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
missingVINs := missingMileageRangeInitialVINs(positions, values)
|
||||
rollbacks, err = s.repository.MileageRollbacks(ctx, vins, queryStart.Format("2006-01-02"), queryEnd.Format("2006-01-02"), protocols)
|
||||
if err != nil {
|
||||
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
||||
return MileageRangeResponse{}, err
|
||||
}
|
||||
missingVINs := missingMileageRangeInitialVINs(positions, values, rollbacks)
|
||||
if len(missingVINs) > 0 {
|
||||
carried, err = s.repository.LatestMileageBefore(ctx, missingVINs, queryStart.Format("2006-01-02"), protocols)
|
||||
if err != nil {
|
||||
@@ -355,6 +582,9 @@ func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string,
|
||||
if value, ok := values[dailyMileageKey(position.vehicle.VIN, position.date)]; ok && validDailyMileage(value) {
|
||||
fillMileageRangeResult(&item, value, value.MileageKm)
|
||||
carried[position.vehicle.VIN] = value
|
||||
} else if rollbacks[dailyMileageKey(position.vehicle.VIN, position.date)] {
|
||||
fillMileageRangeAnomaly(&item)
|
||||
delete(carried, position.vehicle.VIN)
|
||||
} else if value, ok := carried[position.vehicle.VIN]; ok && validDailyMileage(value) {
|
||||
fillMileageRangeResult(&item, value, 0)
|
||||
}
|
||||
@@ -715,9 +945,12 @@ func validDailyMileage(value DailyMileage) bool {
|
||||
value.UpdatedAt != ""
|
||||
}
|
||||
|
||||
func missingMileageVINs(vins []string, values map[string]DailyMileage) []string {
|
||||
func missingMileageVINs(vins []string, values map[string]DailyMileage, rollbacks map[string]bool, date string) []string {
|
||||
missing := make([]string, 0)
|
||||
for _, vin := range vins {
|
||||
if rollbacks[dailyMileageKey(vin, date)] {
|
||||
continue
|
||||
}
|
||||
if value, ok := values[vin]; !ok || !validDailyMileage(value) {
|
||||
missing = append(missing, vin)
|
||||
}
|
||||
@@ -725,7 +958,7 @@ func missingMileageVINs(vins []string, values map[string]DailyMileage) []string
|
||||
return missing
|
||||
}
|
||||
|
||||
func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage) []string {
|
||||
func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage, rollbacks map[string]bool) []string {
|
||||
seen := make(map[string]struct{}, len(positions))
|
||||
missing := make([]string, 0)
|
||||
for _, position := range positions {
|
||||
@@ -734,6 +967,9 @@ func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map
|
||||
continue
|
||||
}
|
||||
seen[vin] = struct{}{}
|
||||
if rollbacks[dailyMileageKey(vin, position.date)] {
|
||||
continue
|
||||
}
|
||||
value, ok := values[dailyMileageKey(vin, position.date)]
|
||||
if !ok || !validDailyMileage(value) {
|
||||
missing = append(missing, vin)
|
||||
@@ -769,6 +1005,20 @@ func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyM
|
||||
item.Status = StatusNormal
|
||||
}
|
||||
|
||||
const mileageTotalRollbackQuality = "TOTAL_MILEAGE_ROLLBACK"
|
||||
|
||||
func fillMileageAnomaly(item *MileageResult) {
|
||||
item.DataQuality = stringPointer(mileageTotalRollbackQuality)
|
||||
item.Status = StatusDataAnomaly
|
||||
}
|
||||
|
||||
func fillMileageRangeAnomaly(item *MileageRangeResult) {
|
||||
item.DataQuality = stringPointer(mileageTotalRollbackQuality)
|
||||
item.Status = StatusDataAnomaly
|
||||
}
|
||||
|
||||
func stringPointer(value string) *string { return &value }
|
||||
|
||||
func dailyMileageKey(vin, date string) string {
|
||||
return vin + "\x00" + date
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user