feat: expand vehicle data platform capabilities

This commit is contained in:
lingniu
2026-07-27 16:46:15 +08:00
parent e3a1f80f86
commit 3c4bece72c
650 changed files with 62155 additions and 2552 deletions

View File

@@ -0,0 +1,732 @@
package openplatform
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"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)
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)
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)
}
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 && 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
}
missingVINs := missingMileageVINs(vins, values)
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 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{}
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
}
missingVINs := missingMileageRangeInitialVINs(positions, values)
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 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) []string {
missing := make([]string, 0)
for _, vin := range vins {
if value, ok := values[vin]; !ok || !validDailyMileage(value) {
missing = append(missing, vin)
}
}
return missing
}
func missingMileageRangeInitialVINs(positions []mileageRangePosition, values map[string]DailyMileage) []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{}{}
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
}
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
}