733 lines
26 KiB
Go
733 lines
26 KiB
Go
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
|
||
}
|