Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -54,7 +56,7 @@ public ResponseEntity<AuthenticationResponse> getToken(
}

@PostMapping("/refresh-token")
public ResponseEntity<AuthenticationResponse> refreshToken(HttpServletRequest request) {
public ResponseEntity<AuthenticationResponse> refreshToken(@Valid @RequestBody RefreshTokenRequest request) {
return ResponseEntity.ok(service.refreshToken(request));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
}
""";
Expand Down
13 changes: 11 additions & 2 deletions src/main/java/com/netfliz/netfliz/constant/CacheKey.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> keys) {
return String.join("_", keys);
return String.join(":", keys);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
127 changes: 90 additions & 37 deletions src/main/java/com/netfliz/netfliz/service/MovieService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -35,6 +36,7 @@
import org.springframework.util.CollectionUtils;

import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;

@Service
Expand All @@ -55,7 +57,8 @@ public class MovieService implements MoviesApiDelegate {
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<MoviePage> getAllMovie(Integer page, Integer pageSize, String filter, String sort) {
Specification<MovieEntity> 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(" ");
Expand All @@ -65,8 +68,7 @@ public ResponseEntity<MoviePage> 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<MovieEntity> resultPage = movieRepository.findAllByFieldName(field, value, pageable);
Expand All @@ -79,18 +81,62 @@ public ResponseEntity<MoviePage> getAllMovie(Integer page, Integer pageSize, Str

@Override
public ResponseEntity<Movie> 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
Expand Down Expand Up @@ -168,7 +214,8 @@ public ResponseEntity<List<Movie>> bulkMovie(List<Movie> movies) {
public ResponseEntity<List<MovieByGenreResponse>> 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)) {
Expand All @@ -180,7 +227,8 @@ public ResponseEntity<List<MovieByGenreResponse>> getMoviesByGenres(MovieByGenre
return ResponseEntity.ok(new ArrayList<>());
}

Map<String, List<MovieByGenreDto>> map = listDto.stream().collect(Collectors.groupingBy(MovieByGenreDto::getName));
Map<String, List<MovieByGenreDto>> map = listDto.stream()
.collect(Collectors.groupingBy(MovieByGenreDto::getName));
List<MovieByGenreResponse> responses = new ArrayList<>();

map.forEach((key, value) -> {
Expand Down Expand Up @@ -213,23 +261,28 @@ public ResponseEntity<MoviePage> 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) {
Expand Down Expand Up @@ -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<MovieImage> getUpdateImage(List<MovieImage> images, Long movieId) {
Map<Long, List<MovieImageEntity>> map = movieImageRepository.findByObjectIdAndObjectType(movieId, MovieObjectType.MOVIE)
Map<Long, List<MovieImageEntity>> map = movieImageRepository
.findByObjectIdAndObjectType(movieId, MovieObjectType.MOVIE)
.stream()
.collect(Collectors.groupingBy(MovieImageEntity::getFileId));

Expand Down Expand Up @@ -342,19 +396,18 @@ private void mapMovieImageAndAsset(List<Movie> movies, Collection<Long> movieIds
.stream()
.collect(Collectors.groupingBy(MovieImageEntity::getObjectId));

Map<Long, List<MovieAssetEntity>> mapAsset = movieAssetRepository.findByObjectIds(movieIds, MovieObjectType.MOVIE)
Map<Long, List<MovieAssetEntity>> 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()));
});
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/netfliz/netfliz/service/RedisService.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import java.time.Duration;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;

@Service
@AllArgsConstructor
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
}