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) 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) } func (s *Service) QueryRealtimeVehicles(ctx context.Context, appKey, traceID string, request RealtimeVehicleRequest) ([]RealtimeVehicleResult, error) { now := s.now().In(s.location) plates, err := normalizePlates(request.PlateNumbers, 2000) if err != nil { return nil, err } app, vehicles, err := s.authorize(ctx, appKey, plates, now, now) if err != nil { _ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "denied", traceID, len(plates), err.Error()) return nil, err } if len(plates) == 0 { plates = vehiclePlates(vehicles) } points, err := s.repository.RealtimeVehicles(ctx, vehicleVINs(vehicles), now) if err != nil { _ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "error", traceID, len(plates), err.Error()) return nil, err } results := make([]RealtimeVehicleResult, 0, len(plates)) for _, plate := range plates { vehicle := vehicles[plate] item := RealtimeVehicleResult{VIN: vehicle.VIN, PlateNumber: plate, MotionStatus: "offline", Status: StatusNoData} if point, ok := points[vehicle.VIN]; ok { difference := int64(now.Sub(point.ObservedAt.In(s.location)).Seconds()) if difference < 0 { difference = 0 } item.Protocol = point.Protocol item.RecordTime = point.ObservedAt.In(s.location).Format("2006-01-02 15:04:05") item.TimeDifferenceSeconds = &difference item.Online = point.Online 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 if validCoordinate(point.Longitude, point.Latitude) { longitude, latitude := point.Longitude, point.Latitude item.Longitude, item.Latitude = &longitude, &latitude item.LocationAvailable = true } item.Status = StatusNormal } results = append(results, item) } _ = s.repository.Audit(ctx, app.ID, "realtime_vehicle_query", "success", traceID, len(results), "") return results, nil } func (s *Service) QueryHydrogenStations(ctx context.Context, appKey, traceID string, request HydrogenStationRequest) ([]HydrogenStation, error) { now := s.now().In(s.location) if !appKeyPattern.MatchString(appKey) { return nil, ErrUnauthorized } request.Province = strings.TrimSpace(request.Province) request.City = strings.TrimSpace(request.City) if len([]rune(request.Province)) > 32 || len([]rune(request.City)) > 32 { return nil, fmt.Errorf("%w: province or city too long", ErrInvalidRequest) } app, err := s.repository.Authenticate(ctx, sha256.Sum256([]byte(strings.ToLower(appKey))), now, now, now) if err != nil { _ = s.repository.Audit(ctx, 0, "hydrogen_station_query", "denied", traceID, 0, ErrUnauthorized.Error()) return nil, ErrUnauthorized } stations, err := s.repository.HydrogenStations(ctx, request) if err != nil { _ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "error", traceID, 0, err.Error()) return nil, err } _ = s.repository.Audit(ctx, app.ID, "hydrogen_station_query", "success", traceID, 0, "") return stations, nil } func validCoordinate(longitude, latitude float64) bool { return longitude >= -180 && longitude <= 180 && latitude >= -90 && latitude <= 90 && !(longitude == 0 && latitude == 0) } type Service struct { repository Repository now func() time.Time location *time.Location } type parsedGrant struct { VIN string ValidFrom time.Time ValidTo *time.Time } func NewService(repository Repository) *Service { return &Service{ repository: repository, now: time.Now, location: time.FixedZone("Asia/Shanghai", 8*60*60), } } func (s *Service) QueryHydrogen(ctx context.Context, appKey, traceID string, request QueryRequest) ([]HydrogenResult, error) { plates, date, start, end, err := s.validateQuery(request) if err != nil { return nil, err } app, vehicles, err := s.authorize(ctx, appKey, plates, start, end) if err != nil { _ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "denied", traceID, len(plates), err.Error()) return nil, err } if len(plates) == 0 { plates = vehiclePlates(vehicles) } vins := vehicleVINs(vehicles) values, err := s.repository.DailyHydrogen(ctx, vins, date) if err != nil { _ = s.repository.Audit(ctx, app.ID, "hydrogen_query", "error", traceID, len(plates), err.Error()) return nil, err } results := make([]HydrogenResult, 0, len(plates)) for _, plate := range plates { vehicle := vehicles[plate] item := HydrogenResult{PlateNumber: plate, Date: date, Status: StatusNoData} // Sampling sufficiency is decided by the producer and persisted in // quality_status. Imported refuelling-ledger rows can be authoritative // with one transaction, while pressure-derived rows require two samples. if value, ok := values[vehicle.VIN]; ok && 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 }