1077 lines
41 KiB
Go
1077 lines
41 KiB
Go
package openplatform
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"lingniu/vehicle-data-platform/apps/api/internal/vehicleprotocol"
|
|
)
|
|
|
|
var appKeyPattern = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
|
|
|
|
var (
|
|
ErrUnauthorized = errors.New("open platform appKey unauthorized")
|
|
ErrForbidden = errors.New("open platform vehicle forbidden")
|
|
ErrInvalidRequest = errors.New("open platform invalid request")
|
|
ErrNotFound = errors.New("open platform resource not found")
|
|
)
|
|
|
|
type Repository interface {
|
|
Authenticate(context.Context, [sha256.Size]byte, time.Time, time.Time, time.Time) (AppCredential, error)
|
|
AuthorizedVehicles(context.Context, uint64, []string, time.Time, time.Time) (map[string]AuthorizedVehicle, error)
|
|
DailyHydrogen(context.Context, []string, string) (map[string]DailyHydrogen, error)
|
|
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
|
|
|
|
CreateApp(context.Context, AppInput, [sha256.Size]byte, string, time.Time, *time.Time, string) (App, error)
|
|
ListApps(context.Context) ([]App, error)
|
|
UpdateApp(context.Context, uint64, AppInput, time.Time, *time.Time, string) (App, error)
|
|
RotateKey(context.Context, uint64, [sha256.Size]byte, string, string) (App, error)
|
|
ReplaceVehicleGrants(context.Context, uint64, []parsedGrant, string) ([]VehicleGrant, error)
|
|
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)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
app, vehicles, err := s.authorize(ctx, appKey, plates, now, now)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "denied", traceID, len(plates), err.Error())
|
|
return nil, err
|
|
}
|
|
if len(plates) == 0 {
|
|
plates = vehiclePlates(vehicles)
|
|
}
|
|
points, err := s.repository.RealtimeVehicles(ctx, vehicleVINs(vehicles), now)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "error", traceID, len(plates), err.Error())
|
|
return nil, err
|
|
}
|
|
results := make([]RealtimeVehicleResult, 0, len(plates))
|
|
for _, plate := range plates {
|
|
vehicle := vehicles[plate]
|
|
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData}
|
|
if point, ok := points[vehicle.VIN]; ok {
|
|
difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds())
|
|
if difference < 0 {
|
|
difference = 0
|
|
}
|
|
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"
|
|
} else if item.Online {
|
|
item.MotionStatus = "idle"
|
|
}
|
|
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
|
|
item.LocationAvailable = true
|
|
}
|
|
item.Status = StatusNormal
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
|
|
return results, nil
|
|
}
|
|
|
|
func (s *Service) QueryHydrogenStations(ctx context.Context, appKey, traceID string, request HydrogenStationRequest) ([]HydrogenStation, error) {
|
|
now := s.now().In(s.location)
|
|
if !appKeyPattern.MatchString(appKey) {
|
|
return nil, ErrUnauthorized
|
|
}
|
|
request.Province = strings.TrimSpace(request.Province)
|
|
request.City = strings.TrimSpace(request.City)
|
|
if len([]rune(request.Province)) > 32 || len([]rune(request.City)) > 32 {
|
|
return nil, fmt.Errorf("%w: province or city too long", ErrInvalidRequest)
|
|
}
|
|
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), now, now, now)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, 0, "hydrogen_station_query", "denied", traceID, 0, ErrUnauthorized.Error())
|
|
return nil, ErrUnauthorized
|
|
}
|
|
stations, err := s.repository.HydrogenStations(ctx, request)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "error", traceID, 0, err.Error())
|
|
return nil, err
|
|
}
|
|
_ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "success", traceID, 0, "")
|
|
return stations, nil
|
|
}
|
|
|
|
func validCoordinate(longitude, latitude float64) bool {
|
|
return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && !(longitude == 0 && latitude == 0)
|
|
}
|
|
|
|
type Service struct {
|
|
repository Repository
|
|
now func() time.Time
|
|
location *time.Location
|
|
}
|
|
|
|
type parsedGrant struct {
|
|
VIN string
|
|
ValidFrom time.Time
|
|
ValidTo *time.Time
|
|
}
|
|
|
|
func NewService(repository Repository) *Service {
|
|
return &Service{
|
|
repository: repository,
|
|
now: time.Now,
|
|
location: time.FixedZone("Asia/Shanghai", 8*60*60),
|
|
}
|
|
}
|
|
|
|
func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, request QueryRequest) ([]HydrogenResult, error) {
|
|
plates, date, start, end, err := s.validateQuery(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, "hydrogen_query", "denied", traceID, len(plates), err.Error())
|
|
return nil, err
|
|
}
|
|
if len(plates) == 0 {
|
|
plates = vehiclePlates(vehicles)
|
|
}
|
|
vins := vehicleVINs(vehicles)
|
|
values, err := s.repository.DailyHydrogen(ctx, vins, date)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "error", traceID, len(plates), err.Error())
|
|
return nil, err
|
|
}
|
|
results := make([]HydrogenResult, 0, len(plates))
|
|
for _, plate := range plates {
|
|
vehicle := vehicles[plate]
|
|
item := HydrogenResult{PlateNumber: plate, Date: date, Status: StatusNoData}
|
|
// Sampling sufficiency is decided by the producer and persisted in
|
|
// quality_status. Imported refuelling-ledger rows can be authoritative
|
|
// with one transaction, while pressure-derived rows require two samples.
|
|
if value, ok := values[vehicle.VIN]; ok {
|
|
item.CalculationPhase = value.CalculationPhase
|
|
item.AlgorithmVersion = value.AlgorithmVersion
|
|
item.QualityStatus = value.QualityStatus
|
|
item.QualityReason = value.QualityReason
|
|
switch {
|
|
case strings.EqualFold(value.QualityStatus, "OK"):
|
|
consumption := round3(value.ConsumptionKg)
|
|
item.HydrogenConsumptionKg = &consumption
|
|
item.Status = StatusNormal
|
|
case strings.EqualFold(value.QualityStatus, "SUSPECT"):
|
|
consumption := round3(value.ConsumptionKg)
|
|
item.HydrogenConsumptionKg = &consumption
|
|
item.Status = StatusDataAnomaly
|
|
}
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
|
|
return results, nil
|
|
}
|
|
|
|
func (s *Service) QueryMileage(ctx context.Context, appKey, traceID string, request QueryRequest) ([]MileageResult, error) {
|
|
plates, date, start, end, err := s.validateQuery(request)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
protocols, err := normalizeProtocolPriority(request.ProtocolPriority)
|
|
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, "mileage_query", "denied", traceID, len(plates), err.Error())
|
|
return nil, err
|
|
}
|
|
if len(plates) == 0 {
|
|
plates = vehiclePlates(vehicles)
|
|
}
|
|
vins := vehicleVINs(vehicles)
|
|
values, err := s.repository.DailyMileage(ctx, vins, date, protocols)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
|
return nil, err
|
|
}
|
|
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)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "error", traceID, len(plates), err.Error())
|
|
return nil, err
|
|
}
|
|
}
|
|
results := make([]MileageResult, 0, len(plates))
|
|
for _, plate := range plates {
|
|
vehicle := vehicles[plate]
|
|
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)
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "")
|
|
return results, nil
|
|
}
|
|
|
|
func (s *Service) QueryMileageRange(ctx context.Context, appKey, traceID string, request MileageRangeRequest) (MileageRangeResponse, error) {
|
|
request.Cursor = strings.TrimSpace(request.Cursor)
|
|
plates, startDate, endDate, start, end, pageSize, protocols, err := s.validateMileageRange(request)
|
|
if err != nil {
|
|
return MileageRangeResponse{}, err
|
|
}
|
|
hash := mileageRangeRequestHash(startDate, endDate, plates, pageSize, protocols)
|
|
var (
|
|
app AppCredential
|
|
snapshot MileageSnapshot
|
|
offset int
|
|
)
|
|
if request.Cursor == "" {
|
|
vehicles := map[string]AuthorizedVehicle{}
|
|
app, vehicles, err = s.authorize(ctx, appKey, plates, start, end)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "denied", traceID, len(plates), err.Error())
|
|
return MileageRangeResponse{}, err
|
|
}
|
|
snapshotID, idErr := newSnapshotID()
|
|
if idErr != nil {
|
|
return MileageRangeResponse{}, idErr
|
|
}
|
|
snapshot = MileageSnapshot{
|
|
ID: snapshotID,
|
|
AppID: app.ID,
|
|
RequestHash: hash[:],
|
|
StartDate: startDate,
|
|
EndDate: endDate,
|
|
ExpiresAt: s.now().Add(2 * time.Hour),
|
|
Vehicles: orderedVehicles(vehicles),
|
|
}
|
|
snapshot.VehicleCount = len(snapshot.Vehicles)
|
|
if err = s.repository.CreateMileageSnapshot(ctx, snapshot); err != nil {
|
|
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, len(snapshot.Vehicles), err.Error())
|
|
return MileageRangeResponse{}, err
|
|
}
|
|
} else {
|
|
snapshotID, parsedOffset, cursorErr := parseMileageCursor(request.Cursor)
|
|
if cursorErr != nil {
|
|
return MileageRangeResponse{}, cursorErr
|
|
}
|
|
offset = parsedOffset
|
|
if !appKeyPattern.MatchString(appKey) {
|
|
return MileageRangeResponse{}, ErrUnauthorized
|
|
}
|
|
app, err = s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), s.now(), start, end)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, 0, "mileage_range_query", "denied", traceID, len(plates), ErrUnauthorized.Error())
|
|
return MileageRangeResponse{}, ErrUnauthorized
|
|
}
|
|
snapshot, err = s.repository.LoadMileageSnapshot(ctx, snapshotID, app.ID, s.now())
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "denied", traceID, len(plates), err.Error())
|
|
return MileageRangeResponse{}, err
|
|
}
|
|
if !bytes.Equal(snapshot.RequestHash, hash[:]) || snapshot.StartDate != startDate || snapshot.EndDate != endDate {
|
|
return MileageRangeResponse{}, fmt.Errorf("%w: cursor does not match request", ErrInvalidRequest)
|
|
}
|
|
}
|
|
|
|
dayCount := int(end.Sub(start).Hours() / 24)
|
|
total := dayCount * snapshot.VehicleCount
|
|
if offset < 0 || offset > total {
|
|
return MileageRangeResponse{}, fmt.Errorf("%w: cursor offset out of range", ErrInvalidRequest)
|
|
}
|
|
pageEnd := offset + pageSize
|
|
if pageEnd > total {
|
|
pageEnd = total
|
|
}
|
|
positions := make([]mileageRangePosition, 0, pageEnd-offset)
|
|
vinSet := make(map[string]struct{})
|
|
var queryStart, queryEnd time.Time
|
|
for index := offset; index < pageEnd; index++ {
|
|
dayOffset := index / snapshot.VehicleCount
|
|
vehicle := snapshot.Vehicles[index%snapshot.VehicleCount]
|
|
date := start.AddDate(0, 0, dayOffset)
|
|
if len(positions) == 0 {
|
|
queryStart = date
|
|
}
|
|
queryEnd = date
|
|
positions = append(positions, mileageRangePosition{vehicle: vehicle, date: date.Format("2006-01-02")})
|
|
vinSet[vehicle.VIN] = struct{}{}
|
|
}
|
|
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 {
|
|
vins = append(vins, vin)
|
|
}
|
|
sort.Strings(vins)
|
|
values, err = s.repository.DailyMileageRange(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
|
|
}
|
|
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 {
|
|
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "error", traceID, snapshot.VehicleCount, err.Error())
|
|
return MileageRangeResponse{}, err
|
|
}
|
|
if carried == nil {
|
|
carried = map[string]DailyMileage{}
|
|
}
|
|
}
|
|
}
|
|
results := make([]MileageRangeResult, 0, len(positions))
|
|
for _, position := range positions {
|
|
item := MileageRangeResult{
|
|
VIN: position.vehicle.VIN,
|
|
PlateNumber: position.vehicle.Plate,
|
|
Date: position.date,
|
|
Status: StatusNoData,
|
|
}
|
|
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)
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
response := MileageRangeResponse{
|
|
Code: "SUCCESS",
|
|
Message: "success",
|
|
Data: results,
|
|
SnapshotID: snapshot.ID,
|
|
TraceID: traceID,
|
|
}
|
|
if pageEnd < total {
|
|
cursor := mileageCursor(snapshot.ID, pageEnd)
|
|
response.NextCursor = &cursor
|
|
}
|
|
_ = s.repository.Audit(ctx, app.ID, "mileage_range_query", "success", traceID, snapshot.VehicleCount, "")
|
|
return response, nil
|
|
}
|
|
|
|
type mileageRangePosition struct {
|
|
vehicle AuthorizedVehicle
|
|
date string
|
|
}
|
|
|
|
func (s *Service) QueryTotalMileage(ctx context.Context, appKey, traceID string, request TotalMileageQueryRequest) (TotalMileageResult, error) {
|
|
vin, queryTime, protocols, protocolInput, err := s.validateTotalMileageQuery(request)
|
|
if err != nil {
|
|
return TotalMileageResult{}, err
|
|
}
|
|
result := TotalMileageResult{VIN: vin, QueryTime: queryTime.Format("2006-01-02 15:04:05"), ProtocolInput: protocolInput, SelectionPolicy: strings.Join(vehicleprotocol.MileagePriority(), " > "), Status: StatusNoData}
|
|
if !appKeyPattern.MatchString(appKey) {
|
|
return TotalMileageResult{}, ErrUnauthorized
|
|
}
|
|
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), s.now(), queryTime, queryTime)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, 0, "total_mileage_query", "denied", traceID, 1, ErrUnauthorized.Error())
|
|
return TotalMileageResult{}, ErrUnauthorized
|
|
}
|
|
authorized, err := s.repository.AuthorizedVIN(ctx, app.ID, vin, queryTime)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "error", traceID, 1, err.Error())
|
|
return TotalMileageResult{}, err
|
|
}
|
|
if !authorized {
|
|
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "denied", traceID, 1, ErrForbidden.Error())
|
|
return TotalMileageResult{}, ErrForbidden
|
|
}
|
|
point, err := s.repository.TotalMileage(ctx, vin, queryTime, protocols)
|
|
if err != nil {
|
|
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "error", traceID, 1, err.Error())
|
|
return TotalMileageResult{}, err
|
|
}
|
|
if point != nil {
|
|
value := round3(point.TotalMileageKm)
|
|
result.TotalMileageKm = &value
|
|
result.Protocol = point.Protocol
|
|
result.MileageMeaning = totalMileageMeaning(point.Protocol)
|
|
result.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05")
|
|
difference := int64(queryTime.Sub(point.ObservedAt.In(s.location)).Seconds())
|
|
result.TimeDifferenceSeconds = &difference
|
|
result.Status = StatusNormal
|
|
}
|
|
_ = s.repository.Audit(ctx, app.ID, "total_mileage_query", "success", traceID, 1, "")
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) validateTotalMileageQuery(request TotalMileageQueryRequest) (string, time.Time, []string, string, error) {
|
|
vin := strings.ToUpper(strings.TrimSpace(request.VIN))
|
|
if !regexp.MustCompile(`^[A-HJ-NPR-Z0-9]{17}$`).MatchString(vin) {
|
|
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid vin", ErrInvalidRequest)
|
|
}
|
|
queryTime, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(request.Time), s.location)
|
|
if err != nil {
|
|
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid datetime", ErrInvalidRequest)
|
|
}
|
|
input := strings.TrimSpace(request.Protocol)
|
|
if input == "" {
|
|
return vin, queryTime, vehicleprotocol.MileagePriority(), "", nil
|
|
}
|
|
canonical, ok := vehicleprotocol.Canonical(input)
|
|
if !ok {
|
|
return "", time.Time{}, nil, "", fmt.Errorf("%w: invalid protocol", ErrInvalidRequest)
|
|
}
|
|
return vin, queryTime, []string{canonical}, input, nil
|
|
}
|
|
|
|
func totalMileageMeaning(protocol string) string {
|
|
switch protocol {
|
|
case vehicleprotocol.GB32960:
|
|
return "车辆仪表盘累计总里程(GB/T 32960整车数据累计里程)"
|
|
case vehicleprotocol.YutongMQTT:
|
|
return "车辆仪表盘或车端控制器累计总里程(MQTT平台上报)"
|
|
case vehicleprotocol.JT808:
|
|
return "定位终端累计里程(GPS/终端侧计算,非车辆仪表盘里程)"
|
|
default:
|
|
return "车辆累计总里程"
|
|
}
|
|
}
|
|
|
|
func (s *Service) authorize(ctx context.Context, rawKey string, plates []string, start, end time.Time) (AppCredential, map[string]AuthorizedVehicle, error) {
|
|
if !appKeyPattern.MatchString(rawKey) {
|
|
return AppCredential{}, nil, ErrUnauthorized
|
|
}
|
|
app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(rawKey))), s.now(), start, end)
|
|
if err != nil {
|
|
return AppCredential{}, nil, ErrUnauthorized
|
|
}
|
|
vehicles, err := s.repository.AuthorizedVehicles(ctx, app.ID, plates, start, end)
|
|
if err != nil {
|
|
return app, nil, err
|
|
}
|
|
if len(plates) > 0 && len(vehicles) != len(plates) {
|
|
return app, nil, ErrForbidden
|
|
}
|
|
return app, vehicles, nil
|
|
}
|
|
|
|
func (s *Service) validateQuery(request QueryRequest) ([]string, string, time.Time, time.Time, error) {
|
|
plates, err := normalizePlates(request.PlateNumbers, 200)
|
|
if err != nil {
|
|
return nil, "", time.Time{}, time.Time{}, err
|
|
}
|
|
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.Date), s.location)
|
|
if err != nil {
|
|
return nil, "", time.Time{}, time.Time{}, fmt.Errorf("%w: invalid date", ErrInvalidRequest)
|
|
}
|
|
return plates, start.Format("2006-01-02"), start, start.AddDate(0, 0, 1), nil
|
|
}
|
|
|
|
func (s *Service) validateMileageRange(request MileageRangeRequest) ([]string, string, string, time.Time, time.Time, int, []string, error) {
|
|
plates, err := normalizePlates(request.PlateNumbers, 5000)
|
|
if err != nil {
|
|
return nil, "", "", time.Time{}, time.Time{}, 0, nil, err
|
|
}
|
|
protocols, err := normalizeProtocolPriority(request.ProtocolPriority)
|
|
if err != nil {
|
|
return nil, "", "", time.Time{}, time.Time{}, 0, nil, err
|
|
}
|
|
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.StartDate), s.location)
|
|
if err != nil {
|
|
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: invalid startDate", ErrInvalidRequest)
|
|
}
|
|
endInclusive, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(request.EndDate), s.location)
|
|
if err != nil {
|
|
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: invalid endDate", ErrInvalidRequest)
|
|
}
|
|
if endInclusive.Before(start) {
|
|
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: endDate precedes startDate", ErrInvalidRequest)
|
|
}
|
|
end := endInclusive.AddDate(0, 0, 1)
|
|
if days := int(end.Sub(start).Hours() / 24); days < 1 || days > 366 {
|
|
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: date range exceeds 366 days", ErrInvalidRequest)
|
|
}
|
|
pageSize := request.PageSize
|
|
if pageSize == 0 {
|
|
pageSize = 5000
|
|
}
|
|
if pageSize < 1 || pageSize > 5000 {
|
|
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: pageSize must be between 1 and 5000", ErrInvalidRequest)
|
|
}
|
|
return plates, start.Format("2006-01-02"), endInclusive.Format("2006-01-02"), start, end, pageSize, protocols, nil
|
|
}
|
|
|
|
func normalizeProtocolPriority(input ProtocolPriority) ([]string, error) {
|
|
if !input.Present && input.Values == nil {
|
|
return nil, nil
|
|
}
|
|
if len(input.Values) == 0 {
|
|
return nil, fmt.Errorf("%w: protocolPriority must not be empty", ErrInvalidRequest)
|
|
}
|
|
protocols := make([]string, 0, len(input.Values))
|
|
seen := make(map[string]bool, len(input.Values))
|
|
for _, protocol := range input.Values {
|
|
if seen[protocol] {
|
|
return nil, fmt.Errorf("%w: protocolPriority contains duplicate protocol", ErrInvalidRequest)
|
|
}
|
|
seen[protocol] = true
|
|
switch protocol {
|
|
case "GB32960", "JT808":
|
|
protocols = append(protocols, protocol)
|
|
case "MQTT":
|
|
protocols = append(protocols, "YUTONG_MQTT")
|
|
default:
|
|
return nil, fmt.Errorf("%w: protocolPriority contains unsupported protocol", ErrInvalidRequest)
|
|
}
|
|
}
|
|
return protocols, nil
|
|
}
|
|
|
|
func externalMileageProtocol(protocol string) string {
|
|
if protocol == "YUTONG_MQTT" {
|
|
return "MQTT"
|
|
}
|
|
return protocol
|
|
}
|
|
|
|
func normalizePlates(input []string, maximum int) ([]string, error) {
|
|
if len(input) > maximum {
|
|
return nil, fmt.Errorf("%w: plate numbers exceed %d", ErrInvalidRequest, maximum)
|
|
}
|
|
plates := make([]string, 0, len(input))
|
|
seen := map[string]bool{}
|
|
for _, raw := range input {
|
|
plate := strings.ToUpper(strings.TrimSpace(raw))
|
|
if plate == "" || len([]rune(plate)) > 32 {
|
|
return nil, fmt.Errorf("%w: invalid plate", ErrInvalidRequest)
|
|
}
|
|
if seen[plate] {
|
|
return nil, fmt.Errorf("%w: duplicate plate", ErrInvalidRequest)
|
|
}
|
|
seen[plate] = true
|
|
plates = append(plates, plate)
|
|
}
|
|
return plates, nil
|
|
}
|
|
|
|
func (s *Service) CreateApp(ctx context.Context, input AppInput, actor string) (AppCreated, error) {
|
|
from, to, err := s.validateAppInput(&input)
|
|
if err != nil {
|
|
return AppCreated{}, err
|
|
}
|
|
key, hash, prefix, err := newAppKey()
|
|
if err != nil {
|
|
return AppCreated{}, err
|
|
}
|
|
app, err := s.repository.CreateApp(ctx, input, hash, prefix, from, to, actor)
|
|
if err != nil {
|
|
return AppCreated{}, err
|
|
}
|
|
return AppCreated{App: app, AppKey: key}, nil
|
|
}
|
|
|
|
func (s *Service) ListApps(ctx context.Context) ([]App, error) {
|
|
return s.repository.ListApps(ctx)
|
|
}
|
|
|
|
func (s *Service) UpdateApp(ctx context.Context, id uint64, input AppInput, actor string) (App, error) {
|
|
from, to, err := s.validateAppInput(&input)
|
|
if err != nil {
|
|
return App{}, err
|
|
}
|
|
return s.repository.UpdateApp(ctx, id, input, from, to, actor)
|
|
}
|
|
|
|
func (s *Service) RotateKey(ctx context.Context, id uint64, actor string) (AppCreated, error) {
|
|
key, hash, prefix, err := newAppKey()
|
|
if err != nil {
|
|
return AppCreated{}, err
|
|
}
|
|
app, err := s.repository.RotateKey(ctx, id, hash, prefix, actor)
|
|
if err != nil {
|
|
return AppCreated{}, err
|
|
}
|
|
return AppCreated{App: app, AppKey: key}, nil
|
|
}
|
|
|
|
func (s *Service) ReplaceVehicleGrants(ctx context.Context, id uint64, request VehicleGrantRequest, actor string) ([]VehicleGrant, error) {
|
|
if len(request.Vehicles) > 5000 {
|
|
return nil, fmt.Errorf("%w: grants exceed 5000", ErrInvalidRequest)
|
|
}
|
|
grants := make([]parsedGrant, 0, len(request.Vehicles))
|
|
seen := map[string]bool{}
|
|
for _, input := range request.Vehicles {
|
|
vin := strings.ToUpper(strings.TrimSpace(input.VIN))
|
|
if len(vin) != 17 || seen[vin] {
|
|
return nil, fmt.Errorf("%w: invalid or duplicate VIN", ErrInvalidRequest)
|
|
}
|
|
seen[vin] = true
|
|
from, to, err := parseInterval(input.ValidFrom, input.ValidTo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
grants = append(grants, parsedGrant{VIN: vin, ValidFrom: from, ValidTo: to})
|
|
}
|
|
return s.repository.ReplaceVehicleGrants(ctx, id, grants, actor)
|
|
}
|
|
|
|
func (s *Service) ListVehicleGrants(ctx context.Context, id uint64) ([]VehicleGrant, error) {
|
|
return s.repository.ListVehicleGrants(ctx, id)
|
|
}
|
|
|
|
func (s *Service) validateAppInput(input *AppInput) (time.Time, *time.Time, error) {
|
|
input.Name = strings.TrimSpace(input.Name)
|
|
input.Status = strings.ToLower(strings.TrimSpace(input.Status))
|
|
if input.Status == "" {
|
|
input.Status = "enabled"
|
|
}
|
|
if input.Name == "" || len([]rune(input.Name)) > 96 || (input.Status != "enabled" && input.Status != "disabled") {
|
|
return time.Time{}, nil, fmt.Errorf("%w: invalid app", ErrInvalidRequest)
|
|
}
|
|
return parseInterval(input.ValidFrom, input.ValidTo)
|
|
}
|
|
|
|
func parseInterval(rawFrom, rawTo string) (time.Time, *time.Time, error) {
|
|
from, err := time.Parse(time.RFC3339, strings.TrimSpace(rawFrom))
|
|
if err != nil {
|
|
return time.Time{}, nil, fmt.Errorf("%w: validFrom must be RFC3339", ErrInvalidRequest)
|
|
}
|
|
var to *time.Time
|
|
if strings.TrimSpace(rawTo) != "" {
|
|
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(rawTo))
|
|
if err != nil || !parsed.After(from) {
|
|
return time.Time{}, nil, fmt.Errorf("%w: invalid validTo", ErrInvalidRequest)
|
|
}
|
|
to = &parsed
|
|
}
|
|
return from, to, nil
|
|
}
|
|
|
|
func newAppKey() (string, [sha256.Size]byte, string, error) {
|
|
var bytes [16]byte
|
|
if _, err := rand.Read(bytes[:]); err != nil {
|
|
return "", [sha256.Size]byte{}, "", err
|
|
}
|
|
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
|
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
|
key := hex.EncodeToString(bytes[:])
|
|
return key, sha256.Sum256([]byte(key)), key[:8], nil
|
|
}
|
|
|
|
func vehicleVINs(vehicles map[string]AuthorizedVehicle) []string {
|
|
seen := map[string]bool{}
|
|
vins := make([]string, 0, len(vehicles))
|
|
for _, vehicle := range vehicles {
|
|
if !seen[vehicle.VIN] {
|
|
seen[vehicle.VIN] = true
|
|
vins = append(vins, vehicle.VIN)
|
|
}
|
|
}
|
|
sort.Strings(vins)
|
|
return vins
|
|
}
|
|
|
|
func vehiclePlates(vehicles map[string]AuthorizedVehicle) []string {
|
|
plates := make([]string, 0, len(vehicles))
|
|
for plate := range vehicles {
|
|
plates = append(plates, plate)
|
|
}
|
|
sort.Strings(plates)
|
|
return plates
|
|
}
|
|
|
|
func orderedVehicles(vehicles map[string]AuthorizedVehicle) []AuthorizedVehicle {
|
|
plates := vehiclePlates(vehicles)
|
|
out := make([]AuthorizedVehicle, 0, len(plates))
|
|
for _, plate := range plates {
|
|
out = append(out, vehicles[plate])
|
|
}
|
|
return out
|
|
}
|
|
|
|
func validDailyMileage(value DailyMileage) bool {
|
|
return value.MileageKm >= 0 &&
|
|
value.TotalMileageKm >= 0 &&
|
|
(value.Protocol == "GB32960" || value.Protocol == "YUTONG_MQTT" || value.Protocol == "JT808") &&
|
|
value.DataTime != "" &&
|
|
value.UpdatedAt != ""
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
return missing
|
|
}
|
|
|
|
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 {
|
|
vin := position.vehicle.VIN
|
|
if _, ok := seen[vin]; ok {
|
|
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)
|
|
}
|
|
}
|
|
sort.Strings(missing)
|
|
return missing
|
|
}
|
|
|
|
func fillMileageResult(item *MileageResult, value DailyMileage, dailyMileage float64) {
|
|
item.DailyMileageKm = &dailyMileage
|
|
totalMileage := value.TotalMileageKm
|
|
item.TotalMileageKm = &totalMileage
|
|
dataTime := value.DataTime
|
|
updatedAt := value.UpdatedAt
|
|
item.DataTime = &dataTime
|
|
item.UpdatedAt = &updatedAt
|
|
sourceProtocol := externalMileageProtocol(value.Protocol)
|
|
item.SourceProtocol = &sourceProtocol
|
|
item.Status = StatusNormal
|
|
}
|
|
|
|
func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyMileage float64) {
|
|
item.DailyMileageKm = &dailyMileage
|
|
totalMileage := value.TotalMileageKm
|
|
item.TotalMileageKm = &totalMileage
|
|
dataTime := value.DataTime
|
|
updatedAt := value.UpdatedAt
|
|
item.DataTime = &dataTime
|
|
item.UpdatedAt = &updatedAt
|
|
sourceProtocol := externalMileageProtocol(value.Protocol)
|
|
item.SourceProtocol = &sourceProtocol
|
|
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
|
|
}
|
|
|
|
func mileageRangeRequestHash(startDate, endDate string, plates []string, pageSize int, protocols []string) [sha256.Size]byte {
|
|
normalized := append([]string(nil), plates...)
|
|
sort.Strings(normalized)
|
|
return sha256.Sum256([]byte(startDate + "\x00" + endDate + "\x00" + strconv.Itoa(pageSize) + "\x00" + strings.Join(normalized, "\x00") + "\x01" + strings.Join(protocols, "\x00")))
|
|
}
|
|
|
|
func newSnapshotID() (string, error) {
|
|
bytes := make([]byte, 16)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(bytes), nil
|
|
}
|
|
|
|
func mileageCursor(snapshotID string, offset int) string {
|
|
return base64.RawURLEncoding.EncodeToString([]byte(snapshotID + ":" + strconv.Itoa(offset)))
|
|
}
|
|
|
|
func parseMileageCursor(cursor string) (string, int, error) {
|
|
decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(cursor))
|
|
if err != nil {
|
|
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
|
|
}
|
|
parts := strings.Split(string(decoded), ":")
|
|
if len(parts) != 2 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(parts[0]) {
|
|
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
|
|
}
|
|
offset, err := strconv.Atoi(parts[1])
|
|
if err != nil || offset < 0 {
|
|
return "", 0, fmt.Errorf("%w: invalid cursor", ErrInvalidRequest)
|
|
}
|
|
return parts[0], offset, nil
|
|
}
|
|
|
|
func round3(value float64) float64 {
|
|
if value >= 0 {
|
|
return float64(int64(value*1000+0.5)) / 1000
|
|
}
|
|
return float64(int64(value*1000-0.5)) / 1000
|
|
}
|