Files
lingniu-vehicle-ingest/vehicle-data-platform/apps/api/internal/openplatform/service.go
T
lingniu d6b957dfc8 feat: sync vehicle monitoring and accumulated platform updates
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.
2026-10-26 18:05:59 +08:00

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
}