From dcb63f993af9f333c08dc66f3a41ca186d4f8082 Mon Sep 17 00:00:00 2001 From: sonpt1 Date: Tue, 10 Feb 2026 15:03:00 +0700 Subject: [PATCH 1/2] update get movie by id --- .../netfliz/netfliz/constant/CacheKey.java | 13 +- .../netfliz/netfliz/service/MovieService.java | 127 +++++++++++++----- .../netfliz/netfliz/service/RedisService.java | 12 ++ 3 files changed, 113 insertions(+), 39 deletions(-) diff --git a/src/main/java/com/netfliz/netfliz/constant/CacheKey.java b/src/main/java/com/netfliz/netfliz/constant/CacheKey.java index 8cc1a3e..83a297f 100644 --- a/src/main/java/com/netfliz/netfliz/constant/CacheKey.java +++ b/src/main/java/com/netfliz/netfliz/constant/CacheKey.java @@ -12,17 +12,26 @@ public class CacheKey { public static final String CACHE_PRESIGN_URL = "CACHE_PRESIGN_URL"; + // Movie + public static final String CACHE_MOVIE = "movie"; + + // Lock key + public static final String LOCK_MOVIE = "lock:movie"; + // Cache time + public static final Integer LOCK_MOVIE_TTL = 10; + + public static final Integer CACHE_FIVE_MINUTE = 60 * 5; public static final Integer CACHE_ONE_MINUTE = 60; public static final Integer CACHE_HAFT_HOUR = 60 * 30; public static final Integer CACHE_ONE_HOUR = 60 * 60; public static final Integer CACHE_ONE_DAY = 24 * 60 * 60; public static String buildKey(String... keys) { - return String.join("_", keys); + return String.join(":", keys); } public static String buildKey(Collection keys) { - return String.join("_", keys); + return String.join(":", keys); } } diff --git a/src/main/java/com/netfliz/netfliz/service/MovieService.java b/src/main/java/com/netfliz/netfliz/service/MovieService.java index 81b1ad8..8774520 100644 --- a/src/main/java/com/netfliz/netfliz/service/MovieService.java +++ b/src/main/java/com/netfliz/netfliz/service/MovieService.java @@ -8,6 +8,7 @@ import com.netfliz.netfliz.entity.enums.MovieAssetType; import com.netfliz.netfliz.entity.enums.MovieImageType; import com.netfliz.netfliz.entity.enums.MovieObjectType; +import com.netfliz.netfliz.exception.BadRequestException; import com.netfliz.netfliz.exception.NotFoundException; import com.netfliz.netfliz.mapper.MovieAssetMapper; import com.netfliz.netfliz.mapper.MovieImageMapper; @@ -35,6 +36,7 @@ import org.springframework.util.CollectionUtils; import java.util.*; +import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; @Service @@ -55,7 +57,8 @@ public class MovieService implements MoviesApiDelegate { @PreAuthorize("hasRole('ADMIN')") public ResponseEntity getAllMovie(Integer page, Integer pageSize, String filter, String sort) { Specification specification = null; - Pageable pageable = PageRequest.of(page > 0 ? page - 1 : page, pageSize, Sort.by(Sort.Direction.DESC, "updatedAt")); + Pageable pageable = PageRequest.of(page > 0 ? page - 1 : page, pageSize, + Sort.by(Sort.Direction.DESC, "updatedAt")); if (filter != null && !filter.isEmpty()) { String[] filterArray = filter.split(" "); @@ -65,8 +68,7 @@ public ResponseEntity getAllMovie(Integer page, Integer pageSize, Str specification = (root, query, criteriaBuilder) -> { return criteriaBuilder.and( - criteriaBuilder.equal(root.get(field), value) - ); + criteriaBuilder.equal(root.get(field), value)); }; Page resultPage = movieRepository.findAllByFieldName(field, value, pageable); @@ -79,18 +81,62 @@ public ResponseEntity getAllMovie(Integer page, Integer pageSize, Str @Override public ResponseEntity getMovieById(Long movieId) { - movieValidator.validateMovieExist(movieId); + if (Objects.isNull(movieId)) { + throw new BadRequestException("Movie id is required"); + } - MovieEntity movieEntity = movieRepository.findById(movieId).orElseThrow( - () -> new NotFoundException("Movie not found with id: " + movieId) - ); + String cacheKey = CacheKey.buildKey(CacheKey.CACHE_MOVIE, String.valueOf(movieId)); + String lockKey = CacheKey.buildKey(CacheKey.LOCK_MOVIE, String.valueOf(movieId)); + var cachedMovie = redisService.get(cacheKey, Movie.class); + Boolean locked = redisService.tryLock(lockKey, CacheKey.LOCK_MOVIE_TTL); - Movie movie = movieMapper.mapFromEntity(movieEntity); + if (Objects.nonNull(cachedMovie)) { + return ResponseEntity.ok(cachedMovie); + } + + // acquire lock + if (Boolean.TRUE.equals(locked)) { + try { + // double check + Movie cachedAgain = redisService.get(cacheKey, Movie.class); + if (cachedAgain != null) + return ResponseEntity.ok(cachedAgain); + + // fetch + MovieEntity movieEntity = movieRepository.findById(movieId).orElse(null); + if (Objects.isNull(movieEntity)) { + // cache null for 1 minute (avoid cache stampede) + redisService.set(cacheKey, "NULL", 60); + throw new NotFoundException("Movie not found with id: " + movieId); + } + + Movie movie = movieMapper.mapFromEntity(movieEntity); + + // map image and asset + mapMovieImageAndAsset(List.of(movie), List.of(movieId)); + + // Cache with random ttl (1 hour + random 20 minutes) + long ttl = redisService.randomTtl(CacheKey.CACHE_ONE_HOUR, 60 * 20); + redisService.set(cacheKey, movie, ttl); + + return ResponseEntity.ok(movie); + } finally { + redisService.unlock(lockKey); + } + } - // map image and asset - mapMovieImageAndAsset(List.of(movie), List.of(movieId)); + // Không lấy được lock -> chờ cache ready + Movie waitedMovie = waitAndGetCache(cacheKey, CacheKey.LOCK_MOVIE_TTL); + if (waitedMovie != null) { + return ResponseEntity.ok(waitedMovie); + } - return ResponseEntity.ok(movie); + // Timeout mà cache vẫn chưa có -> fallback query DB + MovieEntity fallbackEntity = movieRepository.findById(movieId) + .orElseThrow(() -> new NotFoundException("Movie not found with id: " + movieId)); + Movie fallbackMovie = movieMapper.mapFromEntity(fallbackEntity); + mapMovieImageAndAsset(List.of(fallbackMovie), List.of(movieId)); + return ResponseEntity.ok(fallbackMovie); } @Override @@ -168,7 +214,8 @@ public ResponseEntity> bulkMovie(List movies) { public ResponseEntity> getMoviesByGenres(MovieByGenreRequest request) { request.validate(); String genreKey = CacheKey.buildKey(request.getGenres()); - String cacheKey = CacheKey.buildKey(CacheKey.CACHE_MOVIE_BY_GENRES, genreKey, String.valueOf(request.getLimit())); + String cacheKey = CacheKey.buildKey(CacheKey.CACHE_MOVIE_BY_GENRES, genreKey, + String.valueOf(request.getLimit())); var cachedMovieByGenreList = redisService.getList(cacheKey, MovieByGenreResponse.class); if (Objects.nonNull(cachedMovieByGenreList)) { @@ -180,7 +227,8 @@ public ResponseEntity> getMoviesByGenres(MovieByGenre return ResponseEntity.ok(new ArrayList<>()); } - Map> map = listDto.stream().collect(Collectors.groupingBy(MovieByGenreDto::getName)); + Map> map = listDto.stream() + .collect(Collectors.groupingBy(MovieByGenreDto::getName)); List responses = new ArrayList<>(); map.forEach((key, value) -> { @@ -213,23 +261,28 @@ public ResponseEntity getMoviesByFilter(MovieFilterRequest request) { return ResponseEntity.ok(buildPage(resultPage)); } - public String setFilterQuery(String filter) { - String query = ""; - - if (filter != null && !filter.isEmpty()) { - String[] filterArray = filter.split(" "); - String operator = Arrays.stream(filterArray).skip(1).findFirst().get(); - String value = Arrays.stream(filterArray).skip(2).findFirst().get(); - - query = switch (operator) { - case "ne" -> " != " + value; - case "in" -> " like " + "%" + value + "%"; - case "nin" -> " not like " + "%" + value + "%"; - default -> " = " + value; - }; + /** + * Chờ cache ready bằng cách poll Redis mỗi 100ms. + * + * @param cacheKey key cần kiểm tra + * @param timeoutSeconds thời gian tối đa chờ (giây) + * @return Movie từ cache, hoặc null nếu timeout + */ + private Movie waitAndGetCache(String cacheKey, long timeoutSeconds) { + long deadline = System.currentTimeMillis() + timeoutSeconds * 1000; + while (System.currentTimeMillis() < deadline) { + Movie cached = redisService.get(cacheKey, Movie.class); + if (cached != null) { + return cached; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } } - - return query; + return null; } public void updateMovieImage(Long movieId, Movie movie) { @@ -287,7 +340,8 @@ public void updateMovieAssets(Long movieId, Movie movie) { * Lấy ra các image cần update (chưa tồn tại trong db) */ public List getUpdateImage(List images, Long movieId) { - Map> map = movieImageRepository.findByObjectIdAndObjectType(movieId, MovieObjectType.MOVIE) + Map> map = movieImageRepository + .findByObjectIdAndObjectType(movieId, MovieObjectType.MOVIE) .stream() .collect(Collectors.groupingBy(MovieImageEntity::getFileId)); @@ -342,19 +396,18 @@ private void mapMovieImageAndAsset(List movies, Collection movieIds .stream() .collect(Collectors.groupingBy(MovieImageEntity::getObjectId)); - Map> mapAsset = movieAssetRepository.findByObjectIds(movieIds, MovieObjectType.MOVIE) + Map> mapAsset = movieAssetRepository + .findByObjectIds(movieIds, MovieObjectType.MOVIE) .stream() .collect(Collectors.groupingBy(MovieAssetEntity::getObjectId)); movies.forEach(movie -> { Optional.ofNullable(mapImage.get(movie.getId())) - .ifPresent(movieImages -> - movie.setImages(movieImages.stream().map(movieImageMapper::mapFromEntity).toList()) - ); + .ifPresent(movieImages -> movie + .setImages(movieImages.stream().map(movieImageMapper::mapFromEntity).toList())); Optional.ofNullable(mapAsset.get(movie.getId())) - .ifPresent(movieAssets -> - movie.setAssets(movieAssets.stream().map(movieAssetMapper::mapFromEntity).toList()) - ); + .ifPresent(movieAssets -> movie + .setAssets(movieAssets.stream().map(movieAssetMapper::mapFromEntity).toList())); }); } } diff --git a/src/main/java/com/netfliz/netfliz/service/RedisService.java b/src/main/java/com/netfliz/netfliz/service/RedisService.java index b88093c..34a2ba8 100644 --- a/src/main/java/com/netfliz/netfliz/service/RedisService.java +++ b/src/main/java/com/netfliz/netfliz/service/RedisService.java @@ -8,6 +8,7 @@ import java.time.Duration; import java.util.List; +import java.util.concurrent.ThreadLocalRandom; @Service @AllArgsConstructor @@ -26,6 +27,14 @@ public void set(String key, Object value) { setObj(key, value, DEFAULT_TTL); } + public Boolean tryLock(String key, long ttlSeconds) { + return redisTemplate.opsForValue().setIfAbsent(key, "locked", Duration.ofSeconds(ttlSeconds)); + } + + public void unlock(String key) { + redisTemplate.delete(key); + } + private void setObj(String key, Object value, long ttlSeconds) { try { String json = objectMapper.writeValueAsString(value); @@ -69,4 +78,7 @@ public boolean hasKey(String key) { return redisTemplate.hasKey(key); } + public long randomTtl(int origin, int bound) { + return ThreadLocalRandom.current().nextInt(origin, origin + bound); + } } From 48cbd5bd93fbcd3bbda51046d81cc56053ca7fde Mon Sep 17 00:00:00 2001 From: sonpt1 Date: Thu, 12 Feb 2026 15:39:05 +0700 Subject: [PATCH 2/2] update auth --- .../netfliz/api/AuthenticationController.java | 4 +++- .../netfliz/config/JwtAuthenticationFilter.java | 4 ++-- .../netfliz/model/request/RefreshTokenRequest.java | 10 ++++++++++ .../netfliz/service/AuthenticationService.java | 14 +++++--------- 4 files changed, 20 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/netfliz/netfliz/model/request/RefreshTokenRequest.java diff --git a/src/main/java/com/netfliz/netfliz/api/AuthenticationController.java b/src/main/java/com/netfliz/netfliz/api/AuthenticationController.java index ef99cc1..efd7ecc 100644 --- a/src/main/java/com/netfliz/netfliz/api/AuthenticationController.java +++ b/src/main/java/com/netfliz/netfliz/api/AuthenticationController.java @@ -1,12 +1,14 @@ package com.netfliz.netfliz.api; import com.netfliz.netfliz.model.request.AuthenticationRequest; +import com.netfliz.netfliz.model.request.RefreshTokenRequest; import com.netfliz.netfliz.model.response.AuthenticationResponse; import com.netfliz.netfliz.service.AuthenticationService; import com.netfliz.netfliz.model.request.RegisterRequest; import com.netfliz.netfliz.model.User; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -54,7 +56,7 @@ public ResponseEntity getToken( } @PostMapping("/refresh-token") - public ResponseEntity refreshToken(HttpServletRequest request) { + public ResponseEntity refreshToken(@Valid @RequestBody RefreshTokenRequest request) { return ResponseEntity.ok(service.refreshToken(request)); } diff --git a/src/main/java/com/netfliz/netfliz/config/JwtAuthenticationFilter.java b/src/main/java/com/netfliz/netfliz/config/JwtAuthenticationFilter.java index 3b46c75..a0c2bab 100644 --- a/src/main/java/com/netfliz/netfliz/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/netfliz/netfliz/config/JwtAuthenticationFilter.java @@ -96,13 +96,13 @@ protected void doFilterInternal( } private void writeJsonForbidden(HttpServletResponse response) throws IOException { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("UTF-8"); String json = """ { - "code": 403, + "code": 401, "message": "Token không hợp lệ hoặc đã hết hạn!" } """; diff --git a/src/main/java/com/netfliz/netfliz/model/request/RefreshTokenRequest.java b/src/main/java/com/netfliz/netfliz/model/request/RefreshTokenRequest.java new file mode 100644 index 0000000..1a3b45e --- /dev/null +++ b/src/main/java/com/netfliz/netfliz/model/request/RefreshTokenRequest.java @@ -0,0 +1,10 @@ +package com.netfliz.netfliz.model.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +public class RefreshTokenRequest { + @NotBlank(message = "Không để trống refresh token") + private String refreshToken; +} diff --git a/src/main/java/com/netfliz/netfliz/service/AuthenticationService.java b/src/main/java/com/netfliz/netfliz/service/AuthenticationService.java index 445346b..f9ca626 100644 --- a/src/main/java/com/netfliz/netfliz/service/AuthenticationService.java +++ b/src/main/java/com/netfliz/netfliz/service/AuthenticationService.java @@ -10,6 +10,7 @@ import com.netfliz.netfliz.mapper.UserMapper; import com.netfliz.netfliz.model.User; import com.netfliz.netfliz.model.request.AuthenticationRequest; +import com.netfliz.netfliz.model.request.RefreshTokenRequest; import com.netfliz.netfliz.model.request.RegisterRequest; import com.netfliz.netfliz.model.response.AuthenticationResponse; import com.netfliz.netfliz.repository.IProfileRepository; @@ -18,6 +19,7 @@ import com.netfliz.netfliz.util.CommonUtils; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; @@ -173,15 +175,9 @@ private void revokeAllUserTokens(Long userId) { tokenRepository.deleteAllByUserId(userId); } - public AuthenticationResponse refreshToken(HttpServletRequest request) { - final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - final String refreshToken; - final String username; - if (authHeader == null || !authHeader.startsWith("Bearer ")) { - throw new BadCredentialException("Invalid token"); - } - refreshToken = authHeader.substring(7); - username = jwtService.extractUsername(refreshToken); + public AuthenticationResponse refreshToken(@Valid RefreshTokenRequest request) { + final String refreshToken = request.getRefreshToken(); + final String username = jwtService.extractUsername(refreshToken); if (username != null) { var user = this.userRepository.findByUsername(username) .orElseThrow();