Add monitoring metrics, numeric sorting, report-time display, vehicle number labels, and map layout fixes. Include accumulated mileage, hydrogen, OneOS authentication, open platform, ingestion, deployment, and UI updates. Exclude temporary files, generated reports, and credentials. Validate frontend 632 tests and production build, API packages, vehicle-map tests, and release installer tests. Gateway suite has one pre-existing date-sensitive identity throttling test failure reproduced on the parent commit.
1089 lines
42 KiB
Go
1089 lines
42 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 {
|
|
ReconciledMileageRange(context.Context, []string, string, string, []string) (map[string]DailyMileage, error)
|
|
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 := buildRealtimeVehicleResults(plates, vehicles, points, now, s.location)
|
|
_ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "")
|
|
return results, nil
|
|
}
|
|
|
|
func buildRealtimeVehicleResults(plates []string, vehicles map[string]AuthorizedVehicle, points map[string]RealtimeVehiclePoint, now time.Time, location *time.Location) []RealtimeVehicleResult {
|
|
results := make([]RealtimeVehicleResult, 0, len(plates))
|
|
for _, plate := range plates {
|
|
vehicle := vehicles[plate]
|
|
item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData, RealtimeHydrogenData: missingRealtimeHydrogen(""), GPSFixStatus: "UNKNOWN", CoordinateSystem: "UNKNOWN"}
|
|
if point, ok := points[vehicle.VIN]; ok {
|
|
difference := int64(now.Sub(point.ObservedAt.In(location)).Seconds())
|
|
if difference < 0 {
|
|
difference = 0
|
|
}
|
|
item.Protocol = externalMileageProtocol(point.Protocol)
|
|
applyRealtimeLiveData(&item, point, location)
|
|
item.RecordTime = point.ObservedAt.In(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 item.GPSFixStatus != "NO_FIX" && 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)
|
|
}
|
|
return results
|
|
}
|
|
|
|
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 {
|
|
historicalLimiter historicalHydrogenLimiter
|
|
operatingLimiter historicalHydrogenLimiter
|
|
operatingSlots chan 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,
|
|
operatingSlots: make(chan struct{}, 16),
|
|
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 := buildHydrogenResults(plates, vehicles, values, date)
|
|
_ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "success", traceID, len(plates), "")
|
|
return results, nil
|
|
}
|
|
|
|
func buildHydrogenResults(plates []string, vehicles map[string]AuthorizedVehicle, values map[string]DailyHydrogen, date string) []HydrogenResult {
|
|
results := make([]HydrogenResult, 0, len(plates))
|
|
for _, plate := range plates {
|
|
vehicle := vehicles[plate]
|
|
item := HydrogenResult{VIN: vehicle.VIN, 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.StatisticsStartTime, item.StatisticsEndTime = hydrogenStatisticsInterval(value.EvidenceJSON)
|
|
item.UpdatedAt = validStatisticsTimestamp(value.UpdatedAt)
|
|
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)
|
|
}
|
|
return results
|
|
}
|
|
|
|
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.ReconciledMileageRange(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
|
|
}
|
|
results := buildMileageResults(plates, vehicles, values, date)
|
|
_ = s.repository.Audit(ctx, app.ID, "mileage_query", "success", traceID, len(plates), "")
|
|
return results, nil
|
|
}
|
|
|
|
func buildMileageResults(plates []string, vehicles map[string]AuthorizedVehicle, values map[string]DailyMileage, date string) []MileageResult {
|
|
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[dailyMileageKey(vehicle.VIN, date)]; ok {
|
|
fillMileageResult(&item, value, value.MileageKm)
|
|
}
|
|
results = append(results, item)
|
|
}
|
|
return results
|
|
}
|
|
|
|
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{}
|
|
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.ReconciledMileageRange(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
|
|
}
|
|
}
|
|
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 {
|
|
fillMileageRangeResult(&item, value, value.MileageKm)
|
|
}
|
|
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
|
|
}
|
|
for _, plate := range plates {
|
|
vehicle, ok := vehicles[plate]
|
|
if !ok || strings.TrimSpace(vehicle.VIN) == "" || (vehicle.Plate != "" && vehicle.Plate != plate) {
|
|
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 > 1096 {
|
|
return nil, "", "", time.Time{}, time.Time{}, 0, nil, fmt.Errorf("%w: date range exceeds 1096 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) {
|
|
if value.DataQuality != "" && value.DataTime == "" {
|
|
fillMileageAnomaly(item)
|
|
return
|
|
}
|
|
if value.Date == item.Date {
|
|
item.StatisticsStartTime, item.StatisticsEndTime = validatedStatisticsInterval(value.StatisticsStartTime, value.DataTime)
|
|
}
|
|
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
|
|
if value.DataQuality != "" {
|
|
item.DataQuality = stringPointer(value.DataQuality)
|
|
if value.DataQuality != "CARRIED_FORWARD" {
|
|
item.Status = StatusDataAnomaly
|
|
item.DailyMileageKm = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func fillMileageRangeResult(item *MileageRangeResult, value DailyMileage, dailyMileage float64) {
|
|
if value.DataQuality != "" && value.DataTime == "" {
|
|
fillMileageRangeAnomaly(item)
|
|
return
|
|
}
|
|
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
|
|
if value.DataQuality != "" {
|
|
item.DataQuality = stringPointer(value.DataQuality)
|
|
if value.DataQuality != "CARRIED_FORWARD" {
|
|
item.Status = StatusDataAnomaly
|
|
item.DailyMileageKm = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|