RewardsService.java
package com.openclassrooms.tourguide.service;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.PriorityQueue;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.openclassrooms.tourguide.user.User;
import com.openclassrooms.tourguide.user.UserReward;
import gpsUtil.GpsUtil;
import gpsUtil.location.Attraction;
import gpsUtil.location.Location;
import gpsUtil.location.VisitedLocation;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import rewardCentral.RewardCentral;
@Service
@Slf4j
public class RewardsService {
private static final double STATUTE_MILES_PER_NAUTICAL_MILE = 1.15077945;
private final int defaultProximityBuffer = 10;
private int proximityBuffer = defaultProximityBuffer;
private final int attractionProximityRange = 200;
private final GpsUtil gpsUtil;
private final RewardCentral rewardsCentral;
private final Map<String, Double> distanceCache = new ConcurrentHashMap<>();
private final ExecutorService executor;
private static final int MAX_THREADS = 64;
private static final Semaphore semaphore = new Semaphore(MAX_THREADS);
private int maxAttractionsToCheck = 10;
private List<User> allUsers = new ArrayList<>();
/**
* Creates a rewards management service with GPS and RewardCentral services, and
* a thread pool for asynchronous tasks.
*
* @param gpsUtil Geolocation service
* @param rewardCentral Service to get reward points
* @param executorService Executor for parallel tasks
* @throws IllegalStateException if the executor is null or already arrested
*/
public RewardsService(GpsUtil gpsUtil, RewardCentral rewardCentral, ExecutorService executorService) {
this.gpsUtil = gpsUtil;
this.rewardsCentral = rewardCentral;
this.executor = executorService;
if (executor == null || executor.isShutdown()) {
throw new IllegalStateException("ExecutorService must be initialized and active");
}
}
/**
* Sets the maximum number of attractions to analyze to calculate rewards.
*
* @param maxAttractionsToCheck Limit on the number of attractions to be
* considered
*/
public void setMaxAttractionsToCheck(int maxAttractionsToCheck) {
this.maxAttractionsToCheck = maxAttractionsToCheck;
}
/**
* Cleanly shuts down the thread pool if it is still active.
*/
@PreDestroy
public void shutdownExecutor() {
if (!executor.isShutdown()) {
executor.shutdown();
}
}
/**
* Replaces the list of service users with a new one.
*
* @param allUsers List of users to register
* @throws IllegalStateException if the list is empty or null
*/
public void setAllUsers(List<User> allUsers) {
if (allUsers == null || allUsers.isEmpty()) {
throw new IllegalStateException("User list is empty. Cannot initialize users.");
}
if (this.allUsers == null) {
this.allUsers = new ArrayList<>();
} else {
this.allUsers.clear();
}
this.allUsers.addAll(allUsers);
}
/**
* Changes the maximum distance to consider an attraction as close.
*
* @param proximityBuffer Distance value to use
*/
public void setProximityBuffer(int proximityBuffer) {
this.proximityBuffer = proximityBuffer;
}
/**
* Resets the proximity distance to its default value.
*/
public void setDefaultProximityBuffer() {
this.proximityBuffer = defaultProximityBuffer;
}
/**
* Calculates rewards for a user based on their past visits.
*
* @param user Concerned user
* @param attractions List of available attractions
*/
public void calculateRewards(User user, List<Attraction> attractions) {
if (Math.abs(user.getUserId().hashCode()) % 5000 == 0) {
log.info("User: {}, visitedLocations: {}, attractionsToCheck: {}",
user.getUserName(), user.getVisitedLocations().size(), attractions.size());
}
List<VisitedLocation> userLocations = user.getVisitedLocations();
PriorityQueue<Attraction> closestAttractions = new PriorityQueue<>(
Comparator.comparingDouble(
attraction -> -cachedDistance(user.getLastVisitedLocation().location, attraction)));
for (Attraction attraction : attractions) {
closestAttractions.offer(attraction);
if (closestAttractions.size() > maxAttractionsToCheck) {
closestAttractions.poll();
}
}
List<Attraction> attractionsToCheck = new ArrayList<>(closestAttractions);
Set<UUID> rewardedAttractionIds = user.getUserRewards().stream()
.map(r -> r.attraction.attractionId)
.collect(Collectors.toSet());
for (VisitedLocation visitedLocation : userLocations) {
for (Attraction attraction : attractionsToCheck) {
if (nearAttraction(visitedLocation, attraction)
&& !rewardedAttractionIds.contains(attraction.attractionId)) {
int points = getRewardPoints(attraction, user);
user.addUserReward(new UserReward(visitedLocation, attraction, points));
rewardedAttractionIds.add(attraction.attractionId);
}
}
}
}
/**
* Calculates rewards for a user asynchronously (in the background).
*
* @param user Concerned user
* @param attractions List of available attractions
* @return An asynchronous task representing the current computation
*/
public CompletableFuture<Void> calculateRewardsAsync(User user, List<Attraction> attractions) {
return CompletableFuture.runAsync(() -> {
try {
semaphore.acquire();
calculateRewards(user, attractions);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Thread interrupted", e);
} finally {
semaphore.release();
}
}, executor);
}
/**
* Calculates rewards for a list of users in parallel.
*
* @param users User list
* @param attractions List of attractions to consider
*/
public void calculateRewardsForAllUsers(List<User> users, List<Attraction> attractions) {
List<CompletableFuture<Void>> futures = users.stream()
.map(user -> calculateRewardsAsync(user, attractions))
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
/**
* Creates a unique key to associate a geographic location with an attraction.
*
* Latitude and longitude coordinates are rounded to two decimal places to
* reduce precision and improve cache reuse.*
*
* @param location Reference location
* @param attraction Attraction concerned
* @return A string representing the associated cache key
*/
private String getCacheKey(Location location, Attraction attraction) {
double lat = Math.round(location.latitude * 100.0) / 100.0;
double lon = Math.round(location.longitude * 100.0) / 100.0;
return lat + "," + lon + "_" + attraction.attractionId;
}
/**
* Calculates the distance between a location and an attraction with caching.
*
* If the distance has already been calculated for this location and attraction
* combination, it is retrieved from the cache. Otherwise, it is calculated and
* stored.
*
* @param location Reference location
* @param attraction Target attraction
* @return Distance between location and attraction (in miles)
*/
private double cachedDistance(Location location, Attraction attraction) {
String key = getCacheKey(location, attraction);
return distanceCache.computeIfAbsent(key, k -> getDistance(location, attraction));
}
/**
* Check if an attraction is close enough to a location.
*
* @param attraction Attraction to analyze
* @param location Position à comparer
* @return true si la distance est inférieure à la limite, false sinon
*/
public boolean isWithinAttractionProximity(Attraction attraction, Location location) {
return getDistance(attraction, location) <= attractionProximityRange;
}
private boolean nearAttraction(VisitedLocation visitedLocation, Attraction attraction) {
return getDistance(attraction, visitedLocation.location) <= proximityBuffer;
}
private final Cache<String, Integer> rewardPointsCache = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build();
/**
* Returns the number of reward points earned by a user at a given attraction.
* Uses a cache to speed up the result.*
*
* @param attraction The attraction visited
* @param user The user concerned
* @return Number of points awarded
*/
public int getRewardPoints(Attraction attraction, User user) {
String key = attraction.attractionName + ":" + user.getUserId();
return rewardPointsCache.get(key, k -> Math.max(
rewardsCentral.getAttractionRewardPoints(attraction.attractionId, user.getUserId()),
1));
}
/**
* Calculates the distance between two geographic locations.
*
* @param loc1 First point (latitude, longitude)
* @param loc2 Second point
* @return Distance between the two locations in miles
*/
public double getDistance(Location loc1, Location loc2) {
double lat1 = Math.toRadians(loc1.latitude);
double lon1 = Math.toRadians(loc1.longitude);
double lat2 = Math.toRadians(loc2.latitude);
double lon2 = Math.toRadians(loc2.longitude);
double angle = Math.acos(Math.sin(lat1) * Math.sin(lat2)
+ Math.cos(lat1) * Math.cos(lat2) * Math.cos(lon1 - lon2));
double nauticalMiles = 60 * Math.toDegrees(angle);
return STATUTE_MILES_PER_NAUTICAL_MILE * nauticalMiles;
}
}