Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/openplatform/service.go
T

1070 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
if strings.EqualFold(value.QualityStatus, "OK") {
consumption := round3(value.ConsumptionKg)
item.HydrogenConsumptionKg = &consumption
item.Status = StatusNormal
}
}
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
}