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

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