Ce mécanisme vise à prévenir la soumission répétée de requêtes de commande, ce qui pourrait entraîner la création de commandes dupliquées.
Approches pour la Prévention de la Soumission Multiple
- Côté Client : Désactiver le bouton de commande après le premier clic. Cette méthode est limitée car elle peut être contournée et dégrade l'expérience utilisateur.
- Côté Serveur : Utiliser un aspect (AOP) avec une annotation personnalisée pour intercepter et gérer les requêtes potentiellement dupliquées.
Nous allons nous concentrer sur l'implémentation côté serveur.
Annotation personnalisée @RepeatSubmit
Cette annotation permet de spécifier le type de protection contre la soumission multiple et la durée du verrouillage.
import java.lang.annotation.*;
/**
* Annotation personnalisée pour la prévention de la soumission multiple.
*/
@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatSubmit {
/**
* Type de prévention : basé sur les paramètres de la requête ou sur un token.
*/
enum PreventionType {
PARAMETER, TOKEN
}
/**
* Définit le type de prévention. Par défaut, utilise les paramètres de la requête.
* @return Le type de prévention.
*/
PreventionType preventionType() default PreventionType.PARAMETER;
/**
* Durée du verrouillage en secondes. Par défaut, 5 secondes.
* @return Durée du verrouillage.
*/
long lockDuration() default 5;
}
Aspect de Prévention de Soumission Multiple
Cet aspect intercepte les méthodes annotées avec @RepeatSubmit et implémente la logique de protection.
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;
/**
* Aspect pour la prévention de la soumission multiple des requêtes.
*/
@Aspect
@Component
public class RepeatSubmitAspect {
private static final Logger logger = LoggerFactory.getLogger(RepeatSubmitAspect.class);
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private RedissonClient redissonClient;
/**
* Point de coupure pour les méthodes annotées avec @RepeatSubmit.
* @param repeatSubmit L'annotation @RepeatSubmit.
*/
@Pointcut("@annotation(repeatSubmit)")
public void preventRepeatSubmitPointcut(RepeatSubmit repeatSubmit) {}
/**
* Notification autour de l'exécution de la méthode.
*
* @param joinPoint L'objet représentant la méthode appelée.
* @param repeatSubmit L'annotation @RepeatSubmit attachée à la méthode.
* @return Le résultat de l'appel de la méthode.
* @throws Throwable En cas d'erreur lors de l'exécution.
*/
@Around("preventRepeatSubmitPointcut(repeatSubmit)")
public Object aroundPreventRepeatSubmit(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
// Récupérer l'identifiant de l'utilisateur (exemple, à adapter selon votre système d'authentification)
Long userId = getCurrentUserId(); // Assurez-vous que cette méthode récupère correctement l'ID utilisateur
boolean acquiredLock = false;
RepeatSubmit.PreventionType type = repeatSubmit.preventionType();
if (type == RepeatSubmit.PreventionType.PARAMETER) {
// Protection basée sur les paramètres de la requête
long duration = repeatSubmit.lockDuration();
String clientIp = getClientIpAddress(request);
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
String methodKey = String.format("order-lock:%s-%s-%s-%d", clientIp, method.getDeclaringClass().getName(), method.getName(), userId);
RLock lock = redissonClient.getLock(methodKey);
// Essayer d'acquérir le verrou pendant 2 secondes, avec une durée de validité de 'duration' secondes.
acquiredLock = lock.tryLock(2, duration, TimeUnit.SECONDS);
} else if (type == RepeatSubmit.PreventionType.TOKEN) {
// Protection basée sur un token
String requestToken = request.getHeader("X-Request-Token"); // Utiliser un header personnalisé
if (StringUtils.isEmpty(requestToken)) {
logger.error("Le token de requête est manquant.");
// Adapter l'exception selon votre gestion d'erreurs
throw new CustomApiException("Le token de requête est requis.");
}
String tokenKey = String.format("order-token:%d:%s", userId, requestToken);
// Supprimer le token s'il existe dans Redis, indiquant que la requête est valide et traitée.
acquiredLock = redisTemplate.delete(tokenKey) != null;
}
if (!acquiredLock) {
logger.warn("Tentative de soumission répétée détectée pour l'utilisateur {}.", userId);
// Retourner null ou une réponse appropriée pour indiquer l'échec.
return null;
}
logger.debug("Verrou acquis, traitement de la requête.");
Object result = joinPoint.proceed();
logger.debug("Traitement de la requête terminé.");
return result;
}
/**
* Méthode utilitaire pour obtenir l'adresse IP du client.
* À adapter selon votre configuration réseau (proxies, load balancers).
*/
private String getClientIpAddress(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
return ip;
}
/**
* Méthode placeholder pour récupérer l'ID utilisateur actuel.
* À implémenter en fonction de votre système d'authentification (ex: ThreadLocal, SecurityContext).
*/
private Long getCurrentUserId() {
// Exemple: return SecurityContextHolder.getContext().getAuthentication().getPrincipal().getId();
// Ou si vous utilisez un ThreadLocal comme dans l'exemple original:
// return LoginInterceptor.threadLocal.get().getAccountNo();
// Pour cet exemple, nous retournons une valeur fictive.
return 12345L;
}
}
Configuration Redisson
Cette configuration est nécessaire pour utiliser Redisson afin de gérer les verrous distribués.
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Configuration pour Redisson, une bibliothèque de verrous distribués.
*/
@Configuration
public class RedissonConfiguration {
@Value("${spring.redis.host}")
private String redisHost;
@Value("${spring.redis.port}")
private String redisPort;
@Value("${spring.redis.password}")
private String redisPassword;
/**
* Configuration du client Redisson pour une instance Redis en mode standalone.
* @return Le client Redisson configuré.
*/
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
// Configuration pour une instance Redis en mode standalone
config.useSingleServer()
.setPassword(redisPassword)
.setAddress("redis://" + redisHost + ":" + redisPort);
// Pour une configuration en cluster, décommentez et adaptez la section suivante :
/*
config.useClusterServers()
.setScanInterval(2000) // Intervalle de scan de l'état du cluster
.addNodeAddress("redis://127.0.0.1:7000")
.addNodeAddress("redis://127.0.0.1:7002");
*/
return Redisson.create(config);
}
}
Utilisation de l'Annotation
Appliquez l'annotation @RepeatSubmit aux méthodes de contrôleur qui nécessitent une protection contre la soumission multiple.
Exemple : Obtenir un Token pour la Protection
Cette méthode génère un token unique pour une session utilisateur, à utiliser avant de soumettre une commande.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@RestController
public class OrderController {
@Autowired
private StringRedisTemplate redisTemplate;
// Constante pour la clé Redis, à définir dans une classe de constantes
private static final String SUBMIT_ORDER_TOKEN_KEY_PREFIX = "order:submit:token:";
/**
* Génère et renvoie un token unique pour prévenir la soumission multiple.
* @return Un objet JSON contenant le token généré.
*/
@GetMapping("/order/generate-token")
public JsonData generateOrderToken() {
// Récupérer l'ID utilisateur actuel (à implémenter)
Long userId = getCurrentUserId();
String token = UUID.randomUUID().toString();
String redisKey = SUBMIT_ORDER_TOKEN_KEY_PREFIX + userId + ":" + token;
// Stocker le token dans Redis avec une expiration de 30 minutes
redisTemplate.opsForValue().set(redisKey, String.valueOf(System.currentTimeMillis()), 30, TimeUnit.MINUTES);
// Retourner le token à l'appelant (ex: dans un objet JsonData)
return JsonData.buildSuccess(token);
}
// ... autres méthodes du contrôleur
// Placeholder pour la méthode récupérant l'ID utilisateur
private Long getCurrentUserId() {
// Implémentation à adapter selon votre système d'authentification
return 12345L;
}
// Classe placeholder pour la réponse JSON (à adapter)
static class JsonData {
public static JsonData buildSuccess(Object data) { return new JsonData(); }
}
}
Exemple : Endpoint de Confirmation de Commande
L'annotation @RepeatSubmit est appliquée à la méthode de confirmation de commande, spécifiant l'utilisation du type TOKEN pour la prévention.
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
@RestController
public class OrderController {
// ... autres méthodes
/**
* Confirme une commande après vérification de la prévention de soumission multiple via token.
* @param orderRequest Les détails de la commande à confirmer.
* @param response L'objet HttpServletResponse.
*/
@PostMapping("/order/confirm")
@RepeatSubmit(preventionType = RepeatSubmit.PreventionType.TOKEN)
public void confirmOrder(@RequestBody ConfirmOrderRequest orderRequest, HttpServletResponse response) {
// Logique métier de confirmation de commande ici...
System.out.println("Confirmation de commande initiée...");
}
// Classe placeholder pour la requête de commande (à adapter)
static class ConfirmOrderRequest {}
// ... autres méthodes et classes
}
Alternativement, pour utiliser la protection basée sur les paramètres de la requête :
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderController {
// ... autres méthodes
/**
* Endpoint protégé par la prévention de soumission basée sur les paramètres.
* @param orderRequest Les détails de la commande.
*/
@PostMapping("/order/submit-params")
@RepeatSubmit(preventionType = RepeatSubmit.PreventionType.PARAMETER, lockDuration = 10) // Verrou de 10 secondes
public void submitOrderWithParams(@RequestBody OrderDetails orderRequest) {
// Logique métier...
System.out.println("Soumission de commande via paramètres...");
}
// Classe placeholder pour les détails de commande (à adapter)
static class OrderDetails {}
// ... autres méthodes et classes
}